diff --git a/frontend/src/assets/audio/tower-stack/fall.wav b/frontend/src/assets/audio/tower-stack/fall.wav new file mode 100644 index 0000000..9353533 Binary files /dev/null and b/frontend/src/assets/audio/tower-stack/fall.wav differ diff --git a/frontend/src/assets/audio/tower-stack/hit.wav b/frontend/src/assets/audio/tower-stack/hit.wav new file mode 100644 index 0000000..a7dbdec Binary files /dev/null and b/frontend/src/assets/audio/tower-stack/hit.wav differ diff --git a/frontend/src/assets/audio/tower-stack/perfect.wav b/frontend/src/assets/audio/tower-stack/perfect.wav new file mode 100644 index 0000000..ec3e8ea Binary files /dev/null and b/frontend/src/assets/audio/tower-stack/perfect.wav differ diff --git a/frontend/src/assets/audio/tower-stack/start.wav b/frontend/src/assets/audio/tower-stack/start.wav new file mode 100644 index 0000000..e0f1777 Binary files /dev/null and b/frontend/src/assets/audio/tower-stack/start.wav differ diff --git a/frontend/src/assets/img/app-icons/tower-stack.webp b/frontend/src/assets/img/app-icons/tower-stack.webp new file mode 100644 index 0000000..986c714 Binary files /dev/null and b/frontend/src/assets/img/app-icons/tower-stack.webp differ diff --git a/frontend/src/config/apps.test.ts b/frontend/src/config/apps.test.ts index 7c63ac4..e82fac6 100644 --- a/frontend/src/config/apps.test.ts +++ b/frontend/src/config/apps.test.ts @@ -50,6 +50,12 @@ describe('app registry', () => { labelKey: 'Apps.minesweeper.name', route: '/apps/minesweeper', }) + expect(PHONE_APPS.find((app) => app.id === 'tower-stack')).toMatchObject({ + dockOrder: null, + gridOrder: 15, + labelKey: 'Apps.towerStack.name', + route: '/apps/tower-stack', + }) 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 edc9bda..fe5cec1 100644 --- a/frontend/src/config/apps.ts +++ b/frontend/src/config/apps.ts @@ -7,6 +7,7 @@ import { Grid2X2, Brain, Images, + Layers3, Mail, MapPinned, NotebookPen, @@ -31,6 +32,7 @@ import snakeIcon from '@/assets/img/app-icons/snake.webp' 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 weatherIcon from '@/assets/img/app-icons/weather.webp' import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps' @@ -230,6 +232,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [ labelKey: 'Apps.minesweeper.name', route: '/apps/minesweeper', }, + { + component: markRaw( + defineAsyncComponent(() => import('@/views/apps/TowerStackApp.vue')), + ), + dockOrder: null, + gridOrder: 15, + icon: markRaw(Layers3), + iconClass: 'app-icon--tower-stack', + iconImage: towerStackIcon, + id: 'tower-stack', + labelKey: 'Apps.towerStack.name', + route: '/apps/tower-stack', + }, ] export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id) diff --git a/frontend/src/features/games/tower-stack/audio.ts b/frontend/src/features/games/tower-stack/audio.ts new file mode 100644 index 0000000..5c9374f --- /dev/null +++ b/frontend/src/features/games/tower-stack/audio.ts @@ -0,0 +1,42 @@ +import fallUrl from '@/assets/audio/tower-stack/fall.wav?url' +import hitUrl from '@/assets/audio/tower-stack/hit.wav?url' +import perfectUrl from '@/assets/audio/tower-stack/perfect.wav?url' +import startUrl from '@/assets/audio/tower-stack/start.wav?url' + +export type TowerStackSound = 'fall' | 'hit' | 'perfect' | 'start' + +const soundUrls: Record = { + fall: fallUrl, + hit: hitUrl, + perfect: perfectUrl, + start: startUrl, +} +const playerPools = new Map() + +function getPlayers(sound: TowerStackSound): HTMLAudioElement[] { + const existing = playerPools.get(sound) + if (existing) return existing + + const players = Array.from({ length: 3 }, () => { + const player = new Audio(soundUrls[sound]) + player.preload = 'auto' + player.volume = 0.84 + return player + }) + playerPools.set(sound, players) + return players +} + +export function playTowerStackSound( + sound: TowerStackSound, + enabled: boolean, +): void { + if (!enabled) return + + const players = getPlayers(sound) + const player = players.find((candidate) => candidate.paused) ?? players[0] + player.currentTime = 0 + void player.play().catch((error: unknown) => { + console.error(`[Tower Stack audio] Failed to play ${sound}`, error) + }) +} diff --git a/frontend/src/features/games/tower-stack/engine.test.ts b/frontend/src/features/games/tower-stack/engine.test.ts new file mode 100644 index 0000000..04f72cf --- /dev/null +++ b/frontend/src/features/games/tower-stack/engine.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from 'vitest' + +import { + advanceTowerBlock, + createTowerGame, + placeTowerBlock, + TOWER_BASE_WIDTH, + TOWER_MAX_SPEED, + TOWER_PERFECT_TOLERANCE, +} from './engine' + +describe('tower stack engine', () => { + it('creates a centered foundation and moving block', () => { + const state = createTowerGame() + expect(state.blocks).toHaveLength(1) + expect(state.blocks[0].width).toBe(TOWER_BASE_WIDTH) + expect(state.active?.x).toBe(0) + expect(state.status).toBe('playing') + }) + + it('moves the active block using elapsed time', () => { + const state = createTowerGame() + const moved = advanceTowerBlock(state, 0.5) + expect(moved.active?.x).toBeCloseTo(11.5) + }) + + it('reflects a moving block at the field edge', () => { + const state = createTowerGame() + const moved = advanceTowerBlock(state, 2) + expect(moved.active?.direction).toBe(-1) + expect(moved.active?.x).toBeGreaterThanOrEqual(0) + }) + + it('keeps only the overlapping part', () => { + const state = createTowerGame() + state.active = { ...state.active!, x: 25 } + const result = placeTowerBlock(state) + expect(result.outcome).toBe('placed') + expect(result.state.blocks.at(-1)?.width).toBeCloseTo(59) + expect(result.cutWidth).toBeCloseTo(9) + }) + + it('rewards a perfect placement within tolerance', () => { + const state = createTowerGame() + state.active = { + ...state.active!, + x: state.blocks[0].x + TOWER_PERFECT_TOLERANCE / 2, + } + const result = placeTowerBlock(state) + expect(result.outcome).toBe('perfect') + expect(result.state.perfects).toBe(1) + expect(result.state.score).toBe(4) + }) + + it('ends the round on a complete miss', () => { + const state = createTowerGame() + state.active = { ...state.active!, width: 8, x: 0 } + const result = placeTowerBlock(state) + expect(result.outcome).toBe('missed') + expect(result.state.status).toBe('over') + expect(result.state.active).toBeNull() + }) + + it('alternates movement direction for each level', () => { + const state = createTowerGame() + state.active = { ...state.active!, x: state.blocks[0].x } + const result = placeTowerBlock(state) + expect(result.state.active?.direction).toBe(-1) + }) + + it('caps speed at the configured maximum', () => { + let state = createTowerGame() + for (let level = 0; level < 30; level += 1) { + state.active = { ...state.active!, x: state.blocks.at(-1)!.x } + state = placeTowerBlock(state).state + } + expect(state.active?.speed).toBe(TOWER_MAX_SPEED) + }) +}) diff --git a/frontend/src/features/games/tower-stack/engine.ts b/frontend/src/features/games/tower-stack/engine.ts new file mode 100644 index 0000000..ce08221 --- /dev/null +++ b/frontend/src/features/games/tower-stack/engine.ts @@ -0,0 +1,139 @@ +import type { + TowerActiveBlock, + TowerBlock, + TowerGameState, + TowerPlacement, +} from './types' + +export const TOWER_FIELD_WIDTH = 100 +export const TOWER_BASE_WIDTH = 68 +export const TOWER_PERFECT_TOLERANCE = 1.35 +export const TOWER_START_SPEED = 23 +export const TOWER_MAX_SPEED = 48 + +function speedForLevel(level: number): number { + return Math.min( + TOWER_MAX_SPEED, + TOWER_START_SPEED + Math.max(0, level - 1) * 1.65, + ) +} + +function createActiveBlock( + level: number, + width: number, + direction: -1 | 1, +): TowerActiveBlock { + return { + colorIndex: level % 6, + direction, + id: level, + level, + speed: speedForLevel(level), + width, + x: direction === 1 ? 0 : TOWER_FIELD_WIDTH - width, + } +} + +export function createTowerGame(): TowerGameState { + const base: TowerBlock = { + colorIndex: 0, + id: 0, + level: 0, + width: TOWER_BASE_WIDTH, + x: (TOWER_FIELD_WIDTH - TOWER_BASE_WIDTH) / 2, + } + + return { + active: createActiveBlock(1, TOWER_BASE_WIDTH, 1), + blocks: [base], + perfects: 0, + score: 0, + status: 'playing', + } +} + +export function advanceTowerBlock( + state: TowerGameState, + elapsedSeconds: number, +): TowerGameState { + if (state.status !== 'playing' || !state.active || elapsedSeconds <= 0) { + return state + } + + const active = { ...state.active } + let nextX = active.x + active.direction * active.speed * elapsedSeconds + const maximumX = TOWER_FIELD_WIDTH - active.width + + while (nextX < 0 || nextX > maximumX) { + if (nextX > maximumX) { + nextX = maximumX - (nextX - maximumX) + active.direction = -1 + } else { + nextX = -nextX + active.direction = 1 + } + } + + active.x = nextX + return { ...state, active } +} + +export function placeTowerBlock(state: TowerGameState): TowerPlacement { + if (state.status !== 'playing' || !state.active) { + return { cutSide: null, cutWidth: 0, outcome: 'missed', state } + } + + const active = state.active + const support = state.blocks[state.blocks.length - 1] + const overlapStart = Math.max(active.x, support.x) + const overlapEnd = Math.min(active.x + active.width, support.x + support.width) + const overlapWidth = overlapEnd - overlapStart + + if (overlapWidth <= 0) { + return { + cutSide: active.x < support.x ? 'left' : 'right', + cutWidth: active.width, + outcome: 'missed', + state: { ...state, active: null, status: 'over' }, + } + } + + const offset = active.x - support.x + const isPerfect = Math.abs(offset) <= TOWER_PERFECT_TOLERANCE + const placedWidth = isPerfect + ? Math.min(TOWER_BASE_WIDTH, support.width + 0.85) + : overlapWidth + const placedX = isPerfect + ? support.x - (placedWidth - support.width) / 2 + : overlapStart + const block: TowerBlock = { + colorIndex: active.colorIndex, + id: active.id, + level: active.level, + width: placedWidth, + x: placedX, + } + const nextLevel = active.level + 1 + const direction: -1 | 1 = nextLevel % 2 === 0 ? -1 : 1 + + return { + cutSide: isPerfect ? null : offset < 0 ? 'left' : 'right', + cutWidth: isPerfect ? 0 : active.width - overlapWidth, + outcome: isPerfect ? 'perfect' : 'placed', + state: { + active: createActiveBlock(nextLevel, placedWidth, direction), + blocks: [...state.blocks, block], + perfects: state.perfects + (isPerfect ? 1 : 0), + score: state.score + 1 + (isPerfect ? 3 : 0), + status: 'playing', + }, + } +} + +export function pauseTowerGame(state: TowerGameState): TowerGameState { + return state.status === 'playing' ? { ...state, status: 'paused' } : state +} + +export function resumeTowerGame(state: TowerGameState): TowerGameState { + return state.status === 'paused' ? { ...state, status: 'playing' } : state +} diff --git a/frontend/src/features/games/tower-stack/store.ts b/frontend/src/features/games/tower-stack/store.ts new file mode 100644 index 0000000..32ecf71 --- /dev/null +++ b/frontend/src/features/games/tower-stack/store.ts @@ -0,0 +1,96 @@ +import { defineStore } from 'pinia' + +import { useGamesStore } from '@/features/games/store' + +import { + advanceTowerBlock, + createTowerGame, + pauseTowerGame, + placeTowerBlock, + resumeTowerGame, +} from './engine' +import type { TowerGameState, TowerPlacement } from './types' + +type TowerStackSave = { + highHeight: number + highScore: number + soundEnabled: boolean +} + +export const useTowerStackStore = defineStore('tower-stack', { + state: () => ({ + game: null as TowerGameState | null, + highHeight: 0, + highScore: 0, + hydrated: false, + menuOpen: true, + soundEnabled: true, + }), + actions: { + hydrate(): void { + if (this.hydrated) return + + const saved = useGamesStore().readGame>( + 'tower-stack', + ) + this.highHeight = + typeof saved?.highHeight === 'number' && saved.highHeight >= 0 + ? Math.floor(saved.highHeight) + : 0 + this.highScore = + typeof saved?.highScore === 'number' && saved.highScore >= 0 + ? Math.floor(saved.highScore) + : 0 + if (typeof saved?.soundEnabled === 'boolean') { + this.soundEnabled = saved.soundEnabled + } + this.hydrated = true + }, + persist(): void { + useGamesStore().saveGame('tower-stack', { + highHeight: this.highHeight, + highScore: this.highScore, + soundEnabled: this.soundEnabled, + } satisfies TowerStackSave) + }, + start(): void { + this.game = createTowerGame() + this.menuOpen = false + }, + tick(elapsedSeconds: number): void { + if (this.game) { + this.game = advanceTowerBlock(this.game, elapsedSeconds) + } + }, + place(): TowerPlacement | null { + if (!this.game) return null + + const result = placeTowerBlock(this.game) + this.game = result.state + if (result.state.status === 'over') { + const height = result.state.blocks.length - 1 + this.highHeight = Math.max(this.highHeight, height) + this.highScore = Math.max(this.highScore, result.state.score) + this.persist() + } + return result + }, + pause(): void { + if (this.game) this.game = pauseTowerGame(this.game) + }, + resume(): void { + if (this.game) { + this.game = resumeTowerGame(this.game) + this.menuOpen = false + } + }, + showMenu(): void { + this.pause() + this.menuOpen = true + }, + setSoundEnabled(enabled: boolean): void { + this.soundEnabled = enabled + this.persist() + }, + }, +}) diff --git a/frontend/src/features/games/tower-stack/types.ts b/frontend/src/features/games/tower-stack/types.ts new file mode 100644 index 0000000..655b850 --- /dev/null +++ b/frontend/src/features/games/tower-stack/types.ts @@ -0,0 +1,29 @@ +export type TowerStatus = 'over' | 'paused' | 'playing' + +export type TowerBlock = { + colorIndex: number + id: number + level: number + width: number + x: number +} + +export type TowerActiveBlock = TowerBlock & { + direction: -1 | 1 + speed: number +} + +export type TowerGameState = { + active: TowerActiveBlock | null + blocks: TowerBlock[] + perfects: number + score: number + status: TowerStatus +} + +export type TowerPlacement = { + cutSide: 'left' | 'right' | null + cutWidth: number + outcome: 'missed' | 'perfect' | 'placed' + state: TowerGameState +} diff --git a/frontend/src/stores/phone.ts b/frontend/src/stores/phone.ts index b4a5b76..ab3c693 100644 --- a/frontend/src/stores/phone.ts +++ b/frontend/src/stores/phone.ts @@ -243,6 +243,35 @@ const defaultLocales: LocaleTree = { wonBody: 'Minefield cleared in {time}.', wonTitle: 'Field cleared!', }, + towerStack: { + name: 'Tower Stack', + backToMenu: 'Back to game menu', + bestHeight: 'Best Height', + bestScore: 'Best Score', + blocks: 'Blocks', + eyebrow: 'Timing challenge', + finalScore: 'Final Score', + gameHint: 'Tap the playfield to place the moving block', + gameOver: 'Tower collapsed', + height: 'Height', + mainMenu: 'Main Menu', + menuBody: + 'Stop each moving block above the tower. Only the overlapping part remains.', + menuTitle: 'How high can you build?', + mute: 'Mute game sounds', + newGame: 'Start New Game', + pause: 'Pause or resume game', + paused: 'Paused', + perfect: 'PERFECT!', + placeBlock: 'Place moving block', + playAgain: 'Play Again', + ready: 'Ready to stack?', + resume: 'Continue Game', + score: 'Score', + start: 'Start Game', + tapHint: 'Every tap places one block · perfect hits earn bonus points', + 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 85ebee0..fb8e7fa 100644 --- a/frontend/src/types/apps.ts +++ b/frontend/src/types/apps.ts @@ -16,6 +16,7 @@ export type PhoneAppId = | 'memory' | 'number-merge' | 'minesweeper' + | 'tower-stack' export type AppLaunchOrigin = { borderRadius: number diff --git a/frontend/src/utils/preferences.ts b/frontend/src/utils/preferences.ts index 65a452e..13fdf54 100644 --- a/frontend/src/utils/preferences.ts +++ b/frontend/src/utils/preferences.ts @@ -54,6 +54,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record< memory: { enabled: true, sounds: true }, 'number-merge': { enabled: true, sounds: true }, minesweeper: { enabled: true, sounds: true }, + 'tower-stack': { 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/TowerStackApp.vue b/frontend/src/views/apps/TowerStackApp.vue new file mode 100644 index 0000000..384d8fe --- /dev/null +++ b/frontend/src/views/apps/TowerStackApp.vue @@ -0,0 +1,360 @@ + + + + + diff --git a/sky_phone/config/locales/en.lua b/sky_phone/config/locales/en.lua index d1a8087..f0b7b65 100644 --- a/sky_phone/config/locales/en.lua +++ b/sky_phone/config/locales/en.lua @@ -112,6 +112,19 @@ Locales["en"] = { playAgain = "Play Again", restart = "Restart", resume = "Continue Game", time = "Time", unmute = "Turn on game sounds", wonBody = "Minefield cleared in {time}.", wonTitle = "Field cleared!", }, + towerStack = { + name = "Tower Stack", backToMenu = "Back to game menu", bestHeight = "Best Height", + bestScore = "Best Score", blocks = "Blocks", eyebrow = "Timing challenge", finalScore = "Final Score", + gameHint = "Tap the playfield to place the moving block", gameOver = "Tower collapsed", + height = "Height", mainMenu = "Main Menu", + menuBody = "Stop each moving block above the tower. Only the overlapping part remains.", + menuTitle = "How high can you build?", mute = "Mute game sounds", newGame = "Start New Game", + pause = "Pause or resume game", paused = "Paused", perfect = "PERFECT!", + placeBlock = "Place moving block", playAgain = "Play Again", ready = "Ready to stack?", + resume = "Continue Game", score = "Score", start = "Start Game", + tapHint = "Every tap places one block · perfect hits earn bonus points", + 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 fc4bd8a..6a23671 100644 --- a/sky_phone/source/html/index.html +++ b/sky_phone/source/html/index.html @@ -4,7 +4,7 @@ Sky Phone - +