mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-30 17:59:02 +00:00
ADD - sky flappy phone game
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
After Width: | Height: | Size: 69 KiB |
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -17,6 +17,7 @@ export type PhoneAppId =
|
||||
| 'number-merge'
|
||||
| 'minesweeper'
|
||||
| 'tower-stack'
|
||||
| 'sky-flappy'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,165 @@
|
||||
<script setup lang="ts">
|
||||
import { ChevronLeft, Pause, Play, RotateCcw, Volume2, VolumeX, Wind } from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, ref, watch, type CSSProperties } from 'vue'
|
||||
|
||||
import { playSkyFlappySound } from '@/features/games/sky-flappy/audio'
|
||||
import { useSkyFlappyStore } from '@/features/games/sky-flappy/store'
|
||||
import type { SkyFlappyDesign, SkyFlappyObstacle } from '@/features/games/sky-flappy/types'
|
||||
import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const flappy = useSkyFlappyStore()
|
||||
const game = computed(() => flappy.game)
|
||||
const designs: SkyFlappyDesign[] = ['dawn', 'neon', 'storm']
|
||||
const gameOverVisible = ref(true)
|
||||
const flapEffect = ref(0)
|
||||
let animationFrame: number | undefined
|
||||
let previousTime = 0
|
||||
let resultTimer: ReturnType<typeof setTimeout> | undefined
|
||||
|
||||
const playerStyle = computed<CSSProperties>(() => ({
|
||||
top: `${game.value?.playerY ?? 48}%`,
|
||||
transform: `translate(-50%, -50%) rotate(${Math.max(-24, Math.min(70, (game.value?.playerVelocity ?? 0) * 1.35))}deg)`,
|
||||
}))
|
||||
|
||||
function obstacleStyle(obstacle: SkyFlappyObstacle): CSSProperties {
|
||||
return { left: `${obstacle.x}%`, width: '14%' }
|
||||
}
|
||||
|
||||
function runFrame(time: number): void {
|
||||
if (game.value?.status !== 'playing') return
|
||||
if (previousTime > 0) {
|
||||
const previousScore = game.value.score
|
||||
flappy.tick(Math.min(0.035, (time - previousTime) / 1000))
|
||||
if (game.value && game.value.score > previousScore) {
|
||||
playSkyFlappySound('point', flappy.soundEnabled)
|
||||
}
|
||||
const nextStatus: string | undefined = flappy.game?.status
|
||||
if (nextStatus === 'over') {
|
||||
playSkyFlappySound('crash', flappy.soundEnabled)
|
||||
gameOverVisible.value = false
|
||||
resultTimer = setTimeout(() => {
|
||||
gameOverVisible.value = true
|
||||
resultTimer = undefined
|
||||
}, 650)
|
||||
}
|
||||
}
|
||||
previousTime = time
|
||||
animationFrame = requestAnimationFrame(runFrame)
|
||||
}
|
||||
|
||||
function startLoop(): void {
|
||||
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame)
|
||||
previousTime = 0
|
||||
animationFrame = requestAnimationFrame(runFrame)
|
||||
}
|
||||
|
||||
function stopLoop(): void {
|
||||
if (animationFrame !== undefined) cancelAnimationFrame(animationFrame)
|
||||
animationFrame = undefined
|
||||
previousTime = 0
|
||||
}
|
||||
|
||||
function startGame(): void {
|
||||
if (resultTimer) clearTimeout(resultTimer)
|
||||
resultTimer = undefined
|
||||
gameOverVisible.value = true
|
||||
flappy.start()
|
||||
}
|
||||
|
||||
function flap(): void {
|
||||
if (!game.value || game.value.status === 'over' || game.value.status === 'paused') return
|
||||
flappy.flap()
|
||||
flapEffect.value += 1
|
||||
playSkyFlappySound('flap', flappy.soundEnabled)
|
||||
}
|
||||
|
||||
function togglePause(): void {
|
||||
if (game.value?.status === 'playing') flappy.pause()
|
||||
else if (game.value?.status === 'paused') flappy.resume()
|
||||
}
|
||||
|
||||
function toggleSound(): void {
|
||||
const enabled = !flappy.soundEnabled
|
||||
flappy.setSoundEnabled(enabled)
|
||||
if (enabled) playSkyFlappySound('point', true)
|
||||
}
|
||||
|
||||
flappy.hydrate()
|
||||
watch(
|
||||
() => game.value?.status,
|
||||
(status) => {
|
||||
if (status === 'playing') startLoop()
|
||||
else stopLoop()
|
||||
},
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopLoop()
|
||||
if (resultTimer) clearTimeout(resultTimer)
|
||||
flappy.pause()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="flappy-app" :class="`flappy-app--${flappy.design}`" :aria-label="phone.t('Apps.skyFlappy.name')">
|
||||
<header class="flappy-header">
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.eyebrow') }}</span><h1>{{ phone.t('Apps.skyFlappy.name') }}</h1></div>
|
||||
<button type="button" :aria-label="phone.t(flappy.soundEnabled ? 'Apps.skyFlappy.mute' : 'Apps.skyFlappy.unmute')" @click="toggleSound">
|
||||
<Volume2 v-if="flappy.soundEnabled" :size="18" /><VolumeX v-else :size="18" />
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section v-if="flappy.menuOpen" class="flappy-menu">
|
||||
<div class="flappy-menu__hero" aria-hidden="true">
|
||||
<div class="sky-glider sky-glider--hero"><i></i><span></span><b></b></div>
|
||||
<i class="flappy-menu__trail"></i>
|
||||
<div class="flappy-menu__tower flappy-menu__tower--left"></div>
|
||||
<div class="flappy-menu__tower flappy-menu__tower--right"></div>
|
||||
</div>
|
||||
<div class="flappy-menu__copy"><span>{{ phone.t('Apps.skyFlappy.ready') }}</span><h2>{{ phone.t('Apps.skyFlappy.menuTitle') }}</h2><p>{{ phone.t('Apps.skyFlappy.menuBody') }}</p></div>
|
||||
<div class="flappy-record"><span>{{ phone.t('Apps.skyFlappy.highScore') }}</span><strong>{{ flappy.highScore }}</strong></div>
|
||||
<div class="flappy-designs" :aria-label="phone.t('Apps.skyFlappy.design')">
|
||||
<button v-for="design in designs" :key="design" type="button" :class="{ active: flappy.design === design }" @click="flappy.setDesign(design)">
|
||||
<i :class="`flappy-designs__${design}`"></i>{{ phone.t(`Apps.skyFlappy.designs.${design}`) }}
|
||||
</button>
|
||||
</div>
|
||||
<button type="button" class="flappy-primary" @click="startGame">{{ phone.t('Apps.skyFlappy.start') }}</button>
|
||||
<p>{{ phone.t('Apps.skyFlappy.tapHint') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else-if="game" class="flappy-game">
|
||||
<div class="flappy-toolbar">
|
||||
<button type="button" :aria-label="phone.t('Apps.skyFlappy.backToMenu')" @pointerdown.stop="flappy.showMenu()" @click.stop="flappy.showMenu()"><ChevronLeft :size="19" /></button>
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.score') }}</span><strong>{{ game.score }}</strong></div>
|
||||
<div><span>{{ phone.t('Apps.skyFlappy.best') }}</span><strong>{{ flappy.highScore }}</strong></div>
|
||||
<button type="button" :aria-label="phone.t('Apps.skyFlappy.pause')" @click="togglePause"><Pause v-if="game.status === 'playing'" :size="16" fill="currentColor" /><Play v-else :size="16" fill="currentColor" /></button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="flappy-stage" :class="{ 'flappy-stage--crashed': game.status === 'over' && !gameOverVisible }" :aria-label="phone.t('Apps.skyFlappy.flap')" @pointerdown.stop.prevent="flap">
|
||||
<div class="flappy-clouds" aria-hidden="true"><i v-for="cloud in 7" :key="cloud"></i></div>
|
||||
<div v-for="obstacle in game.obstacles" :key="obstacle.id" class="flappy-obstacle" :style="obstacleStyle(obstacle)" aria-hidden="true">
|
||||
<span class="flappy-obstacle__top" :style="{ height: `${obstacle.gapTop}%` }"></span>
|
||||
<span class="flappy-obstacle__bottom" :style="{ top: `${obstacle.gapTop + 29}%` }"></span>
|
||||
</div>
|
||||
<div :key="flapEffect" class="sky-glider sky-glider--player" :style="playerStyle" aria-hidden="true"><i></i><span></span><b></b></div>
|
||||
<strong v-if="game.status === 'ready'" class="flappy-ready">{{ phone.t('Apps.skyFlappy.firstTap') }}</strong>
|
||||
<div class="flappy-horizon" aria-hidden="true"></div>
|
||||
</button>
|
||||
|
||||
<div v-if="game.status === 'paused'" class="flappy-overlay"><Pause :size="30" /><h2>{{ phone.t('Apps.skyFlappy.paused') }}</h2><button type="button" class="flappy-primary" @click="flappy.resume()">{{ phone.t('Apps.skyFlappy.resume') }}</button><button type="button" class="flappy-secondary" @click="flappy.showMenu()">{{ phone.t('Apps.skyFlappy.mainMenu') }}</button></div>
|
||||
<div v-if="game.status === 'over' && gameOverVisible" class="flappy-overlay"><Wind :size="34" /><span>{{ phone.t('Apps.skyFlappy.gameOver') }}</span><h2>{{ game.score }} {{ phone.t('Apps.skyFlappy.points') }}</h2><button type="button" class="flappy-primary" @click="startGame"><RotateCcw :size="16" />{{ phone.t('Apps.skyFlappy.playAgain') }}</button><button type="button" class="flappy-secondary" @click="flappy.showMenu()">{{ phone.t('Apps.skyFlappy.mainMenu') }}</button></div>
|
||||
<p class="flappy-game__hint">{{ phone.t('Apps.skyFlappy.gameHint') }}</p>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.flappy-app { --sky-a:#53c9ed;--sky-b:#7970df;--tower:#645bd1; position:absolute;inset:0;overflow:hidden;padding:52px 16px 27px;color:#fff;background:linear-gradient(160deg,#19375e,#433b80);font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;user-select:none;touch-action:manipulation; }
|
||||
.flappy-app--neon { --sky-a:#1b1e58;--sky-b:#7b258c;--tower:#23cbd1; }.flappy-app--storm { --sky-a:#526779;--sky-b:#24354e;--tower:#b06f64; }
|
||||
.flappy-header{height:55px;display:flex;align-items:center;justify-content:space-between}.flappy-header span{display:block;color:#c3d7ef;font-size:9px;font-weight:850;letter-spacing:1.1px;text-transform:uppercase}.flappy-header h1{margin:0;font-size:24px;line-height:1}.flappy-header button,.flappy-toolbar button{width:36px;height:36px;display:grid;place-items:center;padding:0;border:1px solid #ffffff1c;border-radius:12px;color:#fff;background:#ffffff12}
|
||||
.flappy-menu{height:calc(100% - 55px);display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;text-align:center}.flappy-menu__hero{position:relative;width:220px;height:210px;overflow:hidden;border-radius:28px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:0 18px 32px #101b3b66}.flappy-menu__tower{position:absolute;top:0;bottom:0;width:50px;background:repeating-linear-gradient(180deg,var(--tower) 0 42px,#ffffff2b 43px 48px);box-shadow:inset -7px 0 12px #0003}.flappy-menu__tower--left{left:0}.flappy-menu__tower--right{right:0}.flappy-menu__trail{position:absolute;top:103px;left:45px;width:72px;height:10px;border-radius:50%;background:#ffe381;filter:blur(5px);animation:flappy-trail 1s ease-in-out infinite}.sky-glider{position:absolute;z-index:4;width:48px;height:38px;border-radius:48% 54% 48% 52%;background:linear-gradient(145deg,#48e0db,#087f9d);box-shadow:inset -5px -7px 8px #064f7066,0 0 16px #7bfff0}.sky-glider::after{position:absolute;top:10px;right:7px;width:15px;height:15px;border-radius:50%;background:#ffd75f;box-shadow:inset 3px 0 0 #fff5bf;content:""}.sky-glider i,.sky-glider span{position:absolute;left:-13px;width:24px;height:12px;border-radius:70% 20%;background:#ffbd52;transform-origin:right}.sky-glider i{top:5px;transform:rotate(20deg)}.sky-glider span{bottom:4px;transform:rotate(-18deg)}.sky-glider b{position:absolute;right:0;bottom:3px;width:9px;height:6px;border-radius:50%;background:#ff765e}.sky-glider--hero{top:83px;left:84px;animation:flappy-hero 1.1s ease-in-out infinite alternate}.flappy-menu__copy>span{color:#ffda70;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-menu__copy h2{margin:3px 0 5px;font-size:21px}.flappy-menu__copy p{max-width:275px;margin:0;color:#c0c8df;font-size:10px;line-height:1.4}.flappy-record{width:100%;display:flex;align-items:center;justify-content:space-between;padding:9px 14px;border:1px solid #ffffff14;border-radius:13px;background:#ffffff0c}.flappy-record span{color:#bdc8df;font-size:9px;font-weight:800;text-transform:uppercase}.flappy-record strong{font-size:19px}.flappy-designs{width:100%;display:grid;grid-template-columns:repeat(3,1fr);gap:6px}.flappy-designs button{display:grid;place-items:center;gap:3px;padding:7px 3px;border:1px solid #ffffff12;border-radius:12px;color:#ccd4e9;background:#ffffff0a;font-size:8px}.flappy-designs button.active{border-color:#ffdd72;color:#fff;background:#ffffff1b}.flappy-designs i{width:26px;height:13px;border-radius:8px}.flappy-designs__dawn{background:linear-gradient(90deg,#58d6ee,#ff9b80)}.flappy-designs__neon{background:linear-gradient(90deg,#24255f,#ef55ca)}.flappy-designs__storm{background:linear-gradient(90deg,#71899b,#253750)}.flappy-primary,.flappy-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}.flappy-primary{border:0;color:#173353;background:linear-gradient(135deg,#ffe16c,#ff9d68)}.flappy-secondary{border:1px solid #ffffff18;color:#fff;background:#ffffff0b}.flappy-menu>p,.flappy-game__hint{margin:0;color:#aeb9d2;font-size:9px}
|
||||
.flappy-game{position:relative;height:calc(100% - 55px)}.flappy-toolbar{height:47px;display:grid;grid-template-columns:36px 1fr 1fr 36px;align-items:center;gap:7px}.flappy-toolbar div{display:grid;justify-items:center}.flappy-toolbar span{color:#bac9df;font-size:8px;font-weight:850;text-transform:uppercase}.flappy-toolbar strong{font-size:16px}.flappy-stage{position:relative;width:100%;height:500px;display:block;overflow:hidden;padding:0;border:1px solid #ffffff1c;border-radius:22px;background:linear-gradient(var(--sky-a),var(--sky-b));box-shadow:inset 0 0 35px #15244c55,0 16px 30px #10193477;touch-action:manipulation}.flappy-clouds i{position:absolute;width:80px;height:24px;border-radius:50%;background:#ffffff30;filter:blur(1px);animation:cloud-drift 7s linear infinite}.flappy-clouds i:nth-child(1){top:10%;left:15%}.flappy-clouds i:nth-child(2){top:25%;left:65%;animation-delay:-2s}.flappy-clouds i:nth-child(3){top:45%;left:35%;animation-delay:-4s}.flappy-clouds i:nth-child(4){top:68%;left:78%}.flappy-clouds i:nth-child(5){top:78%;left:4%;animation-delay:-3s}.flappy-obstacle{position:absolute;z-index:2;top:0;bottom:0}.flappy-obstacle span{position:absolute;right:0;left:0;background:linear-gradient(90deg,color-mix(in srgb,var(--tower),white 20%),var(--tower));box-shadow:inset -6px 0 8px #0003,0 0 13px #17204e55}.flappy-obstacle span::after{position:absolute;right:-4px;left:-4px;height:14px;border-radius:6px;background:color-mix(in srgb,var(--tower),white 10%);box-shadow:inset 0 3px 0 #ffffff25;content:""}.flappy-obstacle__top{top:0;border-radius:0 0 7px 7px}.flappy-obstacle__top::after{bottom:0}.flappy-obstacle__bottom{bottom:0;border-radius:7px 7px 0 0}.flappy-obstacle__bottom::after{top:0}.sky-glider--player{left:23%;animation:flappy-wing .2s ease-out}.flappy-ready{position:absolute;z-index:6;top:36%;left:50%;padding:9px 15px;border-radius:17px;background:#15284fbb;font-size:11px;transform:translateX(-50%)}.flappy-horizon{position:absolute;z-index:3;right:0;bottom:0;left:0;height:12px;background:#263a62;box-shadow:0 -5px 14px #ffffff26}.flappy-stage--crashed{animation:flappy-crash .55s ease-out}.flappy-game__hint{margin-top:8px;text-align:center}.flappy-overlay{position:absolute;z-index:12;inset:47px 0 25px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:9px;padding:28px;border-radius:22px;background:#101a38dc;backdrop-filter:blur(7px);text-align:center}.flappy-overlay>svg{color:#ffdc71}.flappy-overlay>span{color:#ffad78;font-size:9px;font-weight:900;letter-spacing:1px;text-transform:uppercase}.flappy-overlay h2{margin:0 0 5px;font-size:26px}
|
||||
@keyframes flappy-hero{from{transform:translateY(7px) rotate(-5deg)}to{transform:translateY(-8px) rotate(7deg)}}@keyframes flappy-trail{0%,100%{opacity:.25;transform:scaleX(.6)}50%{opacity:.9;transform:scaleX(1)}}@keyframes flappy-wing{from{filter:brightness(1.5);scale:1.08}to{filter:brightness(1);scale:1}}@keyframes cloud-drift{from{translate:90px 0}to{translate:-160px 0}}@keyframes flappy-crash{0%,100%{transform:translate(0)}25%{transform:translate(-5px,3px)}50%{transform:translate(5px,-2px)}75%{transform:translate(-3px,1px)}}button:active{transform:scale(.97)}@media(prefers-reduced-motion:reduce){.sky-glider,.flappy-menu__trail,.flappy-clouds i,.flappy-stage{animation:none}}
|
||||
</style>
|
||||
@@ -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" },
|
||||
|
||||
@@ -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-2wOQNR-p.js"></script>
|
||||
<script type="module" crossorigin src="./assets/sky-index-gZccfInr.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="./assets/sky-index-D9eWeoNZ.css">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
Reference in New Issue
Block a user