diff --git a/frontend/src/assets/audio/sky-flappy/crash.wav b/frontend/src/assets/audio/sky-flappy/crash.wav new file mode 100644 index 0000000..1718d18 Binary files /dev/null and b/frontend/src/assets/audio/sky-flappy/crash.wav differ diff --git a/frontend/src/assets/audio/sky-flappy/flap.wav b/frontend/src/assets/audio/sky-flappy/flap.wav new file mode 100644 index 0000000..5841246 Binary files /dev/null and b/frontend/src/assets/audio/sky-flappy/flap.wav differ diff --git a/frontend/src/assets/audio/sky-flappy/point.wav b/frontend/src/assets/audio/sky-flappy/point.wav new file mode 100644 index 0000000..e0501dd Binary files /dev/null and b/frontend/src/assets/audio/sky-flappy/point.wav differ diff --git a/frontend/src/assets/img/app-icons/sky-flappy.webp b/frontend/src/assets/img/app-icons/sky-flappy.webp new file mode 100644 index 0000000..d2d71c5 Binary files /dev/null and b/frontend/src/assets/img/app-icons/sky-flappy.webp differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index e82fac6..520d7f1 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -56,6 +56,12 @@ describe('app registry', () => { labelKey: 'Apps.towerStack.name', route: '/apps/tower-stack', }) + expect(PHONE_APPS.find((app) => app.id === 'sky-flappy')).toMatchObject({ + dockOrder: null, + gridOrder: 16, + labelKey: 'Apps.skyFlappy.name', + route: '/apps/sky-flappy', + }) expect( PHONE_APPS.filter((app) => app.dockOrder !== null) .sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0)) diff --git a/frontend/src/config/apps.ts b/frontend/src/config/apps.ts index fe5cec1..1e864a5 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -15,6 +15,7 @@ import { Settings, ShoppingBag, CloudSun, + Wind, } from 'lucide-vue-next' import { defineAsyncComponent, markRaw } from 'vue' @@ -33,6 +34,7 @@ import memoryIcon from '@/assets/img/app-icons/memory.webp' import numberMergeIcon from '@/assets/img/app-icons/number-merge.webp' import minesweeperIcon from '@/assets/img/app-icons/minesweeper.webp' import towerStackIcon from '@/assets/img/app-icons/tower-stack.webp' +import skyFlappyIcon from '@/assets/img/app-icons/sky-flappy.webp' import weatherIcon from '@/assets/img/app-icons/weather.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' @@ -245,6 +247,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.towerStack.name', route: '/apps/tower-stack', }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/SkyFlappyApp.vue')), + ), + dockOrder: null, + gridOrder: 16, + icon: markRaw(Wind), + iconClass: 'app-icon--sky-flappy', + iconImage: skyFlappyIcon, + id: 'sky-flappy', + labelKey: 'Apps.skyFlappy.name', + route: '/apps/sky-flappy', + }, ] export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) diff --git a/frontend/src/features/games/sky-flappy/audio.ts b/frontend/src/features/games/sky-flappy/audio.ts new file mode 100644 index 0000000..a8af227 --- /dev/null +++ b/frontend/src/features/games/sky-flappy/audio.ts @@ -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 = { crash: crashUrl, flap: flapUrl, point: pointUrl } +const pools = new Map() + +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)) +} diff --git a/frontend/src/features/games/sky-flappy/engine.test.ts b/frontend/src/features/games/sky-flappy/engine.test.ts new file mode 100644 index 0000000..3df016c --- /dev/null +++ b/frontend/src/features/games/sky-flappy/engine.test.ts @@ -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') + }) +}) diff --git a/frontend/src/features/games/sky-flappy/engine.ts b/frontend/src/features/games/sky-flappy/engine.ts new file mode 100644 index 0000000..1413e0d --- /dev/null +++ b/frontend/src/features/games/sky-flappy/engine.ts @@ -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 +} diff --git a/frontend/src/features/games/sky-flappy/store.ts b/frontend/src/features/games/sky-flappy/store.ts new file mode 100644 index 0000000..7e21078 --- /dev/null +++ b/frontend/src/features/games/sky-flappy/store.ts @@ -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>('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() + }, + }, +}) diff --git a/frontend/src/features/games/sky-flappy/types.ts b/frontend/src/features/games/sky-flappy/types.ts new file mode 100644 index 0000000..fa42cb4 --- /dev/null +++ b/frontend/src/features/games/sky-flappy/types.ts @@ -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 +} diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index ab3c693..4b0b25c 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -272,6 +272,34 @@ const defaultLocales: LocaleTree = { tapHint: 'Every tap places one block · perfect hits earn bonus points', unmute: 'Turn on game sounds', }, + skyFlappy: { + name: 'Sky Flappy', + backToMenu: 'Back to game menu', + best: 'Best', + design: 'Sky design', + designs: { dawn: 'Dawn', neon: 'Neon', storm: 'Storm' }, + eyebrow: 'Sky challenge', + firstTap: 'Tap to launch', + flap: 'Fly upward', + gameHint: 'Tap anywhere in the sky to fly upward', + gameOver: 'Flight ended', + highScore: 'High Score', + mainMenu: 'Main Menu', + menuBody: + 'Guide the Sky Glider through every tower opening without touching the edges.', + menuTitle: 'Fly between the towers', + mute: 'Mute game sounds', + pause: 'Pause or resume game', + paused: 'Paused', + playAgain: 'Fly Again', + points: 'Points', + ready: 'Ready for takeoff?', + resume: 'Continue Flight', + score: 'Score', + start: 'Start Flight', + tapHint: 'Tap to rise · gravity pulls the glider down', + unmute: 'Turn on game sounds', + }, map: { name: 'Map', controls: 'Map controls', diff --git a/frontend/src/types/apps.ts b/frontend/src/types/apps.ts index fb8e7fa..f763b7b 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -17,6 +17,7 @@ export type PhoneAppId = | 'number-merge' | 'minesweeper' | 'tower-stack' + | 'sky-flappy' export type AppLaunchOrigin = { borderRadius: number diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 13fdf54..8eabc6e 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -55,6 +55,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< 'number-merge': { enabled: true, sounds: true }, minesweeper: { enabled: true, sounds: true }, 'tower-stack': { enabled: true, sounds: true }, + 'sky-flappy': { enabled: true, sounds: true }, camera: { enabled: true, sounds: true }, clock: { enabled: true, sounds: true }, weather: { enabled: true, sounds: true }, diff --git a/frontend/src/views/apps/SkyFlappyApp.vue b/frontend/src/views/apps/SkyFlappyApp.vue new file mode 100644 index 0000000..c727e9d --- /dev/null +++ b/frontend/src/views/apps/SkyFlappyApp.vue @@ -0,0 +1,165 @@ + + + + + diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index f0b7b65..8ac5b53 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -125,6 +125,17 @@ Locales["en"] = { tapHint = "Every tap places one block · perfect hits earn bonus points", unmute = "Turn on game sounds", }, + skyFlappy = { + name = "Sky Flappy", backToMenu = "Back to game menu", best = "Best", design = "Sky design", + designs = { dawn = "Dawn", neon = "Neon", storm = "Storm" }, eyebrow = "Sky challenge", + firstTap = "Tap to launch", flap = "Fly upward", gameHint = "Tap anywhere in the sky to fly upward", + gameOver = "Flight ended", highScore = "High Score", mainMenu = "Main Menu", + menuBody = "Guide the Sky Glider through every tower opening without touching the edges.", + menuTitle = "Fly between the towers", mute = "Mute game sounds", pause = "Pause or resume game", + paused = "Paused", playAgain = "Fly Again", points = "Points", ready = "Ready for takeoff?", + resume = "Continue Flight", score = "Score", start = "Start Flight", + tapHint = "Tap to rise · gravity pulls the glider down", unmute = "Turn on game sounds", + }, camera = { name = "Camera", shutter = "Take photo", flip = "Flip camera", flash = "Toggle flash", controls = "Camera controls", modes = { timelapse = "Timelapse", slowMo = "Slow-Mo", cinematic = "Cinematic", video = "Video", photo = "Photo", portrait = "Portrait", pano = "Pano" }, diff --git a/sky_phone/source/html/index.html b/sky_phone/source/html/index.html index 6a23671..4723ce9 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,7 +4,7 @@ Sky Phone - +