ENH - make Snake fullscreen and keyboard controlled

This commit is contained in:
smx.pusha
2026-08-07 14:27:48 +02:00
parent e2393704dd
commit 1af799499b
4 changed files with 57 additions and 99 deletions
+1 -1
View File
@@ -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 },
+1 -1
View File
@@ -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',
+54 -96
View File
@@ -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);
}
+1 -1
View File
@@ -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",