mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 17:23:25 +00:00
ADD - sky flappy phone game
This commit is contained in:
@@ -0,0 +1,24 @@
|
||||
import crashUrl from '@/assets/audio/sky-flappy/crash.wav?url'
|
||||
import flapUrl from '@/assets/audio/sky-flappy/flap.wav?url'
|
||||
import pointUrl from '@/assets/audio/sky-flappy/point.wav?url'
|
||||
|
||||
export type SkyFlappySound = 'crash' | 'flap' | 'point'
|
||||
const urls: Record<SkyFlappySound, string> = { crash: crashUrl, flap: flapUrl, point: pointUrl }
|
||||
const pools = new Map<SkyFlappySound, HTMLAudioElement[]>()
|
||||
|
||||
export function playSkyFlappySound(sound: SkyFlappySound, enabled: boolean): void {
|
||||
if (!enabled) return
|
||||
let players = pools.get(sound)
|
||||
if (!players) {
|
||||
players = Array.from({ length: 3 }, () => {
|
||||
const player = new Audio(urls[sound])
|
||||
player.preload = 'auto'
|
||||
player.volume = 0.84
|
||||
return player
|
||||
})
|
||||
pools.set(sound, players)
|
||||
}
|
||||
const player = players.find((candidate) => candidate.paused) ?? players[0]
|
||||
player.currentTime = 0
|
||||
void player.play().catch((error: unknown) => console.error(`[Sky Flappy audio] Failed to play ${sound}`, error))
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
createSkyFlappyGame,
|
||||
FLAPPY_GAP_HEIGHT,
|
||||
FLAPPY_MAX_GAP_TOP,
|
||||
FLAPPY_MIN_GAP_TOP,
|
||||
flapSkyGlider,
|
||||
stepSkyFlappy,
|
||||
} from './engine'
|
||||
|
||||
describe('sky flappy engine', () => {
|
||||
it('starts in a ready state', () => {
|
||||
expect(createSkyFlappyGame()).toMatchObject({ playerY: 48, score: 0, status: 'ready' })
|
||||
})
|
||||
|
||||
it('starts and applies an upward impulse on flap', () => {
|
||||
const state = flapSkyGlider(createSkyFlappyGame())
|
||||
expect(state.status).toBe('playing')
|
||||
expect(state.playerVelocity).toBeLessThan(0)
|
||||
})
|
||||
|
||||
it('applies deterministic time-based physics', () => {
|
||||
const state = flapSkyGlider(createSkyFlappyGame())
|
||||
expect(stepSkyFlappy(state, 0.1, () => 0.5)).toEqual(
|
||||
stepSkyFlappy(state, 0.1, () => 0.5),
|
||||
)
|
||||
})
|
||||
|
||||
it('keeps generated gaps inside playable bounds', () => {
|
||||
const state = flapSkyGlider(createSkyFlappyGame())
|
||||
const low = stepSkyFlappy(state, 0.01, () => 0)
|
||||
const high = stepSkyFlappy(state, 0.01, () => 1)
|
||||
expect(low.obstacles[0].gapTop).toBe(FLAPPY_MIN_GAP_TOP)
|
||||
expect(high.obstacles[0].gapTop).toBe(FLAPPY_MAX_GAP_TOP)
|
||||
expect(high.obstacles[0].gapTop + FLAPPY_GAP_HEIGHT).toBeLessThan(100)
|
||||
})
|
||||
|
||||
it('scores an obstacle only once', () => {
|
||||
const state = {
|
||||
...flapSkyGlider(createSkyFlappyGame()),
|
||||
obstacles: [{ gapTop: 30, id: 1, scored: false, x: 7 }],
|
||||
}
|
||||
const scored = stepSkyFlappy(state, 0.01, () => 0.5)
|
||||
expect(scored.score).toBe(1)
|
||||
expect(stepSkyFlappy(scored, 0.01, () => 0.5).score).toBe(1)
|
||||
})
|
||||
|
||||
it('detects collision using the player edges', () => {
|
||||
const state = {
|
||||
...flapSkyGlider(createSkyFlappyGame()),
|
||||
obstacles: [{ gapTop: 40, id: 1, scored: false, x: 22 }],
|
||||
playerY: 41,
|
||||
playerVelocity: 0,
|
||||
}
|
||||
expect(stepSkyFlappy(state, 0.01).status).toBe('over')
|
||||
})
|
||||
|
||||
it('ends at the upper and lower boundaries', () => {
|
||||
const upper = { ...flapSkyGlider(createSkyFlappyGame()), playerY: 1 }
|
||||
const lower = { ...flapSkyGlider(createSkyFlappyGame()), playerY: 99 }
|
||||
expect(stepSkyFlappy(upper, 0.01).status).toBe('over')
|
||||
expect(stepSkyFlappy(lower, 0.01).status).toBe('over')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,119 @@
|
||||
import type { SkyFlappyGameState, SkyFlappyObstacle } from './types'
|
||||
|
||||
export const FLAPPY_PLAYER_X = 23
|
||||
export const FLAPPY_PLAYER_RADIUS = 3.2
|
||||
export const FLAPPY_GRAVITY = 82
|
||||
export const FLAPPY_IMPULSE = -34
|
||||
export const FLAPPY_OBSTACLE_WIDTH = 14
|
||||
export const FLAPPY_GAP_HEIGHT = 29
|
||||
export const FLAPPY_MIN_GAP_TOP = 15
|
||||
export const FLAPPY_MAX_GAP_TOP = 56
|
||||
|
||||
export function createSkyFlappyGame(): SkyFlappyGameState {
|
||||
return {
|
||||
nextObstacleId: 1,
|
||||
obstacles: [],
|
||||
playerVelocity: 0,
|
||||
playerY: 48,
|
||||
score: 0,
|
||||
status: 'ready',
|
||||
}
|
||||
}
|
||||
|
||||
export function flapSkyGlider(state: SkyFlappyGameState): SkyFlappyGameState {
|
||||
if (state.status === 'over' || state.status === 'paused') return state
|
||||
return { ...state, playerVelocity: FLAPPY_IMPULSE, status: 'playing' }
|
||||
}
|
||||
|
||||
function createObstacle(
|
||||
id: number,
|
||||
random: () => number,
|
||||
): SkyFlappyObstacle {
|
||||
return {
|
||||
gapTop:
|
||||
FLAPPY_MIN_GAP_TOP +
|
||||
Math.max(0, Math.min(1, random())) *
|
||||
(FLAPPY_MAX_GAP_TOP - FLAPPY_MIN_GAP_TOP),
|
||||
id,
|
||||
scored: false,
|
||||
x: 108,
|
||||
}
|
||||
}
|
||||
|
||||
function collidesWithObstacle(
|
||||
playerY: number,
|
||||
obstacle: SkyFlappyObstacle,
|
||||
): boolean {
|
||||
const horizontalCollision =
|
||||
FLAPPY_PLAYER_X + FLAPPY_PLAYER_RADIUS > obstacle.x &&
|
||||
FLAPPY_PLAYER_X - FLAPPY_PLAYER_RADIUS <
|
||||
obstacle.x + FLAPPY_OBSTACLE_WIDTH
|
||||
if (!horizontalCollision) return false
|
||||
|
||||
return (
|
||||
playerY - FLAPPY_PLAYER_RADIUS < obstacle.gapTop ||
|
||||
playerY + FLAPPY_PLAYER_RADIUS > obstacle.gapTop + FLAPPY_GAP_HEIGHT
|
||||
)
|
||||
}
|
||||
|
||||
export function stepSkyFlappy(
|
||||
state: SkyFlappyGameState,
|
||||
elapsedSeconds: number,
|
||||
random: () => number = Math.random,
|
||||
): SkyFlappyGameState {
|
||||
if (state.status !== 'playing' || elapsedSeconds <= 0) return state
|
||||
|
||||
const speed = Math.min(33, 20.5 + state.score * 0.38)
|
||||
const playerVelocity = state.playerVelocity + FLAPPY_GRAVITY * elapsedSeconds
|
||||
const playerY = state.playerY + playerVelocity * elapsedSeconds
|
||||
let nextObstacleId = state.nextObstacleId
|
||||
let obstacles = state.obstacles
|
||||
.map((obstacle) => ({
|
||||
...obstacle,
|
||||
x: obstacle.x - speed * elapsedSeconds,
|
||||
}))
|
||||
.filter((obstacle) => obstacle.x + FLAPPY_OBSTACLE_WIDTH > -2)
|
||||
|
||||
if (obstacles.length === 0 || obstacles[obstacles.length - 1].x < 61) {
|
||||
obstacles = [...obstacles, createObstacle(nextObstacleId, random)]
|
||||
nextObstacleId += 1
|
||||
}
|
||||
|
||||
let score = state.score
|
||||
obstacles = obstacles.map((obstacle) => {
|
||||
if (
|
||||
!obstacle.scored &&
|
||||
obstacle.x + FLAPPY_OBSTACLE_WIDTH < FLAPPY_PLAYER_X
|
||||
) {
|
||||
score += 1
|
||||
return { ...obstacle, scored: true }
|
||||
}
|
||||
return obstacle
|
||||
})
|
||||
|
||||
const collided =
|
||||
playerY - FLAPPY_PLAYER_RADIUS <= 0 ||
|
||||
playerY + FLAPPY_PLAYER_RADIUS >= 100 ||
|
||||
obstacles.some((obstacle) => collidesWithObstacle(playerY, obstacle))
|
||||
|
||||
return {
|
||||
nextObstacleId,
|
||||
obstacles,
|
||||
playerVelocity,
|
||||
playerY,
|
||||
score,
|
||||
status: collided ? 'over' : 'playing',
|
||||
}
|
||||
}
|
||||
|
||||
export function pauseSkyFlappy(
|
||||
state: SkyFlappyGameState,
|
||||
): SkyFlappyGameState {
|
||||
return state.status === 'playing' ? { ...state, status: 'paused' } : state
|
||||
}
|
||||
|
||||
export function resumeSkyFlappy(
|
||||
state: SkyFlappyGameState,
|
||||
): SkyFlappyGameState {
|
||||
return state.status === 'paused' ? { ...state, status: 'playing' } : state
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { useGamesStore } from '@/features/games/store'
|
||||
|
||||
import {
|
||||
createSkyFlappyGame,
|
||||
flapSkyGlider,
|
||||
pauseSkyFlappy,
|
||||
resumeSkyFlappy,
|
||||
stepSkyFlappy,
|
||||
} from './engine'
|
||||
import type { SkyFlappyDesign, SkyFlappyGameState } from './types'
|
||||
|
||||
type SkyFlappySave = {
|
||||
design: SkyFlappyDesign
|
||||
highScore: number
|
||||
soundEnabled: boolean
|
||||
}
|
||||
|
||||
function isDesign(value: unknown): value is SkyFlappyDesign {
|
||||
return value === 'dawn' || value === 'neon' || value === 'storm'
|
||||
}
|
||||
|
||||
export const useSkyFlappyStore = defineStore('sky-flappy', {
|
||||
state: () => ({
|
||||
design: 'dawn' as SkyFlappyDesign,
|
||||
game: null as SkyFlappyGameState | null,
|
||||
highScore: 0,
|
||||
hydrated: false,
|
||||
menuOpen: true,
|
||||
soundEnabled: true,
|
||||
}),
|
||||
actions: {
|
||||
hydrate(): void {
|
||||
if (this.hydrated) return
|
||||
const saved = useGamesStore().readGame<Partial<SkyFlappySave>>('sky-flappy')
|
||||
this.highScore = typeof saved?.highScore === 'number' && saved.highScore >= 0 ? Math.floor(saved.highScore) : 0
|
||||
this.design = isDesign(saved?.design) ? saved.design : 'dawn'
|
||||
if (typeof saved?.soundEnabled === 'boolean') this.soundEnabled = saved.soundEnabled
|
||||
this.hydrated = true
|
||||
},
|
||||
persist(): void {
|
||||
useGamesStore().saveGame('sky-flappy', {
|
||||
design: this.design,
|
||||
highScore: this.highScore,
|
||||
soundEnabled: this.soundEnabled,
|
||||
} satisfies SkyFlappySave)
|
||||
},
|
||||
start(): void {
|
||||
this.game = createSkyFlappyGame()
|
||||
this.menuOpen = false
|
||||
},
|
||||
flap(): void {
|
||||
if (this.game) this.game = flapSkyGlider(this.game)
|
||||
},
|
||||
tick(elapsedSeconds: number): void {
|
||||
if (!this.game) return
|
||||
this.game = stepSkyFlappy(this.game, elapsedSeconds)
|
||||
if (this.game.status === 'over' && this.game.score > this.highScore) {
|
||||
this.highScore = this.game.score
|
||||
this.persist()
|
||||
}
|
||||
},
|
||||
pause(): void {
|
||||
if (this.game) this.game = pauseSkyFlappy(this.game)
|
||||
},
|
||||
resume(): void {
|
||||
if (this.game) {
|
||||
this.game = resumeSkyFlappy(this.game)
|
||||
this.menuOpen = false
|
||||
}
|
||||
},
|
||||
showMenu(): void {
|
||||
this.pause()
|
||||
this.menuOpen = true
|
||||
},
|
||||
setDesign(design: SkyFlappyDesign): void {
|
||||
this.design = design
|
||||
this.persist()
|
||||
},
|
||||
setSoundEnabled(enabled: boolean): void {
|
||||
this.soundEnabled = enabled
|
||||
this.persist()
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,18 @@
|
||||
export type SkyFlappyStatus = 'over' | 'paused' | 'playing' | 'ready'
|
||||
export type SkyFlappyDesign = 'dawn' | 'neon' | 'storm'
|
||||
|
||||
export type SkyFlappyObstacle = {
|
||||
gapTop: number
|
||||
id: number
|
||||
scored: boolean
|
||||
x: number
|
||||
}
|
||||
|
||||
export type SkyFlappyGameState = {
|
||||
nextObstacleId: number
|
||||
obstacles: SkyFlappyObstacle[]
|
||||
playerVelocity: number
|
||||
playerY: number
|
||||
score: number
|
||||
status: SkyFlappyStatus
|
||||
}
|
||||
Reference in New Issue
Block a user