ADD - sky flappy phone game

This commit is contained in:
smx.pusha
2026-08-06 09:37:35 +02:00
parent 0dded888a1
commit b851dfaff8
17 changed files with 540 additions and 1 deletions
@@ -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
}