mirror of
https://github.com/sky-systems/sky_phone.git
synced 2026-09-04 16:23:22 +00:00
ENH - make Snake fullscreen and keyboard controlled
This commit is contained in:
@@ -5,7 +5,7 @@ import type {
|
||||
} from './types'
|
||||
|
||||
export const SNAKE_BOARD_WIDTH = 16
|
||||
export const SNAKE_BOARD_HEIGHT = 18
|
||||
export const SNAKE_BOARD_HEIGHT = 34
|
||||
|
||||
const DIRECTION_VECTORS: Record<SnakeDirection, SnakePoint> = {
|
||||
up: { x: 0, y: -1 },
|
||||
|
||||
@@ -234,7 +234,7 @@ const defaultLocales: LocaleTree = {
|
||||
speed: 'Speed',
|
||||
speeds: { fast: 'Fast', normal: 'Normal', relaxed: 'Relaxed' },
|
||||
start: 'Start Game',
|
||||
swipeHint: 'Swipe, tap the controls, or use arrow keys / WASD',
|
||||
swipeHint: 'Use the arrow keys to steer',
|
||||
},
|
||||
memory: {
|
||||
name: 'Memory',
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
ChevronUp,
|
||||
Pause,
|
||||
Play,
|
||||
} from 'lucide-vue-next'
|
||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, onMounted, watch } from 'vue'
|
||||
|
||||
import {
|
||||
SNAKE_BOARD_HEIGHT,
|
||||
@@ -23,17 +20,7 @@ import { usePhoneStore } from '@/stores/phone'
|
||||
|
||||
const phone = usePhoneStore()
|
||||
const snake = useSnakeStore()
|
||||
const touchStart = ref<SnakePoint | null>(null)
|
||||
const speedOptions: SnakeSpeed[] = ['relaxed', 'normal', 'fast']
|
||||
const directionButtons: Array<{
|
||||
direction: SnakeDirection
|
||||
icon: typeof ChevronUp
|
||||
}> = [
|
||||
{ direction: 'up', icon: ChevronUp },
|
||||
{ direction: 'left', icon: ChevronLeft },
|
||||
{ direction: 'down', icon: ChevronDown },
|
||||
{ direction: 'right', icon: ChevronRight },
|
||||
]
|
||||
const game = computed(() => snake.game)
|
||||
const boardMotionStyle = computed(() => ({
|
||||
'--snake-motion-duration': `${Math.min(
|
||||
@@ -90,7 +77,9 @@ function stopGameTimer(): void {
|
||||
function syncGameTimer(): void {
|
||||
stopGameTimer()
|
||||
if (snake.game?.status === 'playing') {
|
||||
gameTimer = setInterval(() => snake.tick(), snake.tickMilliseconds)
|
||||
gameTimer = setInterval(() => {
|
||||
snake.tick()
|
||||
}, snake.tickMilliseconds)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,16 +88,16 @@ function returnToMenu(): void {
|
||||
snake.showMenu()
|
||||
}
|
||||
|
||||
function startGame(): void {
|
||||
snake.start()
|
||||
}
|
||||
|
||||
function handleKeydown(event: KeyboardEvent): void {
|
||||
const directionByKey: Partial<Record<string, SnakeDirection>> = {
|
||||
ArrowDown: 'down',
|
||||
ArrowLeft: 'left',
|
||||
ArrowRight: 'right',
|
||||
ArrowUp: 'up',
|
||||
a: 'left',
|
||||
d: 'right',
|
||||
s: 'down',
|
||||
w: 'up',
|
||||
}
|
||||
const direction = directionByKey[event.key]
|
||||
|
||||
@@ -125,28 +114,6 @@ function handleKeydown(event: KeyboardEvent): void {
|
||||
}
|
||||
}
|
||||
|
||||
function beginSwipe(event: TouchEvent): void {
|
||||
const touch = event.changedTouches[0]
|
||||
touchStart.value = touch ? { x: touch.clientX, y: touch.clientY } : null
|
||||
}
|
||||
|
||||
function endSwipe(event: TouchEvent): void {
|
||||
const start = touchStart.value
|
||||
const touch = event.changedTouches[0]
|
||||
touchStart.value = null
|
||||
if (!start || !touch) return
|
||||
|
||||
const deltaX = touch.clientX - start.x
|
||||
const deltaY = touch.clientY - start.y
|
||||
if (Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 18) return
|
||||
|
||||
if (Math.abs(deltaX) > Math.abs(deltaY)) {
|
||||
snake.turn(deltaX > 0 ? 'right' : 'left')
|
||||
} else {
|
||||
snake.turn(deltaY > 0 ? 'down' : 'up')
|
||||
}
|
||||
}
|
||||
|
||||
snake.hydrate()
|
||||
watch(
|
||||
() => [snake.game?.status, snake.tickMilliseconds],
|
||||
@@ -162,8 +129,12 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="snake-app" :aria-label="phone.t('Apps.snake.name')">
|
||||
<header class="snake-header">
|
||||
<main
|
||||
class="snake-app"
|
||||
:class="{ 'snake-app--playing': game }"
|
||||
:aria-label="phone.t('Apps.snake.name')"
|
||||
>
|
||||
<header v-if="!game" class="snake-header">
|
||||
<span class="snake-brand">{{ phone.t('Apps.snake.name') }}</span>
|
||||
<div class="snake-score-card">
|
||||
<span>{{ phone.t('Apps.snake.highScore') }}</span>
|
||||
@@ -226,7 +197,7 @@ onBeforeUnmount(() => {
|
||||
{{ phone.t(`Apps.snake.speeds.${speed}`) }}
|
||||
</button>
|
||||
</fieldset>
|
||||
<button type="button" class="snake-primary" @click="snake.start">
|
||||
<button type="button" class="snake-primary" @click="startGame">
|
||||
<Play :size="18" fill="currentColor" />
|
||||
{{ phone.t('Apps.snake.start') }}
|
||||
</button>
|
||||
@@ -267,8 +238,6 @@ onBeforeUnmount(() => {
|
||||
class="snake-board"
|
||||
:style="boardMotionStyle"
|
||||
:aria-label="phone.t('Apps.snake.board')"
|
||||
@touchstart.passive="beginSwipe"
|
||||
@touchend.passive="endSwipe"
|
||||
>
|
||||
<span
|
||||
v-for="(_, index) in game.body.slice(1)"
|
||||
@@ -306,7 +275,7 @@ onBeforeUnmount(() => {
|
||||
<template v-else>
|
||||
<span class="snake-overline">{{ phone.t('Apps.snake.score') }} {{ game.score }}</span>
|
||||
<h2>{{ phone.t('Apps.snake.gameOver') }}</h2>
|
||||
<button type="button" class="snake-primary" @click="snake.start">
|
||||
<button type="button" class="snake-primary" @click="startGame">
|
||||
{{ phone.t('Apps.snake.restart') }}
|
||||
</button>
|
||||
<button type="button" class="snake-secondary" @click="returnToMenu">
|
||||
@@ -317,18 +286,6 @@ onBeforeUnmount(() => {
|
||||
</div>
|
||||
|
||||
<p class="snake-hint">{{ phone.t('Apps.snake.swipeHint') }}</p>
|
||||
<div class="snake-controls" :aria-label="phone.t('Apps.snake.controls')">
|
||||
<button
|
||||
v-for="control in directionButtons"
|
||||
:key="control.direction"
|
||||
type="button"
|
||||
:class="`snake-control--${control.direction}`"
|
||||
:aria-label="phone.t(`Apps.snake.directions.${control.direction}`)"
|
||||
@click="snake.turn(control.direction)"
|
||||
>
|
||||
<component :is="control.icon" :size="24" :stroke-width="2.6" />
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -348,6 +305,10 @@ onBeforeUnmount(() => {
|
||||
touch-action: none;
|
||||
}
|
||||
|
||||
.snake-app--playing {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.snake-header,
|
||||
.snake-game__meta {
|
||||
display: flex;
|
||||
@@ -507,13 +468,25 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.snake-game {
|
||||
padding-top: 8px;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
}
|
||||
|
||||
.snake-game__meta {
|
||||
height: 43px;
|
||||
position: absolute;
|
||||
z-index: 7;
|
||||
top: 48px;
|
||||
right: 14px;
|
||||
left: 14px;
|
||||
height: 42px;
|
||||
justify-content: flex-start;
|
||||
gap: 8px;
|
||||
padding: 4px 5px;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 22px;
|
||||
background: rgb(8 22 18 / 58%);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 18%);
|
||||
backdrop-filter: blur(14px);
|
||||
}
|
||||
|
||||
.snake-game__meta span {
|
||||
@@ -532,28 +505,27 @@ onBeforeUnmount(() => {
|
||||
height: 34px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border: 0;
|
||||
border-radius: 50%;
|
||||
color: #dff6d9;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
background: rgb(255 255 255 / 8%);
|
||||
}
|
||||
|
||||
.snake-game__meta .snake-game__pause { margin-left: auto; }
|
||||
.snake-game__meta .snake-game__back { flex: 0 0 auto; }
|
||||
|
||||
.snake-board {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
aspect-ratio: 16 / 18;
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
overflow: hidden;
|
||||
border: 1px solid rgb(167 231 157 / 16%);
|
||||
border-radius: 18px;
|
||||
border: 0;
|
||||
border-radius: 0;
|
||||
background-color: #162d26;
|
||||
background-image:
|
||||
linear-gradient(rgb(255 255 255 / 2%) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgb(255 255 255 / 2%) 1px, transparent 1px);
|
||||
background-size: calc(100% / 16) calc(100% / 18);
|
||||
box-shadow: inset 0 0 35px rgb(0 0 0 / 20%), 0 17px 35px rgb(0 0 0 / 20%);
|
||||
background-size: calc(100% / 16) calc(100% / 34);
|
||||
box-shadow: inset 0 0 55px rgb(0 0 0 / 24%);
|
||||
}
|
||||
|
||||
.snake-head,
|
||||
@@ -679,36 +651,22 @@ onBeforeUnmount(() => {
|
||||
}
|
||||
|
||||
.snake-hint {
|
||||
margin: 9px 0 6px;
|
||||
color: #71867c;
|
||||
position: absolute;
|
||||
z-index: 6;
|
||||
right: 50px;
|
||||
bottom: 27px;
|
||||
left: 50px;
|
||||
margin: 0;
|
||||
padding: 7px 10px;
|
||||
border-radius: 999px;
|
||||
color: #9eb2a8;
|
||||
background: rgb(8 22 18 / 48%);
|
||||
backdrop-filter: blur(10px);
|
||||
font-size: 10px;
|
||||
text-align: center;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.snake-controls {
|
||||
position: relative;
|
||||
width: 132px;
|
||||
height: 91px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.snake-controls button {
|
||||
position: absolute;
|
||||
width: 42px;
|
||||
height: 42px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 1px solid rgb(255 255 255 / 9%);
|
||||
border-radius: 13px;
|
||||
color: #cae6d1;
|
||||
background: rgb(255 255 255 / 7%);
|
||||
}
|
||||
|
||||
.snake-control--up { left: 45px; top: 0; }
|
||||
.snake-control--left { left: 0; top: 45px; }
|
||||
.snake-control--down { left: 45px; top: 45px; }
|
||||
.snake-control--right { right: 0; top: 45px; }
|
||||
|
||||
button:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@@ -126,7 +126,7 @@ Locales["en"] = {
|
||||
readyBody = "Collect fruit, grow longer, and stay clear of every wall.", readyTitle = "Ready to play?",
|
||||
restart = "Play Again", resume = "Resume game", score = "Score", speed = "Speed",
|
||||
speeds = { fast = "Fast", normal = "Normal", relaxed = "Relaxed" }, start = "Start Game",
|
||||
swipeHint = "Swipe, tap the controls, or use arrow keys / WASD",
|
||||
swipeHint = "Use the arrow keys to steer",
|
||||
},
|
||||
memory = {
|
||||
name = "Memory", backToMenu = "Back to board selection", board = "Memory card board",
|
||||
|
||||
Reference in New Issue
Block a user