ADD - tower stack phone game

This commit is contained in:
smx.pusha
2026-08-06 09:28:30 +02:00
parent e1931a6527
commit 0dded888a1
18 changed files with 811 additions and 1 deletions
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

+6
View File
@@ -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))
+15
View File
@@ -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)
@@ -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<TowerStackSound, string> = {
fall: fallUrl,
hit: hitUrl,
perfect: perfectUrl,
start: startUrl,
}
const playerPools = new Map<TowerStackSound, HTMLAudioElement[]>()
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)
})
}
@@ -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)
})
})
@@ -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
}
@@ -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<Partial<TowerStackSave>>(
'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()
},
},
})
@@ -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
}
+29
View File
@@ -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',
+1
View File
@@ -16,6 +16,7 @@ export type PhoneAppId =
| 'memory'
| 'number-merge'
| 'minesweeper'
| 'tower-stack'
export type AppLaunchOrigin = {
borderRadius: number
+1
View File
@@ -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 },
+360
View File
@@ -0,0 +1,360 @@
<script setup lang="ts">
import {
ChevronLeft,
Layers3,
Pause,
Play,
RotateCcw,
Volume2,
VolumeX,
} from 'lucide-vue-next'
import {
computed,
onBeforeUnmount,
ref,
watch,
type CSSProperties,
} from 'vue'
import { playTowerStackSound } from '@/features/games/tower-stack/audio'
import { useTowerStackStore } from '@/features/games/tower-stack/store'
import type {
TowerActiveBlock,
TowerBlock,
} from '@/features/games/tower-stack/types'
import { usePhoneStore } from '@/stores/phone'
const phone = usePhoneStore()
const tower = useTowerStackStore()
const game = computed(() => tower.game)
const visibleBlocks = computed(() => game.value?.blocks.slice(-9) ?? [])
const placementEffect = ref<'missed' | 'perfect' | 'placed' | null>(null)
const fallingBlock = ref<TowerActiveBlock | null>(null)
const fallingStyle = ref<CSSProperties>({})
const gameOverVisible = ref(true)
let animationFrame: number | undefined
let previousFrameTime = 0
let effectTimer: ReturnType<typeof setTimeout> | undefined
const blockColors = [
'#ff6b68',
'#ffbf3f',
'#39c9d3',
'#8c63e8',
'#ff8454',
'#54d5a5',
]
function blockStyle(block: TowerBlock, index: number): CSSProperties {
return {
'--tower-block-color': blockColors[block.colorIndex % blockColors.length],
bottom: `${24 + index * 35}px`,
left: `${block.x}%`,
width: `${block.width}%`,
} as CSSProperties
}
const activeStyle = computed<CSSProperties>(() => {
const active = game.value?.active
if (!active) return {}
return {
'--tower-block-color': blockColors[active.colorIndex % blockColors.length],
bottom: `${24 + visibleBlocks.value.length * 35}px`,
left: `${active.x}%`,
width: `${active.width}%`,
} as CSSProperties
})
function runFrame(time: number): void {
if (game.value?.status !== 'playing') return
if (previousFrameTime > 0) {
tower.tick(Math.min(0.04, (time - previousFrameTime) / 1000))
}
previousFrameTime = time
animationFrame = requestAnimationFrame(runFrame)
}
function startLoop(): void {
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame)
previousFrameTime = 0
animationFrame = requestAnimationFrame(runFrame)
}
function stopLoop(): void {
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame)
animationFrame = undefined
previousFrameTime = 0
}
function startGame(): void {
clearEffects()
tower.start()
playTowerStackSound('start', tower.soundEnabled)
}
function placeBlock(): void {
if (game.value?.status !== 'playing' || !game.value.active) return
const activeBeforePlacement = { ...game.value.active }
const visibleLevel = visibleBlocks.value.length
const result = tower.place()
if (!result) return
placementEffect.value = result.outcome
gameOverVisible.value = result.outcome !== 'missed'
if (result.outcome === 'perfect') {
playTowerStackSound('perfect', tower.soundEnabled)
} else if (result.outcome === 'placed') {
playTowerStackSound('hit', tower.soundEnabled)
} else {
playTowerStackSound('fall', tower.soundEnabled)
}
if (result.cutWidth > 0) {
fallingBlock.value = {
...activeBeforePlacement,
width: result.cutWidth,
x:
result.cutSide === 'left'
? activeBeforePlacement.x
: activeBeforePlacement.x + activeBeforePlacement.width - result.cutWidth,
}
fallingStyle.value = {
'--tower-block-color':
blockColors[activeBeforePlacement.colorIndex % blockColors.length],
bottom: `${24 + visibleLevel * 35}px`,
left: `${fallingBlock.value.x}%`,
width: `${fallingBlock.value.width}%`,
} as CSSProperties
}
if (effectTimer) clearTimeout(effectTimer)
effectTimer = setTimeout(() => {
placementEffect.value = null
fallingBlock.value = null
gameOverVisible.value = true
effectTimer = undefined
}, result.outcome === 'missed' ? 950 : 650)
}
function clearEffects(): void {
if (effectTimer) clearTimeout(effectTimer)
effectTimer = undefined
placementEffect.value = null
fallingBlock.value = null
gameOverVisible.value = true
}
function toggleSound(): void {
const enabled = !tower.soundEnabled
tower.setSoundEnabled(enabled)
if (enabled) playTowerStackSound('hit', true)
}
function togglePause(): void {
if (game.value?.status === 'playing') tower.pause()
else if (game.value?.status === 'paused') tower.resume()
}
tower.hydrate()
watch(
() => game.value?.status,
(status) => {
if (status === 'playing') startLoop()
else stopLoop()
},
{ immediate: true },
)
onBeforeUnmount(() => {
stopLoop()
clearEffects()
tower.pause()
})
</script>
<template>
<main class="tower-app" :aria-label="phone.t('Apps.towerStack.name')">
<header class="tower-header">
<div>
<span>{{ phone.t('Apps.towerStack.eyebrow') }}</span>
<h1>{{ phone.t('Apps.towerStack.name') }}</h1>
</div>
<button
type="button"
:aria-label="phone.t(tower.soundEnabled ? 'Apps.towerStack.mute' : 'Apps.towerStack.unmute')"
@click="toggleSound"
>
<Volume2 v-if="tower.soundEnabled" :size="18" aria-hidden="true" />
<VolumeX v-else :size="18" aria-hidden="true" />
</button>
</header>
<section v-if="tower.menuOpen" class="tower-menu">
<div class="tower-menu__preview" aria-hidden="true">
<i v-for="index in 7" :key="index" :style="{ '--preview-index': index }"></i>
<span></span>
</div>
<div class="tower-menu__copy">
<span>{{ phone.t('Apps.towerStack.ready') }}</span>
<h2>{{ phone.t('Apps.towerStack.menuTitle') }}</h2>
<p>{{ phone.t('Apps.towerStack.menuBody') }}</p>
</div>
<div class="tower-records">
<div><span>{{ phone.t('Apps.towerStack.bestHeight') }}</span><strong>{{ tower.highHeight }}</strong></div>
<div><span>{{ phone.t('Apps.towerStack.bestScore') }}</span><strong>{{ tower.highScore }}</strong></div>
</div>
<button
v-if="game?.status === 'paused'"
type="button"
class="tower-primary"
@click="tower.resume()"
>
<Play :size="17" fill="currentColor" />
{{ phone.t('Apps.towerStack.resume') }}
</button>
<button type="button" class="tower-secondary" @click="startGame">
{{ phone.t(game ? 'Apps.towerStack.newGame' : 'Apps.towerStack.start') }}
</button>
<p class="tower-menu__hint">{{ phone.t('Apps.towerStack.tapHint') }}</p>
</section>
<section v-else-if="game" class="tower-game">
<div class="tower-toolbar">
<button
type="button"
:aria-label="phone.t('Apps.towerStack.backToMenu')"
@pointerdown.stop="tower.showMenu()"
@click.stop="tower.showMenu()"
><ChevronLeft :size="19" /></button>
<div><span>{{ phone.t('Apps.towerStack.height') }}</span><strong>{{ game.blocks.length - 1 }}</strong></div>
<div><span>{{ phone.t('Apps.towerStack.score') }}</span><strong>{{ game.score }}</strong></div>
<button type="button" :aria-label="phone.t('Apps.towerStack.pause')" @click="togglePause">
<Pause v-if="game.status === 'playing'" :size="17" fill="currentColor" />
<Play v-else :size="17" fill="currentColor" />
</button>
</div>
<button
type="button"
class="tower-stage"
:class="{
'tower-stage--perfect': placementEffect === 'perfect',
'tower-stage--missed': placementEffect === 'missed',
}"
:aria-label="phone.t('Apps.towerStack.placeBlock')"
@pointerdown.stop.prevent="placeBlock"
>
<div class="tower-sky" aria-hidden="true"><i v-for="star in 13" :key="star"></i></div>
<span v-if="placementEffect === 'perfect'" class="tower-perfect">
{{ phone.t('Apps.towerStack.perfect') }}
</span>
<div class="tower-stack" aria-hidden="true">
<span
v-for="(block, index) in visibleBlocks"
:key="block.id"
class="tower-block tower-block--placed"
:style="blockStyle(block, index)"
></span>
<span
v-if="game.active"
class="tower-block tower-block--active"
:style="activeStyle"
></span>
<span
v-if="fallingBlock"
class="tower-block tower-block--falling"
:style="fallingStyle"
></span>
</div>
<div class="tower-ground" aria-hidden="true"></div>
</button>
<p class="tower-game__hint">{{ phone.t('Apps.towerStack.gameHint') }}</p>
<div v-if="game.status === 'paused'" class="tower-overlay">
<Pause :size="30" />
<h2>{{ phone.t('Apps.towerStack.paused') }}</h2>
<button type="button" class="tower-primary" @click="tower.resume()">
{{ phone.t('Apps.towerStack.resume') }}
</button>
<button type="button" class="tower-secondary" @click="tower.showMenu()">
{{ phone.t('Apps.towerStack.mainMenu') }}
</button>
</div>
<div
v-if="game.status === 'over' && gameOverVisible"
class="tower-overlay tower-overlay--over"
>
<Layers3 :size="34" />
<span>{{ phone.t('Apps.towerStack.gameOver') }}</span>
<h2>{{ game.blocks.length - 1 }} {{ phone.t('Apps.towerStack.blocks') }}</h2>
<p>{{ phone.t('Apps.towerStack.finalScore') }}: {{ game.score }}</p>
<button type="button" class="tower-primary" @click="startGame">
<RotateCcw :size="16" /> {{ phone.t('Apps.towerStack.playAgain') }}
</button>
<button type="button" class="tower-secondary" @click="tower.showMenu()">
{{ phone.t('Apps.towerStack.mainMenu') }}
</button>
</div>
</section>
</main>
</template>
<style scoped>
.tower-app { position: absolute; inset: 0; overflow: hidden; padding: 52px 16px 27px; color: #eef5ff; background: radial-gradient(circle at 75% 8%, #7146c866, transparent 35%), linear-gradient(170deg, #161634, #242054 52%, #10132c); font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; user-select: none; touch-action: manipulation; }
.tower-header { height: 55px; display: flex; align-items: center; justify-content: space-between; }
.tower-header span { display: block; color: #a69dd3; font-size: 9px; font-weight: 850; letter-spacing: 1.1px; text-transform: uppercase; }
.tower-header h1 { margin: 0; font-size: 24px; line-height: 1; letter-spacing: -0.8px; }
.tower-header button, .tower-toolbar button { width: 36px; height: 36px; display: grid; place-items: center; padding: 0; border: 1px solid #ffffff14; border-radius: 12px; color: #f2edff; background: #ffffff0d; }
.tower-menu { height: calc(100% - 55px); display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 13px; text-align: center; }
.tower-menu__preview { position: relative; width: 190px; height: 235px; }
.tower-menu__preview i { position: absolute; right: 12px; bottom: calc(var(--preview-index) * 25px); left: 25px; height: 29px; border-radius: 7px; background: hsl(calc(var(--preview-index) * 49deg + 5deg) 82% 62%); box-shadow: inset 0 4px 0 #ffffff35, 0 8px 15px #08091c5c; transform: perspective(200px) rotateX(5deg); }
.tower-menu__preview i:nth-child(even) { right: 25px; left: 12px; }
.tower-menu__preview span { position: absolute; top: 4px; left: 2px; width: 120px; height: 29px; border-radius: 7px; background: #ff6b68; box-shadow: 0 0 24px #ff6b6877; animation: tower-preview-slide 1.5s ease-in-out infinite alternate; }
.tower-menu__copy > span { color: #ffbd45; font-size: 9px; font-weight: 900; letter-spacing: 1px; text-transform: uppercase; }
.tower-menu__copy h2 { margin: 3px 0 5px; font-size: 21px; }
.tower-menu__copy p { max-width: 275px; margin: 0; color: #aca7cc; font-size: 10px; line-height: 1.4; }
.tower-records { width: 100%; display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.tower-records div { display: grid; gap: 2px; padding: 9px; border: 1px solid #ffffff0e; border-radius: 13px; background: #ffffff0a; }
.tower-records span { color: #9790bd; font-size: 8px; font-weight: 800; text-transform: uppercase; }
.tower-records strong { font-size: 18px; }
.tower-primary, .tower-secondary { width: 100%; min-height: 43px; display: flex; align-items: center; justify-content: center; gap: 7px; border-radius: 14px; font-size: 11px; font-weight: 850; }
.tower-primary { border: 0; color: #1e1839; background: linear-gradient(135deg, #ffca4f, #ff8760); box-shadow: 0 8px 18px #ff895330; }
.tower-secondary { border: 1px solid #ffffff16; color: #eeeaff; background: #ffffff0a; }
.tower-menu__hint, .tower-game__hint { margin: 0; color: #8d87b3; font-size: 9px; }
.tower-game { position: relative; height: calc(100% - 55px); }
.tower-toolbar { height: 47px; display: grid; grid-template-columns: 36px 1fr 1fr 36px; align-items: center; gap: 7px; }
.tower-toolbar div { display: grid; justify-items: center; line-height: 1.05; }
.tower-toolbar span { color: #918ab9; font-size: 8px; font-weight: 850; text-transform: uppercase; }
.tower-toolbar strong { font-size: 16px; }
.tower-stage { position: relative; width: 100%; height: 500px; display: block; overflow: hidden; padding: 0; border: 1px solid #ffffff12; border-radius: 22px; background: linear-gradient(#17173b, #322361 60%, #70426c); box-shadow: inset 0 0 35px #08091d99, 0 16px 28px #08091c66; touch-action: manipulation; }
.tower-sky { position: absolute; inset: 0; pointer-events: none; }
.tower-sky i { position: absolute; width: 3px; height: 3px; border-radius: 50%; background: #fff; box-shadow: 0 0 7px #c5c2ff; opacity: .65; }
.tower-sky i:nth-child(1) { top: 8%; left: 12%; } .tower-sky i:nth-child(2) { top: 17%; left: 72%; } .tower-sky i:nth-child(3) { top: 28%; left: 42%; } .tower-sky i:nth-child(4) { top: 37%; left: 88%; } .tower-sky i:nth-child(5) { top: 46%; left: 18%; } .tower-sky i:nth-child(6) { top: 58%; left: 64%; } .tower-sky i:nth-child(7) { top: 70%; left: 31%; } .tower-sky i:nth-child(8) { top: 11%; left: 91%; } .tower-sky i:nth-child(9) { top: 22%; left: 25%; } .tower-sky i:nth-child(10) { top: 50%; left: 78%; } .tower-sky i:nth-child(11) { top: 64%; left: 8%; } .tower-sky i:nth-child(12) { top: 77%; left: 92%; } .tower-sky i:nth-child(13) { top: 33%; left: 58%; }
.tower-stack { position: absolute; inset: 0 14px; }
.tower-block { position: absolute; height: 31px; border-radius: 7px; background: linear-gradient(180deg, color-mix(in srgb, var(--tower-block-color), white 18%), var(--tower-block-color)); box-shadow: inset 0 4px 0 #ffffff35, inset 0 -4px 0 #00000016, 0 7px 10px #08091d55; }
.tower-block--active { z-index: 3; box-shadow: inset 0 4px 0 #ffffff45, 0 0 18px color-mix(in srgb, var(--tower-block-color), transparent 45%); }
.tower-block--placed { animation: tower-land 180ms ease-out; }
.tower-block--falling { z-index: 4; animation: tower-fall 850ms ease-in forwards; }
.tower-ground { position: absolute; right: 0; bottom: 0; left: 0; height: 35px; background: linear-gradient(#31224f, #16152e); box-shadow: 0 -8px 20px #a9547928; }
.tower-perfect { position: absolute; z-index: 8; top: 24%; left: 50%; padding: 8px 17px; border-radius: 18px; color: #332149; background: #ffdc65; box-shadow: 0 0 24px #ffcf64aa; font-size: 13px; font-weight: 950; letter-spacing: .8px; transform: translateX(-50%); animation: tower-perfect-pop 650ms ease-out forwards; }
.tower-stage--perfect { animation: tower-perfect-glow 500ms ease-out; }
.tower-stage--missed { animation: tower-stage-shake 500ms ease-out; }
.tower-game__hint { margin-top: 8px; text-align: center; }
.tower-overlay { position: absolute; z-index: 15; inset: 47px 0 25px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 9px; padding: 28px; border-radius: 22px; color: #f7f2ff; background: #11122bd9; backdrop-filter: blur(7px); text-align: center; }
.tower-overlay > svg { color: #ffbd4d; }
.tower-overlay > span { color: #ff9b65; font-size: 9px; font-weight: 900; letter-spacing: 1.2px; text-transform: uppercase; }
.tower-overlay h2 { margin: 0; font-size: 25px; }
.tower-overlay p { margin: -3px 0 5px; color: #a9a3c9; font-size: 10px; }
@keyframes tower-preview-slide { from { transform: translateX(0) rotate(-2deg); } to { transform: translateX(66px) rotate(2deg); } }
@keyframes tower-land { from { filter: brightness(1.8); transform: translateY(-8px) scaleY(.85); } to { filter: brightness(1); transform: translateY(0) scaleY(1); } }
@keyframes tower-fall { 0% { opacity: 1; transform: translate(0) rotate(0); } 100% { opacity: 0; transform: translate(45px, 390px) rotate(145deg); } }
@keyframes tower-perfect-pop { 0% { opacity: 0; transform: translateX(-50%) scale(.4); } 35% { opacity: 1; transform: translateX(-50%) scale(1.2); } 100% { opacity: 0; transform: translateX(-50%) scale(1); } }
@keyframes tower-perfect-glow { 0%, 100% { box-shadow: inset 0 0 35px #08091d99, 0 16px 28px #08091c66; } 45% { box-shadow: inset 0 0 45px #ffd75c55, 0 0 30px #ffd75c66; } }
@keyframes tower-stage-shake { 0%, 100% { transform: translate(0); } 20% { transform: translate(-5px, 2px); } 40% { transform: translate(5px, -2px); } 60% { transform: translate(-3px, 1px); } 80% { transform: translate(2px); } }
button:active { transform: scale(.97); }
@media (prefers-reduced-motion: reduce) { .tower-menu__preview span, .tower-block, .tower-perfect, .tower-stage { animation: none; } }
</style>
+13
View File
@@ -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" },
+1 -1
View File
@@ -4,7 +4,7 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Sky Phone</title>
<script type="module" crossorigin src="./assets/sky-index-lJl_5aH2.js"></script>
<script type="module" crossorigin src="./assets/sky-index-2wOQNR-p.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-D9eWeoNZ.css">
</head>
<body>