ADD - neon drop phone game

This commit is contained in:
smx.pusha
2026-08-06 10:07:33 +02:00
parent aba7dd99e8
commit 8f7fc165a5
15 changed files with 1666 additions and 1 deletions
@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<rect width="512" height="512" fill="#07101f"/>
<rect x="82" y="48" width="348" height="416" rx="42" fill="#111a3c" stroke="#65f4e4" stroke-width="10"/>
<path d="M104 82h304M104 130h304M104 178h304M104 226h304M104 274h304M104 322h304M104 370h304M104 418h304M130 64v384M178 64v384M226 64v384M274 64v384M322 64v384M370 64v384" stroke="#80a0d0" stroke-opacity=".12" stroke-width="4"/>
<g stroke="#ffffff" stroke-opacity=".55" stroke-width="4">
<path d="M178 106h48v48h48v48h-48v48h-48v-48h-48v-48h48Z" fill="#49e8e0"/>
<path d="M130 346h48v48h48v48h-96Z" fill="#5c8cff"/>
<path d="M226 394h48v48h-48Z" fill="#ffe265"/>
<path d="M274 346h48v48h48v48h-96Z" fill="#b86cff"/>
<path d="M370 394h38v48h-38Z" fill="#ff5f7e"/>
</g>
<path d="M202 76h24v22h-24Z" fill="#ffffff" opacity=".8"/>
</svg>

After

Width:  |  Height:  |  Size: 882 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

+6
View File
@@ -62,6 +62,12 @@ describe('app registry', () => {
labelKey: 'Apps.skyFlappy.name',
route: '/apps/sky-flappy',
})
expect(PHONE_APPS.find((app) => app.id === 'neon-drop')).toMatchObject({
dockOrder: null,
gridOrder: 17,
labelKey: 'Apps.neonDrop.name',
route: '/apps/neon-drop',
})
expect(
PHONE_APPS.filter((app) => app.dockOrder !== null)
.sort((a, b) => (a.dockOrder ?? 0) - (b.dockOrder ?? 0))
+15
View File
@@ -1,6 +1,7 @@
import {
Calculator,
Bomb,
Blocks,
Camera,
Clock3,
Gamepad2,
@@ -35,6 +36,7 @@ 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 neonDropIcon from '@/assets/img/app-icons/neon-drop.webp'
import weatherIcon from '@/assets/img/app-icons/weather.webp'
import type { PhoneAppDefinition, PhoneAppId } from '@/types/apps'
@@ -260,6 +262,19 @@ export const PHONE_APPS: PhoneAppDefinition[] = [
labelKey: 'Apps.skyFlappy.name',
route: '/apps/sky-flappy',
},
{
component: markRaw(
defineAsyncComponent(() => import('@/views/apps/NeonDropApp.vue')),
),
dockOrder: null,
gridOrder: 17,
icon: markRaw(Blocks),
iconClass: 'app-icon--neon-drop',
iconImage: neonDropIcon,
id: 'neon-drop',
labelKey: 'Apps.neonDrop.name',
route: '/apps/neon-drop',
},
]
export const PHONE_APP_IDS = PHONE_APPS.map((app) => app.id)
@@ -0,0 +1,76 @@
export type NeonDropSound =
| 'clear'
| 'drop'
| 'game-over'
| 'lock'
| 'move'
| 'rotate'
| 'start'
let context: AudioContext | undefined
function playSequence(sound: NeonDropSound): void {
context ??= new AudioContext()
const patterns: Record<
NeonDropSound,
Array<[number, number, number, OscillatorType]>
> = {
clear: [
[520, 0, 0.08, 'sine'],
[700, 0.07, 0.1, 'sine'],
[930, 0.15, 0.16, 'triangle'],
],
drop: [
[180, 0, 0.07, 'square'],
[95, 0.055, 0.11, 'triangle'],
],
'game-over': [
[260, 0, 0.16, 'sawtooth'],
[190, 0.14, 0.18, 'sawtooth'],
[110, 0.3, 0.28, 'triangle'],
],
lock: [[145, 0, 0.07, 'triangle']],
move: [[340, 0, 0.035, 'square']],
rotate: [
[410, 0, 0.045, 'triangle'],
[530, 0.035, 0.055, 'triangle'],
],
start: [
[330, 0, 0.08, 'sine'],
[500, 0.07, 0.1, 'triangle'],
[760, 0.15, 0.13, 'sine'],
],
}
const start = context.currentTime
for (const [frequency, delay, duration, type] of patterns[sound]) {
const oscillator = context.createOscillator()
const gain = context.createGain()
oscillator.type = type
oscillator.frequency.setValueAtTime(frequency, start + delay)
gain.gain.setValueAtTime(0.0001, start + delay)
gain.gain.exponentialRampToValueAtTime(0.12, start + delay + 0.008)
gain.gain.exponentialRampToValueAtTime(0.0001, start + delay + duration)
oscillator.connect(gain)
gain.connect(context.destination)
oscillator.start(start + delay)
oscillator.stop(start + delay + duration + 0.02)
}
}
export function playNeonDropSound(
sound: NeonDropSound,
enabled: boolean,
): void {
if (!enabled) return
context ??= new AudioContext()
if (context.state === 'suspended') {
void context
.resume()
.then(() => playSequence(sound))
.catch((error: unknown) => {
console.error(`[Neon Drop audio] Failed to resume for ${sound}`, error)
})
return
}
playSequence(sound)
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import {
canPlaceNeonDropPiece,
clearCompletedNeonDropLines,
createNeonDropBoard,
createNeonDropGame,
getNeonDropCells,
getNeonDropGhostPiece,
getNeonDropInterval,
hardDropNeonPiece,
moveNeonDropPiece,
NEON_DROP_COLUMNS,
NEON_DROP_ROWS,
rotateNeonDropPiece,
stepNeonDrop,
} from './engine'
describe('neon drop engine', () => {
it('starts with a valid piece and a complete seven-piece bag', () => {
const game = createNeonDropGame(() => 0.5)
expect(game.status).toBe('playing')
expect(new Set([game.active.kind, ...game.queue]).size).toBe(7)
expect(canPlaceNeonDropPiece(game.board, game.active)).toBe(true)
})
it('does not move through the side walls', () => {
let game = createNeonDropGame(() => 0)
for (let index = 0; index < NEON_DROP_COLUMNS; index += 1) {
game = moveNeonDropPiece(game, -1).state
}
expect(
Math.min(...getNeonDropCells(game.active).map((cell) => cell.x)),
).toBe(0)
expect(moveNeonDropPiece(game, -1).event).toBe('none')
})
it('rotates pieces while keeping every cell inside the board', () => {
const game = createNeonDropGame(() => 0.5)
const rotated = rotateNeonDropPiece(game)
expect(rotated.event).toBe('rotate')
expect(
canPlaceNeonDropPiece(rotated.state.board, rotated.state.active),
).toBe(true)
})
it('clears complete rows and moves remaining cells downward', () => {
const board = createNeonDropBoard()
board[NEON_DROP_ROWS - 1].fill('I')
board[NEON_DROP_ROWS - 2][0] = 'T'
const result = clearCompletedNeonDropLines(board)
expect(result.clearedLines).toBe(1)
expect(result.board[NEON_DROP_ROWS - 1][0]).toBe('T')
expect(result.board[0].every((cell) => cell === null)).toBe(true)
})
it('hard drops exactly onto the ghost position and awards distance points', () => {
const game = createNeonDropGame(() => 0.5)
const ghost = getNeonDropGhostPiece(game)
const distance = ghost.y - game.active.y
const result = hardDropNeonPiece(game, () => 0.5)
expect(result.event).toBe('lock')
expect(result.state.score).toBe(distance * 2)
expect(result.state.board.flat().filter(Boolean)).toHaveLength(4)
})
it('awards line points and advances the level after eight cleared rows', () => {
const game = createNeonDropGame(() => 0.5)
const board = createNeonDropBoard()
board[NEON_DROP_ROWS - 1].fill('J')
for (let x = 3; x <= 6; x += 1) board[NEON_DROP_ROWS - 1][x] = null
const result = hardDropNeonPiece({
...game,
active: { kind: 'I', rotation: 0, x: 3, y: NEON_DROP_ROWS - 2 },
board,
lines: 7,
})
expect(result.clearedLines).toBe(1)
expect(result.state.score).toBe(100)
expect(result.state.lines).toBe(8)
expect(result.state.level).toBe(2)
})
it('ends the round when the next piece cannot enter the board', () => {
const game = createNeonDropGame(() => 0.5)
const board = createNeonDropBoard()
board[2].fill('Z')
const result = stepNeonDrop({
...game,
active: { kind: 'O', rotation: 0, x: 3, y: 0 },
board,
})
expect(result.event).toBe('game-over')
expect(result.state.status).toBe('over')
})
it('increases speed each level but keeps a playable minimum interval', () => {
expect(getNeonDropInterval(2)).toBeLessThan(getNeonDropInterval(1))
expect(getNeonDropInterval(100)).toBe(110)
})
})
@@ -0,0 +1,393 @@
import type {
NeonDropActionResult,
NeonDropCell,
NeonDropGameState,
NeonDropPiece,
NeonDropPieceKind,
NeonDropPoint,
} from './types'
export const NEON_DROP_COLUMNS = 10
export const NEON_DROP_ROWS = 18
export const NEON_DROP_LINES_PER_LEVEL = 8
const PIECE_KINDS: NeonDropPieceKind[] = ['I', 'J', 'L', 'O', 'S', 'T', 'Z']
const LINE_SCORES = [0, 100, 300, 500, 800]
const SHAPES: Record<NeonDropPieceKind, NeonDropPoint[][]> = {
I: [
[
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 3, y: 1 },
],
[
{ x: 2, y: 0 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
{ x: 2, y: 3 },
],
[
{ x: 0, y: 2 },
{ x: 1, y: 2 },
{ x: 2, y: 2 },
{ x: 3, y: 2 },
],
[
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
{ x: 1, y: 3 },
],
],
J: [
[
{ x: 0, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
],
[
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
],
[
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
],
[
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 0, y: 2 },
{ x: 1, y: 2 },
],
],
L: [
[
{ x: 2, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
],
[
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
{ x: 2, y: 2 },
],
[
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 0, y: 2 },
],
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
],
],
O: [
[
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
],
],
S: [
[
{ x: 1, y: 0 },
{ x: 2, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
],
[
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 2, y: 2 },
],
[
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 0, y: 2 },
{ x: 1, y: 2 },
],
[
{ x: 0, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
],
],
T: [
[
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
],
[
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 2 },
],
[
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 2 },
],
[
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
],
],
Z: [
[
{ x: 0, y: 0 },
{ x: 1, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
],
[
{ x: 2, y: 0 },
{ x: 1, y: 1 },
{ x: 2, y: 1 },
{ x: 1, y: 2 },
],
[
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 1, y: 2 },
{ x: 2, y: 2 },
],
[
{ x: 1, y: 0 },
{ x: 0, y: 1 },
{ x: 1, y: 1 },
{ x: 0, y: 2 },
],
],
}
export function createNeonDropBoard(): NeonDropCell[][] {
return Array.from({ length: NEON_DROP_ROWS }, () =>
Array<NeonDropCell>(NEON_DROP_COLUMNS).fill(null),
)
}
function shuffleBag(random: () => number): NeonDropPieceKind[] {
const bag = [...PIECE_KINDS]
for (let index = bag.length - 1; index > 0; index -= 1) {
const target = Math.floor(
Math.max(0, Math.min(0.999999, random())) * (index + 1),
)
;[bag[index], bag[target]] = [bag[target], bag[index]]
}
return bag
}
export function getNeonDropCells(piece: NeonDropPiece): NeonDropPoint[] {
return SHAPES[piece.kind][piece.rotation % SHAPES[piece.kind].length].map(
(point) => ({ x: piece.x + point.x, y: piece.y + point.y }),
)
}
export function createNeonDropPiece(kind: NeonDropPieceKind): NeonDropPiece {
const points = SHAPES[kind][0]
const minX = Math.min(...points.map((point) => point.x))
const maxX = Math.max(...points.map((point) => point.x))
const minY = Math.min(...points.map((point) => point.y))
return {
kind,
rotation: 0,
x: Math.floor((NEON_DROP_COLUMNS - (maxX - minX + 1)) / 2) - minX,
y: -minY,
}
}
export function canPlaceNeonDropPiece(
board: NeonDropCell[][],
piece: NeonDropPiece,
): boolean {
return getNeonDropCells(piece).every(
({ x, y }) =>
x >= 0 &&
x < NEON_DROP_COLUMNS &&
y < NEON_DROP_ROWS &&
(y < 0 || board[y][x] === null),
)
}
export function createNeonDropGame(
random: () => number = Math.random,
): NeonDropGameState {
const queue = shuffleBag(random)
const kind = queue.shift() as NeonDropPieceKind
return {
active: createNeonDropPiece(kind),
board: createNeonDropBoard(),
level: 1,
lines: 0,
nextKind: queue[0],
queue,
score: 0,
status: 'playing',
}
}
export function moveNeonDropPiece(
state: NeonDropGameState,
direction: -1 | 1,
): NeonDropActionResult {
if (state.status !== 'playing')
return { clearedLines: 0, event: 'none', state }
const active = { ...state.active, x: state.active.x + direction }
if (!canPlaceNeonDropPiece(state.board, active)) {
return { clearedLines: 0, event: 'none', state }
}
return { clearedLines: 0, event: 'move', state: { ...state, active } }
}
export function rotateNeonDropPiece(
state: NeonDropGameState,
): NeonDropActionResult {
if (state.status !== 'playing')
return { clearedLines: 0, event: 'none', state }
const rotation =
(state.active.rotation + 1) % SHAPES[state.active.kind].length
for (const offset of [0, -1, 1, -2, 2]) {
const active = { ...state.active, rotation, x: state.active.x + offset }
if (canPlaceNeonDropPiece(state.board, active)) {
return { clearedLines: 0, event: 'rotate', state: { ...state, active } }
}
}
return { clearedLines: 0, event: 'none', state }
}
export function clearCompletedNeonDropLines(board: NeonDropCell[][]): {
board: NeonDropCell[][]
clearedLines: number
} {
const remaining = board.filter((row) => row.some((cell) => cell === null))
const clearedLines = NEON_DROP_ROWS - remaining.length
return {
board: [
...Array.from({ length: clearedLines }, () =>
Array<NeonDropCell>(NEON_DROP_COLUMNS).fill(null),
),
...remaining.map((row) => [...row]),
],
clearedLines,
}
}
function lockNeonDropPiece(
state: NeonDropGameState,
random: () => number,
): NeonDropActionResult {
const board = state.board.map((row) => [...row])
for (const { x, y } of getNeonDropCells(state.active)) {
if (y < 0) {
return {
clearedLines: 0,
event: 'game-over',
state: { ...state, status: 'over' },
}
}
board[y][x] = state.active.kind
}
const cleared = clearCompletedNeonDropLines(board)
let queue = state.queue.slice(1)
if (queue.length === 0) queue = shuffleBag(random)
const active = createNeonDropPiece(state.nextKind)
const lines = state.lines + cleared.clearedLines
const level = Math.floor(lines / NEON_DROP_LINES_PER_LEVEL) + 1
const score = state.score + LINE_SCORES[cleared.clearedLines] * state.level
const nextState: NeonDropGameState = {
...state,
active,
board: cleared.board,
level,
lines,
nextKind: queue[0],
queue,
score,
}
if (!canPlaceNeonDropPiece(nextState.board, active)) {
return {
clearedLines: cleared.clearedLines,
event: 'game-over',
state: { ...nextState, status: 'over' },
}
}
return {
clearedLines: cleared.clearedLines,
event: cleared.clearedLines > 0 ? 'clear' : 'lock',
state: nextState,
}
}
export function stepNeonDrop(
state: NeonDropGameState,
random: () => number = Math.random,
manual = false,
): NeonDropActionResult {
if (state.status !== 'playing')
return { clearedLines: 0, event: 'none', state }
const active = { ...state.active, y: state.active.y + 1 }
if (canPlaceNeonDropPiece(state.board, active)) {
return {
clearedLines: 0,
event: manual ? 'move' : 'none',
state: { ...state, active, score: state.score + (manual ? 1 : 0) },
}
}
return lockNeonDropPiece(state, random)
}
export function getNeonDropGhostPiece(state: NeonDropGameState): NeonDropPiece {
let active = { ...state.active }
while (canPlaceNeonDropPiece(state.board, { ...active, y: active.y + 1 })) {
active = { ...active, y: active.y + 1 }
}
return active
}
export function hardDropNeonPiece(
state: NeonDropGameState,
random: () => number = Math.random,
): NeonDropActionResult {
if (state.status !== 'playing')
return { clearedLines: 0, event: 'none', state }
const active = getNeonDropGhostPiece(state)
const distance = active.y - state.active.y
return lockNeonDropPiece(
{ ...state, active, score: state.score + distance * 2 },
random,
)
}
export function getNeonDropInterval(level: number): number {
return Math.max(110, 720 - (Math.max(1, level) - 1) * 55)
}
export function pauseNeonDrop(state: NeonDropGameState): NeonDropGameState {
return state.status === 'playing' ? { ...state, status: 'paused' } : state
}
export function resumeNeonDrop(state: NeonDropGameState): NeonDropGameState {
return state.status === 'paused' ? { ...state, status: 'playing' } : state
}
@@ -0,0 +1,109 @@
import { defineStore } from 'pinia'
import { useGamesStore } from '@/features/games/store'
import {
createNeonDropGame,
hardDropNeonPiece,
moveNeonDropPiece,
pauseNeonDrop,
resumeNeonDrop,
rotateNeonDropPiece,
stepNeonDrop,
} from './engine'
import type { NeonDropEvent, NeonDropGameState } from './types'
type NeonDropSave = {
bestLines: number
bestScore: number
soundEnabled: boolean
}
export const useNeonDropStore = defineStore('neon-drop', {
state: () => ({
bestLines: 0,
bestScore: 0,
game: null as NeonDropGameState | null,
hydrated: false,
menuOpen: true,
soundEnabled: true,
}),
actions: {
hydrate(): void {
if (this.hydrated) return
const saved = useGamesStore().readGame<Partial<NeonDropSave>>('neon-drop')
this.bestLines =
typeof saved?.bestLines === 'number' && saved.bestLines >= 0
? Math.floor(saved.bestLines)
: 0
this.bestScore =
typeof saved?.bestScore === 'number' && saved.bestScore >= 0
? Math.floor(saved.bestScore)
: 0
if (typeof saved?.soundEnabled === 'boolean') {
this.soundEnabled = saved.soundEnabled
}
this.hydrated = true
},
persist(): void {
useGamesStore().saveGame('neon-drop', {
bestLines: this.bestLines,
bestScore: this.bestScore,
soundEnabled: this.soundEnabled,
} satisfies NeonDropSave)
},
start(): void {
this.game = createNeonDropGame()
this.menuOpen = false
},
applyResult(result: {
event: NeonDropEvent
state: NeonDropGameState
}): NeonDropEvent {
this.game = result.state
if (result.state.status === 'over') {
this.bestLines = Math.max(this.bestLines, result.state.lines)
this.bestScore = Math.max(this.bestScore, result.state.score)
this.persist()
}
return result.event
},
move(direction: -1 | 1): NeonDropEvent {
if (!this.game) return 'none'
return this.applyResult(moveNeonDropPiece(this.game, direction))
},
rotate(): NeonDropEvent {
if (!this.game) return 'none'
return this.applyResult(rotateNeonDropPiece(this.game))
},
softDrop(): NeonDropEvent {
if (!this.game) return 'none'
return this.applyResult(stepNeonDrop(this.game, Math.random, true))
},
hardDrop(): NeonDropEvent {
if (!this.game) return 'none'
return this.applyResult(hardDropNeonPiece(this.game))
},
tick(): NeonDropEvent {
if (!this.game) return 'none'
return this.applyResult(stepNeonDrop(this.game))
},
pause(): void {
if (this.game) this.game = pauseNeonDrop(this.game)
},
resume(): void {
if (this.game) {
this.game = resumeNeonDrop(this.game)
this.menuOpen = false
}
},
showMenu(): void {
this.pause()
this.menuOpen = true
},
setSoundEnabled(enabled: boolean): void {
this.soundEnabled = enabled
this.persist()
},
},
})
@@ -0,0 +1,40 @@
export type NeonDropPieceKind = 'I' | 'J' | 'L' | 'O' | 'S' | 'T' | 'Z'
export type NeonDropCell = NeonDropPieceKind | null
export type NeonDropStatus = 'over' | 'paused' | 'playing'
export type NeonDropEvent =
| 'clear'
| 'drop'
| 'game-over'
| 'lock'
| 'move'
| 'none'
| 'rotate'
export type NeonDropPoint = {
x: number
y: number
}
export type NeonDropPiece = {
kind: NeonDropPieceKind
rotation: number
x: number
y: number
}
export type NeonDropGameState = {
active: NeonDropPiece
board: NeonDropCell[][]
level: number
lines: number
nextKind: NeonDropPieceKind
queue: NeonDropPieceKind[]
score: number
status: NeonDropStatus
}
export type NeonDropActionResult = {
clearedLines: number
event: NeonDropEvent
state: NeonDropGameState
}
+37
View File
@@ -300,6 +300,43 @@ const defaultLocales: LocaleTree = {
tapHint: 'Tap to rise · gravity pulls the glider down',
unmute: 'Turn on game sounds',
},
neonDrop: {
name: 'Neon Drop',
backToMenu: 'Back to game menu',
bestLines: 'Best Lines',
bestScore: 'Best Score',
board: 'Neon Drop reactor board',
controls: 'Block controls',
eyebrow: 'Energy block puzzle',
gameHint: 'Swipe the reactor or use the five controls',
gameOver: 'Reactor overloaded',
hardDrop: 'Drop block immediately',
howToBody:
'Fill complete horizontal rows to dissolve them and keep the reactor clear.',
howToTitle: 'Charge the reactor',
left: 'Move block left',
level: 'Level',
lines: 'Lines',
mainMenu: 'Main Menu',
menuBody:
'Rotate and place falling energy shapes. Every cleared row increases your charge.',
menuTitle: 'Build complete energy lines',
mute: 'Mute game sounds',
newGame: 'Start New Game',
next: 'Next',
pause: 'Pause or resume game',
paused: 'Paused',
playAgain: 'Play Again',
points: 'Points',
ready: 'Reactor ready',
resume: 'Continue Game',
right: 'Move block right',
rotate: 'Rotate block',
score: 'Score',
softDrop: 'Move block down',
start: 'Start Reactor',
unmute: 'Turn on game sounds',
},
map: {
name: 'Map',
controls: 'Map controls',
+1
View File
@@ -18,6 +18,7 @@ export type PhoneAppId =
| 'minesweeper'
| 'tower-stack'
| 'sky-flappy'
| 'neon-drop'
export type AppLaunchOrigin = {
borderRadius: number
+1
View File
@@ -56,6 +56,7 @@ const DEFAULT_APP_NOTIFICATIONS: Record<
minesweeper: { enabled: true, sounds: true },
'tower-stack': { enabled: true, sounds: true },
'sky-flappy': { enabled: true, sounds: true },
'neon-drop': { enabled: true, sounds: true },
camera: { enabled: true, sounds: true },
clock: { enabled: true, sounds: true },
weather: { enabled: true, sounds: true },
+859
View File
@@ -0,0 +1,859 @@
<script setup lang="ts">
import {
ArrowDown,
ArrowLeft,
ArrowRight,
ChevronLeft,
ChevronsDown,
Pause,
Play,
RotateCcw,
RotateCw,
Trophy,
Volume2,
VolumeX,
Zap,
} from 'lucide-vue-next'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { playNeonDropSound } from '@/features/games/neon-drop/audio'
import {
createNeonDropPiece,
getNeonDropCells,
getNeonDropGhostPiece,
getNeonDropInterval,
NEON_DROP_COLUMNS,
} from '@/features/games/neon-drop/engine'
import { useNeonDropStore } from '@/features/games/neon-drop/store'
import type {
NeonDropEvent,
NeonDropPieceKind,
} from '@/features/games/neon-drop/types'
import { usePhoneStore } from '@/stores/phone'
type RenderCell = {
active: boolean
ghost: boolean
key: number
kind: NeonDropPieceKind | null
}
const phone = usePhoneStore()
const neon = useNeonDropStore()
const game = computed(() => neon.game)
const clearEffect = ref(false)
let dropTimer: ReturnType<typeof setTimeout> | undefined
let effectTimer: ReturnType<typeof setTimeout> | undefined
let pointerStart: { x: number; y: number } | undefined
const renderedCells = computed<RenderCell[]>(() => {
if (!game.value) return []
const cells = game.value.board.flat().map((kind, key) => ({
active: false,
ghost: false,
key,
kind,
}))
for (const point of getNeonDropCells(getNeonDropGhostPiece(game.value))) {
if (point.y < 0) continue
const cell = cells[point.y * NEON_DROP_COLUMNS + point.x]
if (cell && cell.kind === null) {
cell.ghost = true
cell.kind = game.value.active.kind
}
}
for (const point of getNeonDropCells(game.value.active)) {
if (point.y < 0) continue
const cell = cells[point.y * NEON_DROP_COLUMNS + point.x]
if (cell) {
cell.active = true
cell.ghost = false
cell.kind = game.value.active.kind
}
}
return cells
})
const nextCells = computed(() => {
if (!game.value) return new Set<string>()
const points = getNeonDropCells(createNeonDropPiece(game.value.nextKind))
const minX = Math.min(...points.map((point) => point.x))
const minY = Math.min(...points.map((point) => point.y))
return new Set(points.map((point) => `${point.x - minX},${point.y - minY}`))
})
const levelProgress = computed(() => ((game.value?.lines ?? 0) % 8) / 8)
function cellClass(cell: RenderCell): Record<string, boolean> {
return {
'neon-cell--active': cell.active,
'neon-cell--ghost': cell.ghost,
[`neon-piece--${cell.kind}`]: cell.kind !== null,
}
}
function handleEvent(event: NeonDropEvent): void {
if (event === 'none') return
if (event === 'clear') {
if (effectTimer) clearTimeout(effectTimer)
clearEffect.value = true
effectTimer = setTimeout(() => {
clearEffect.value = false
effectTimer = undefined
}, 260)
}
playNeonDropSound(event === 'drop' ? 'drop' : event, neon.soundEnabled)
}
function clearDropTimer(): void {
if (dropTimer) clearTimeout(dropTimer)
dropTimer = undefined
}
function scheduleDrop(): void {
clearDropTimer()
if (game.value?.status !== 'playing') return
dropTimer = setTimeout(() => {
handleEvent(neon.tick())
scheduleDrop()
}, getNeonDropInterval(game.value.level))
}
function startGame(): void {
neon.start()
playNeonDropSound('start', neon.soundEnabled)
}
function move(direction: -1 | 1): void {
handleEvent(neon.move(direction))
}
function rotate(): void {
handleEvent(neon.rotate())
}
function softDrop(): void {
handleEvent(neon.softDrop())
}
function hardDrop(): void {
playNeonDropSound('drop', neon.soundEnabled)
const event = neon.hardDrop()
if (event !== 'lock') handleEvent(event)
}
function togglePause(): void {
if (game.value?.status === 'playing') neon.pause()
else if (game.value?.status === 'paused') neon.resume()
}
function toggleSound(): void {
const enabled = !neon.soundEnabled
neon.setSoundEnabled(enabled)
if (enabled) playNeonDropSound('rotate', true)
}
function onPointerDown(event: PointerEvent): void {
pointerStart = { x: event.clientX, y: event.clientY }
}
function onPointerUp(event: PointerEvent): void {
if (!pointerStart || game.value?.status !== 'playing') return
const deltaX = event.clientX - pointerStart.x
const deltaY = event.clientY - pointerStart.y
pointerStart = undefined
if (Math.abs(deltaX) < 14 && Math.abs(deltaY) < 14) rotate()
else if (Math.abs(deltaX) > Math.abs(deltaY)) move(deltaX > 0 ? 1 : -1)
else if (deltaY < 0) rotate()
else if (deltaY > 90) hardDrop()
else softDrop()
}
function onKeyDown(event: KeyboardEvent): void {
if (neon.menuOpen || game.value?.status !== 'playing') return
if (event.key === 'ArrowLeft' || event.key.toLowerCase() === 'a') move(-1)
else if (event.key === 'ArrowRight' || event.key.toLowerCase() === 'd')
move(1)
else if (event.key === 'ArrowUp' || event.key.toLowerCase() === 'w') rotate()
else if (event.key === 'ArrowDown' || event.key.toLowerCase() === 's')
softDrop()
else if (event.code === 'Space') hardDrop()
else return
event.preventDefault()
}
neon.hydrate()
watch(
() => game.value?.status,
(status) => {
if (status === 'playing') scheduleDrop()
else clearDropTimer()
},
{ immediate: true },
)
onMounted(() => document.addEventListener('keydown', onKeyDown))
onBeforeUnmount(() => {
clearDropTimer()
if (effectTimer) clearTimeout(effectTimer)
document.removeEventListener('keydown', onKeyDown)
neon.pause()
})
</script>
<template>
<main class="neon-drop-app" :aria-label="phone.t('Apps.neonDrop.name')">
<header class="neon-header">
<div>
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
</div>
<button
type="button"
:aria-label="
phone.t(
neon.soundEnabled ? 'Apps.neonDrop.mute' : 'Apps.neonDrop.unmute',
)
"
@click="toggleSound"
>
<Volume2 v-if="neon.soundEnabled" :size="18" /><VolumeX
v-else
:size="18"
/>
</button>
</header>
<section v-if="neon.menuOpen" class="neon-menu">
<div class="neon-hero" aria-hidden="true">
<div class="neon-hero__beam"></div>
<div class="neon-hero__piece neon-hero__piece--one">
<i v-for="cell in 4" :key="cell"></i>
</div>
<div class="neon-hero__piece neon-hero__piece--two">
<i v-for="cell in 4" :key="cell"></i>
</div>
<div class="neon-hero__floor">
<i v-for="cell in 8" :key="cell"></i>
</div>
</div>
<div class="neon-menu__copy">
<span>{{ phone.t('Apps.neonDrop.ready') }}</span>
<h2>{{ phone.t('Apps.neonDrop.menuTitle') }}</h2>
<p>{{ phone.t('Apps.neonDrop.menuBody') }}</p>
</div>
<div class="neon-records">
<div>
<span>{{ phone.t('Apps.neonDrop.bestScore') }}</span
><strong>{{ neon.bestScore }}</strong>
</div>
<div>
<span>{{ phone.t('Apps.neonDrop.bestLines') }}</span
><strong>{{ neon.bestLines }}</strong>
</div>
</div>
<div class="neon-how">
<Zap :size="17" />
<p>
<strong>{{ phone.t('Apps.neonDrop.howToTitle') }}</strong
>{{ phone.t('Apps.neonDrop.howToBody') }}
</p>
</div>
<button type="button" class="neon-primary" @click="startGame">
<Play :size="17" fill="currentColor" />{{
phone.t(game ? 'Apps.neonDrop.newGame' : 'Apps.neonDrop.start')
}}
</button>
<button
v-if="game?.status === 'paused'"
type="button"
class="neon-secondary"
@click="neon.resume()"
>
{{ phone.t('Apps.neonDrop.resume') }}
</button>
</section>
<section v-else-if="game" class="neon-game">
<div class="neon-toolbar">
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.backToMenu')"
@click="neon.showMenu()"
>
<ChevronLeft :size="19" />
</button>
<div>
<span>{{ phone.t('Apps.neonDrop.score') }}</span
><strong>{{ game.score }}</strong>
</div>
<div>
<span>{{ phone.t('Apps.neonDrop.lines') }}</span
><strong>{{ game.lines }}</strong>
</div>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.pause')"
@click="togglePause"
>
<Pause
v-if="game.status === 'playing'"
:size="16"
fill="currentColor"
/><Play v-else :size="16" fill="currentColor" />
</button>
</div>
<div class="neon-play-area">
<div
class="neon-board"
:class="{ 'neon-board--clear': clearEffect }"
:aria-label="phone.t('Apps.neonDrop.board')"
role="application"
@pointerdown.prevent="onPointerDown"
@pointerup.prevent="onPointerUp"
>
<i
v-for="cell in renderedCells"
:key="cell.key"
class="neon-cell"
:class="cellClass(cell)"
></i>
</div>
<aside class="neon-side">
<span>{{ phone.t('Apps.neonDrop.next') }}</span>
<div class="neon-preview" aria-hidden="true">
<i
v-for="index in 16"
:key="index"
:class="{
[`neon-piece--${game.nextKind}`]: nextCells.has(
`${(index - 1) % 4},${Math.floor((index - 1) / 4)}`,
),
}"
></i>
</div>
<span>{{ phone.t('Apps.neonDrop.level') }}</span
><strong>{{ game.level }}</strong>
<div class="neon-level">
<i :style="{ height: `${levelProgress * 100}%` }"></i>
</div>
</aside>
</div>
<div
class="neon-controls"
:aria-label="phone.t('Apps.neonDrop.controls')"
>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.left')"
@click="move(-1)"
>
<ArrowLeft :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.rotate')"
@click="rotate"
>
<RotateCw :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.right')"
@click="move(1)"
>
<ArrowRight :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.softDrop')"
@click="softDrop"
>
<ArrowDown :size="18" />
</button>
<button
type="button"
:aria-label="phone.t('Apps.neonDrop.hardDrop')"
@click="hardDrop"
>
<ChevronsDown :size="18" />
</button>
</div>
<p class="neon-hint">{{ phone.t('Apps.neonDrop.gameHint') }}</p>
<div v-if="game.status === 'paused'" class="neon-overlay">
<Pause :size="30" />
<h2>{{ phone.t('Apps.neonDrop.paused') }}</h2>
<button type="button" class="neon-primary" @click="neon.resume()">
{{ phone.t('Apps.neonDrop.resume') }}</button
><button type="button" class="neon-secondary" @click="neon.showMenu()">
{{ phone.t('Apps.neonDrop.mainMenu') }}
</button>
</div>
<div v-if="game.status === 'over'" class="neon-overlay">
<Trophy :size="33" /><span>{{
phone.t('Apps.neonDrop.gameOver')
}}</span>
<h2>{{ game.score }} {{ phone.t('Apps.neonDrop.points') }}</h2>
<p>{{ game.lines }} {{ phone.t('Apps.neonDrop.lines') }}</p>
<button type="button" class="neon-primary" @click="startGame">
<RotateCcw :size="16" />{{
phone.t('Apps.neonDrop.playAgain')
}}</button
><button type="button" class="neon-secondary" @click="neon.showMenu()">
{{ phone.t('Apps.neonDrop.mainMenu') }}
</button>
</div>
</section>
</main>
</template>
<style scoped>
.neon-drop-app {
position: absolute;
inset: 0;
overflow: hidden;
padding: 52px 14px 25px;
color: #f5fbff;
background: radial-gradient(circle at 50% 0, #253163, #090c22 58%, #050715);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
user-select: none;
touch-action: manipulation;
}
.neon-header {
height: 54px;
display: flex;
align-items: center;
justify-content: space-between;
}
.neon-header span,
.neon-menu__copy > span {
display: block;
color: #7cf6e7;
font-size: 8px;
font-weight: 900;
letter-spacing: 1.2px;
text-transform: uppercase;
}
.neon-header h1 {
margin: 1px 0 0;
font-size: 23px;
line-height: 1;
}
.neon-header button,
.neon-toolbar button {
width: 35px;
height: 35px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid #7cf6e72d;
border-radius: 12px;
color: #fff;
background: #ffffff0d;
}
.neon-menu {
height: calc(100% - 54px);
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
text-align: center;
}
.neon-hero {
position: relative;
width: 210px;
height: 145px;
overflow: hidden;
border: 1px solid #80f8ec35;
border-radius: 25px;
background: linear-gradient(160deg, #172557, #09102c);
box-shadow:
0 18px 32px #02040db3,
inset 0 0 35px #19f5de12;
}
.neon-hero::before {
position: absolute;
inset: 0;
background:
linear-gradient(#61f8e70a 1px, transparent 1px),
linear-gradient(90deg, #61f8e70a 1px, transparent 1px);
background-size: 18px 18px;
content: '';
}
.neon-hero__beam {
position: absolute;
top: 0;
left: 91px;
width: 28px;
height: 85px;
background: linear-gradient(#79fff000, #79fff033);
clip-path: polygon(35% 0, 65% 0, 100% 100%, 0 100%);
}
.neon-hero__piece {
position: absolute;
z-index: 2;
display: grid;
grid-template-columns: repeat(2, 18px);
gap: 2px;
filter: drop-shadow(0 0 8px currentColor);
}
.neon-hero__piece i,
.neon-hero__floor i {
height: 18px;
border: 1px solid #ffffff75;
border-radius: 4px;
background: currentColor;
box-shadow: inset 2px 2px #ffffff3d;
}
.neon-hero__piece--one {
top: 22px;
left: 86px;
color: #65f4e4;
animation: neon-fall 2.1s ease-in infinite;
}
.neon-hero__piece--two {
right: 44px;
bottom: 23px;
color: #ff668e;
transform: rotate(90deg);
}
.neon-hero__floor {
position: absolute;
right: 31px;
bottom: 4px;
left: 31px;
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
color: #7a6cff;
}
.neon-menu__copy h2 {
margin: 2px 0 4px;
font-size: 20px;
}
.neon-menu__copy p {
max-width: 272px;
margin: 0;
color: #b6c0db;
font-size: 10px;
line-height: 1.4;
}
.neon-records {
width: 100%;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 7px;
}
.neon-records div {
padding: 9px;
border: 1px solid #ffffff12;
border-radius: 13px;
background: #ffffff08;
}
.neon-records span,
.neon-side > span,
.neon-toolbar span {
display: block;
color: #99a8c9;
font-size: 8px;
font-weight: 850;
text-transform: uppercase;
}
.neon-records strong {
font-size: 18px;
}
.neon-how {
width: 100%;
display: flex;
align-items: center;
gap: 9px;
padding: 9px 11px;
border: 1px solid #6ff5e42a;
border-radius: 13px;
color: #72f5e4;
background: #34d7c60d;
text-align: left;
}
.neon-how p {
margin: 0;
color: #aeb9d4;
font-size: 9px;
line-height: 1.35;
}
.neon-how strong {
display: block;
color: #fff;
font-size: 10px;
}
.neon-primary,
.neon-secondary {
width: 100%;
min-height: 41px;
display: flex;
align-items: center;
justify-content: center;
gap: 7px;
border-radius: 13px;
font-size: 11px;
font-weight: 900;
}
.neon-primary {
border: 0;
color: #061521;
background: linear-gradient(135deg, #71f5e5, #73a7ff);
box-shadow: 0 8px 20px #39dfca2b;
}
.neon-secondary {
border: 1px solid #ffffff1b;
color: #fff;
background: #ffffff0a;
}
.neon-game {
position: relative;
height: calc(100% - 54px);
}
.neon-toolbar {
height: 42px;
display: grid;
grid-template-columns: 35px 1fr 1fr 35px;
align-items: center;
gap: 5px;
}
.neon-toolbar div {
text-align: center;
}
.neon-toolbar strong {
font-size: 14px;
}
.neon-play-area {
display: flex;
justify-content: center;
align-items: flex-start;
gap: 7px;
}
.neon-board {
width: 200px;
height: 360px;
display: grid;
grid-template-columns: repeat(10, 1fr);
grid-template-rows: repeat(18, 1fr);
gap: 2px;
padding: 5px;
border: 1px solid #65f4e43b;
border-radius: 13px;
background: #06091af2;
box-shadow:
inset 0 0 25px #000b,
0 12px 26px #0008;
touch-action: none;
}
.neon-cell {
display: block;
border: 1px solid #87a1c00c;
border-radius: 3px;
background: #ffffff06;
}
.neon-cell[class*='neon-piece--'] {
border-color: #ffffff70;
background: currentColor;
box-shadow:
inset 2px 2px #ffffff45,
inset -2px -2px #0003,
0 0 6px currentColor;
}
.neon-cell--ghost {
opacity: 0.2;
background: transparent !important;
outline: 1px dashed currentColor;
outline-offset: -2px;
box-shadow: none !important;
}
.neon-cell--active {
animation: neon-active 0.22s ease-out;
}
.neon-piece--I {
color: #49e8e0;
}
.neon-piece--J {
color: #5c8cff;
}
.neon-piece--L {
color: #ffad4c;
}
.neon-piece--O {
color: #ffe265;
}
.neon-piece--S {
color: #63e17e;
}
.neon-piece--T {
color: #b86cff;
}
.neon-piece--Z {
color: #ff5f7e;
}
.neon-board--clear {
animation: neon-clear 0.24s ease-out;
}
.neon-side {
width: 49px;
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
padding-top: 4px;
}
.neon-side > strong {
font-size: 22px;
}
.neon-preview {
width: 49px;
height: 49px;
display: grid;
grid-template-columns: repeat(4, 1fr);
grid-template-rows: repeat(4, 1fr);
gap: 1px;
padding: 4px;
border: 1px solid #ffffff16;
border-radius: 10px;
background: #ffffff08;
}
.neon-preview i[class*='neon-piece--'] {
border: 1px solid #ffffff68;
border-radius: 2px;
background: currentColor;
box-shadow: 0 0 5px currentColor;
}
.neon-level {
position: relative;
width: 7px;
height: 190px;
overflow: hidden;
border-radius: 8px;
background: #ffffff0c;
}
.neon-level i {
position: absolute;
right: 0;
bottom: 0;
left: 0;
border-radius: 8px;
background: linear-gradient(#73a7ff, #71f5e5);
box-shadow: 0 0 9px #69f4e5;
transition: height 0.25s;
}
.neon-controls {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 5px;
margin-top: 7px;
}
.neon-controls button {
height: 39px;
display: grid;
place-items: center;
padding: 0;
border: 1px solid #ffffff16;
border-radius: 11px;
color: #dffefd;
background: #ffffff0b;
}
.neon-controls button:last-child {
color: #071225;
background: linear-gradient(135deg, #ffe265, #ff9167);
}
.neon-hint {
margin: 5px 0 0;
color: #8d9bb9;
font-size: 8px;
text-align: center;
}
.neon-overlay {
position: absolute;
z-index: 10;
inset: 42px 0 19px;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 25px;
border-radius: 17px;
background: #080c21e8;
backdrop-filter: blur(6px);
text-align: center;
}
.neon-overlay > svg {
color: #71f5e5;
}
.neon-overlay > span {
color: #ff7fa0;
font-size: 9px;
font-weight: 900;
letter-spacing: 1px;
text-transform: uppercase;
}
.neon-overlay h2 {
margin: 0;
font-size: 24px;
}
.neon-overlay p {
margin: 0 0 4px;
color: #aeb8d0;
font-size: 10px;
}
@keyframes neon-fall {
0% {
opacity: 0;
transform: translateY(-25px);
}
18% {
opacity: 1;
}
72% {
transform: translateY(37px);
}
84%,
100% {
opacity: 0;
transform: translateY(37px);
}
}
@keyframes neon-active {
from {
filter: brightness(1.8);
transform: scale(0.82);
}
to {
filter: brightness(1);
transform: scale(1);
}
}
@keyframes neon-clear {
0% {
filter: brightness(1);
}
50% {
filter: brightness(2.1);
box-shadow:
inset 0 0 40px #fff8,
0 0 22px #67f8e5;
}
100% {
filter: brightness(1);
}
}
button:active {
transform: scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
.neon-hero__piece,
.neon-cell,
.neon-board {
animation: none;
}
}
</style>
+14
View File
@@ -136,6 +136,20 @@ Locales["en"] = {
resume = "Continue Flight", score = "Score", start = "Start Flight",
tapHint = "Tap to rise · gravity pulls the glider down", unmute = "Turn on game sounds",
},
neonDrop = {
name = "Neon Drop", backToMenu = "Back to game menu", bestLines = "Best Lines",
bestScore = "Best Score", board = "Neon Drop reactor board", controls = "Block controls",
eyebrow = "Energy block puzzle", gameHint = "Swipe the reactor or use the five controls",
gameOver = "Reactor overloaded", hardDrop = "Drop block immediately",
howToBody = "Fill complete horizontal rows to dissolve them and keep the reactor clear.",
howToTitle = "Charge the reactor", left = "Move block left", level = "Level", lines = "Lines",
mainMenu = "Main Menu", menuBody = "Rotate and place falling energy shapes. Every cleared row increases your charge.",
menuTitle = "Build complete energy lines", mute = "Mute game sounds", newGame = "Start New Game",
next = "Next", pause = "Pause or resume game", paused = "Paused", playAgain = "Play Again",
points = "Points", ready = "Reactor ready", resume = "Continue Game", right = "Move block right",
rotate = "Rotate block", score = "Score", softDrop = "Move block down", start = "Start Reactor",
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-CEASdl3u.js"></script>
<script type="module" crossorigin src="./assets/sky-index-BqqMbGkp.js"></script>
<link rel="stylesheet" crossorigin href="./assets/sky-index-D9eWeoNZ.css">
</head>
<body>