mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 16:23:22 +00:00
ADD - neon drop phone game
This commit is contained in:
@@ -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 |
@@ -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))
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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',
|
||||
|
||||
@@ -18,6 +18,7 @@ export type PhoneAppId =
|
||||
| 'minesweeper'
|
||||
| 'tower-stack'
|
||||
| 'sky-flappy'
|
||||
| 'neon-drop'
|
||||
|
||||
export type AppLaunchOrigin = {
|
||||
borderRadius: number
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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>
|
||||
@@ -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" },
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user