mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-08-31 02:08:58 +00:00
859 lines
20 KiB
Vue
859 lines
20 KiB
Vue
<script setup lang="ts">
|
|
import {
|
|
ChevronLeft,
|
|
Pause,
|
|
Play,
|
|
RotateCcw,
|
|
Trophy,
|
|
Volume2,
|
|
VolumeX,
|
|
Zap,
|
|
} from 'lucide-vue-next'
|
|
import { kButton } from 'konsta/vue'
|
|
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"
|
|
:class="{ 'neon-drop-app--playing': !neon.menuOpen && game }"
|
|
:aria-label="phone.t('Apps.neonDrop.name')"
|
|
>
|
|
<header v-if="neon.menuOpen" class="neon-header">
|
|
<div>
|
|
<span>{{ phone.t('Apps.neonDrop.eyebrow') }}</span>
|
|
<h1>{{ phone.t('Apps.neonDrop.name') }}</h1>
|
|
</div>
|
|
<k-button
|
|
component="button"
|
|
clear
|
|
rounded
|
|
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"
|
|
/>
|
|
</k-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">
|
|
<k-button
|
|
component="button"
|
|
clear
|
|
rounded
|
|
type="button"
|
|
:aria-label="phone.t('Apps.neonDrop.backToMenu')"
|
|
@click="neon.showMenu()"
|
|
>
|
|
<ChevronLeft :size="19" />
|
|
</k-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>
|
|
<k-button
|
|
component="button"
|
|
clear
|
|
rounded
|
|
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" />
|
|
</k-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="{ width: `${levelProgress * 100}%` }"></i>
|
|
</div>
|
|
</aside>
|
|
</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-drop-app--playing {
|
|
padding: 0;
|
|
}
|
|
.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: 14px;
|
|
font-weight: 900;
|
|
letter-spacing: 1.2px;
|
|
text-transform: uppercase;
|
|
}
|
|
.neon-header h1 {
|
|
margin: 1px 0 0;
|
|
font-size: 32px;
|
|
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: 4px 0 7px;
|
|
font-size: 30px;
|
|
}
|
|
.neon-menu__copy p {
|
|
max-width: 296px;
|
|
margin: 0;
|
|
color: #e1e7f5;
|
|
font-size: 18px;
|
|
font-weight: 550;
|
|
line-height: 1.4;
|
|
}
|
|
.neon-records {
|
|
width: 100%;
|
|
display: grid;
|
|
grid-template-columns: 1fr 1fr;
|
|
gap: 7px;
|
|
}
|
|
.neon-records div {
|
|
padding: 12px 9px;
|
|
border: 1px solid #ffffff24;
|
|
border-radius: 13px;
|
|
background: #ffffff08;
|
|
}
|
|
.neon-records span,
|
|
.neon-side > span,
|
|
.neon-toolbar span {
|
|
display: block;
|
|
color: #d2dcef;
|
|
font-size: 12px;
|
|
font-weight: 850;
|
|
text-transform: uppercase;
|
|
}
|
|
.neon-records strong {
|
|
font-size: 26px;
|
|
}
|
|
.neon-how {
|
|
width: 100%;
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 9px;
|
|
padding: 12px 13px;
|
|
border: 1px solid #6ff5e42a;
|
|
border-radius: 13px;
|
|
color: #72f5e4;
|
|
background: #34d7c60d;
|
|
text-align: left;
|
|
}
|
|
.neon-how p {
|
|
margin: 0;
|
|
color: #dce4f3;
|
|
font-size: 16px;
|
|
line-height: 1.4;
|
|
}
|
|
.neon-how strong {
|
|
display: block;
|
|
color: #fff;
|
|
font-size: 17px;
|
|
}
|
|
.neon-primary,
|
|
.neon-secondary {
|
|
width: 100%;
|
|
min-height: 50px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 7px;
|
|
border-radius: 13px;
|
|
font-size: 18px;
|
|
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: absolute;
|
|
inset: 0;
|
|
}
|
|
.neon-toolbar {
|
|
position: absolute;
|
|
z-index: 10;
|
|
top: 66px;
|
|
right: 18px;
|
|
left: 18px;
|
|
height: 42px;
|
|
display: grid;
|
|
grid-template-columns: 32px 1fr 1fr 32px;
|
|
align-items: center;
|
|
gap: 4px;
|
|
padding: 4px;
|
|
border: 1px solid #7cf6e72b;
|
|
border-radius: 21px;
|
|
background: #101a38a8;
|
|
box-shadow: 0 8px 24px #0008;
|
|
backdrop-filter: blur(14px);
|
|
box-sizing: border-box;
|
|
}
|
|
.neon-toolbar span {
|
|
font-size: 11px;
|
|
line-height: 10px;
|
|
}
|
|
.neon-toolbar div {
|
|
height: 32px;
|
|
display: grid;
|
|
grid-template-rows: 10px 20px;
|
|
align-content: center;
|
|
justify-items: center;
|
|
}
|
|
.neon-toolbar strong {
|
|
display: block;
|
|
font-size: 19px;
|
|
line-height: 20px;
|
|
}
|
|
.neon-toolbar button {
|
|
width: 32px;
|
|
height: 32px;
|
|
border: 0;
|
|
border-radius: 50%;
|
|
box-shadow: none;
|
|
}
|
|
.neon-play-area {
|
|
position: absolute;
|
|
inset: 0;
|
|
}
|
|
.neon-board {
|
|
position: absolute;
|
|
top: 156px;
|
|
right: 0;
|
|
bottom: 76px;
|
|
left: 0;
|
|
display: grid;
|
|
grid-template-columns: repeat(10, 1fr);
|
|
grid-template-rows: repeat(17, 1fr);
|
|
gap: 1px;
|
|
padding: 0;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
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 {
|
|
position: absolute;
|
|
top: 110px;
|
|
right: 14px;
|
|
left: 14px;
|
|
height: 40px;
|
|
display: grid;
|
|
grid-template-columns: auto 40px auto auto 1fr;
|
|
align-items: center;
|
|
gap: 8px;
|
|
padding: 0 6px;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
box-shadow: none;
|
|
}
|
|
.neon-side > strong {
|
|
font-size: 21px;
|
|
}
|
|
.neon-preview {
|
|
width: 40px;
|
|
height: 40px;
|
|
display: grid;
|
|
grid-template-columns: repeat(4, 1fr);
|
|
grid-template-rows: repeat(4, 1fr);
|
|
gap: 1px;
|
|
padding: 3px;
|
|
border: 0;
|
|
border-radius: 0;
|
|
background: transparent;
|
|
}
|
|
.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: 100%;
|
|
height: 6px;
|
|
overflow: hidden;
|
|
border-radius: 8px;
|
|
background: #ffffff0c;
|
|
}
|
|
.neon-level i {
|
|
position: absolute;
|
|
top: 0;
|
|
bottom: 0;
|
|
left: 0;
|
|
border-radius: 8px;
|
|
background: linear-gradient(#73a7ff, #71f5e5);
|
|
box-shadow: 0 0 9px #69f4e5;
|
|
transition: width 0.25s;
|
|
}
|
|
.neon-hint {
|
|
position: absolute;
|
|
z-index: 6;
|
|
right: 45px;
|
|
bottom: 27px;
|
|
left: 45px;
|
|
margin: 0;
|
|
padding: 7px 10px;
|
|
border-radius: 999px;
|
|
color: #d6deed;
|
|
background: #101a3891;
|
|
backdrop-filter: blur(10px);
|
|
font-size: 10px;
|
|
font-weight: 700;
|
|
text-align: center;
|
|
pointer-events: none;
|
|
}
|
|
.neon-overlay {
|
|
position: absolute;
|
|
z-index: 10;
|
|
inset: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
align-items: center;
|
|
justify-content: center;
|
|
gap: 8px;
|
|
padding: 25px;
|
|
background: #080c21e8;
|
|
backdrop-filter: blur(6px);
|
|
text-align: center;
|
|
}
|
|
.neon-overlay > svg {
|
|
color: #71f5e5;
|
|
}
|
|
.neon-overlay > span {
|
|
color: #ff7fa0;
|
|
font-size: 13px;
|
|
font-weight: 900;
|
|
letter-spacing: 1px;
|
|
text-transform: uppercase;
|
|
}
|
|
.neon-overlay h2 {
|
|
margin: 0;
|
|
font-size: 30px;
|
|
}
|
|
.neon-overlay p {
|
|
margin: 0 0 4px;
|
|
color: #dce4f2;
|
|
font-size: 16px;
|
|
}
|
|
@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>
|