From ba2405b7986837e44426285a31e94642f25f0696 Mon Sep 17 00:00:00 2001 From: Florian <45694132+flo-bit@users.noreply.github.com> Date: Tue, 17 Feb 2026 14:57:47 +0100 Subject: [PATCH] add snake game card --- .../games/SnakeGameCard/SnakeGameCard.svelte | 371 ++++++++++++++++++ src/lib/cards/games/SnakeGameCard/index.ts | 22 ++ .../cards/games/SnakeGameCard/snakeGame.ts | 214 ++++++++++ src/lib/cards/index.ts | 2 + 4 files changed, 609 insertions(+) create mode 100644 src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte create mode 100644 src/lib/cards/games/SnakeGameCard/index.ts create mode 100644 src/lib/cards/games/SnakeGameCard/snakeGame.ts diff --git a/src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte b/src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte new file mode 100644 index 0000000..5455056 --- /dev/null +++ b/src/lib/cards/games/SnakeGameCard/SnakeGameCard.svelte @@ -0,0 +1,371 @@ + + + + +
+
+ + +
+ SCORE {state.score} +
+ + {#if state.phase === 'idle' || state.phase === 'gameover'} + + {/if} +
+ + +
diff --git a/src/lib/cards/games/SnakeGameCard/index.ts b/src/lib/cards/games/SnakeGameCard/index.ts new file mode 100644 index 0000000..2cf9489 --- /dev/null +++ b/src/lib/cards/games/SnakeGameCard/index.ts @@ -0,0 +1,22 @@ +import type { CardDefinition, ContentComponentProps } from '$lib/cards/types'; +import type { Component } from 'svelte'; +import SnakeGameCard from './SnakeGameCard.svelte'; + +export const SnakeGameCardDefinition = { + type: 'snake-game', + contentComponent: SnakeGameCard as unknown as Component, + allowSetColor: true, + createNew: (card) => { + card.w = 4; + card.h = 3; + card.mobileW = 8; + card.mobileH = 8; + card.cardData = {}; + }, + canHaveLabel: false, + + keywords: ['snake', 'arcade', 'retro', 'game'], + groups: ['Games'], + name: 'Snake', + icon: `` +} as CardDefinition & { type: 'snake-game' }; diff --git a/src/lib/cards/games/SnakeGameCard/snakeGame.ts b/src/lib/cards/games/SnakeGameCard/snakeGame.ts new file mode 100644 index 0000000..bbd478c --- /dev/null +++ b/src/lib/cards/games/SnakeGameCard/snakeGame.ts @@ -0,0 +1,214 @@ +export type SnakeDirection = 'up' | 'down' | 'left' | 'right'; +export type SnakePhase = 'idle' | 'playing' | 'gameover'; + +export type SnakePosition = { + x: number; + y: number; +}; + +export type SnakeGameConfig = { + cols?: number; + rows?: number; + initialLength?: number; +}; + +export type SnakeGameState = { + cols: number; + rows: number; + initialLength: number; + snake: SnakePosition[]; + direction: SnakeDirection; + queuedDirection: SnakeDirection | null; + food: SnakePosition | null; + score: number; + tick: number; + phase: SnakePhase; +}; + +type RandomFn = () => number; + +const DEFAULT_COLS = 16; +const DEFAULT_ROWS = 16; +const DEFAULT_INITIAL_LENGTH = 3; + +const OPPOSITES: Record = { + up: 'down', + down: 'up', + left: 'right', + right: 'left' +}; + +const DIRECTION_VECTORS: Record = { + up: { x: 0, y: -1 }, + down: { x: 0, y: 1 }, + left: { x: -1, y: 0 }, + right: { x: 1, y: 0 } +}; + +function clampRandom(value: number): number { + if (!Number.isFinite(value)) return 0; + if (value <= 0) return 0; + if (value >= 1) return 0.999999999999; + return value; +} + +function positionKey(pos: SnakePosition): string { + return `${pos.x}:${pos.y}`; +} + +function isSamePosition(a: SnakePosition, b: SnakePosition): boolean { + return a.x === b.x && a.y === b.y; +} + +function buildInitialSnake(cols: number, rows: number, length: number): SnakePosition[] { + const safeLength = Math.max(2, Math.min(length, cols)); + const headX = Math.min(cols - 1, Math.max(safeLength - 1, Math.floor(cols / 2))); + const y = Math.max(0, Math.floor(rows / 2)); + + const snake: SnakePosition[] = []; + for (let i = 0; i < safeLength; i++) { + snake.push({ x: headX - i, y }); + } + + return snake; +} + +function spawnFood( + snake: SnakePosition[], + cols: number, + rows: number, + rng: RandomFn +): SnakePosition | null { + const occupied = new Set(snake.map(positionKey)); + const freeCells: SnakePosition[] = []; + + for (let y = 0; y < rows; y++) { + for (let x = 0; x < cols; x++) { + const key = `${x}:${y}`; + if (!occupied.has(key)) { + freeCells.push({ x, y }); + } + } + } + + if (freeCells.length === 0) { + return null; + } + + const index = Math.floor(clampRandom(rng()) * freeCells.length); + return freeCells[index]; +} + +function createBaseState( + { + cols = DEFAULT_COLS, + rows = DEFAULT_ROWS, + initialLength = DEFAULT_INITIAL_LENGTH + }: SnakeGameConfig, + rng: RandomFn, + phase: SnakePhase +): SnakeGameState { + const safeCols = Math.max(4, cols); + const safeRows = Math.max(4, rows); + const safeInitialLength = Math.max(2, Math.min(initialLength, safeCols)); + const snake = buildInitialSnake(safeCols, safeRows, safeInitialLength); + + return { + cols: safeCols, + rows: safeRows, + initialLength: safeInitialLength, + snake, + direction: 'right', + queuedDirection: null, + food: spawnFood(snake, safeCols, safeRows, rng), + score: 0, + tick: 0, + phase + }; +} + +export function createSnakeGame( + config: SnakeGameConfig = {}, + rng: RandomFn = Math.random +): SnakeGameState { + return createBaseState(config, rng, 'idle'); +} + +export function startSnakeGame(state: SnakeGameState): SnakeGameState { + if (state.phase === 'playing' || state.phase === 'gameover') return state; + return { ...state, phase: 'playing' }; +} + +export function restartSnakeGame( + state: SnakeGameState, + rng: RandomFn = Math.random +): SnakeGameState { + return createBaseState( + { cols: state.cols, rows: state.rows, initialLength: state.initialLength }, + rng, + 'playing' + ); +} + +export function setSnakeDirection( + state: SnakeGameState, + direction: SnakeDirection +): SnakeGameState { + if (state.phase !== 'playing') return state; + if (direction === state.direction || OPPOSITES[state.direction] === direction) { + return state; + } + return { ...state, queuedDirection: direction }; +} + +export function tickSnakeGame(state: SnakeGameState, rng: RandomFn = Math.random): SnakeGameState { + if (state.phase !== 'playing') return state; + + const nextDirection = state.queuedDirection ?? state.direction; + const vector = DIRECTION_VECTORS[nextDirection]; + const head = state.snake[0]; + const nextHead = { + x: (head.x + vector.x + state.cols) % state.cols, + y: (head.y + vector.y + state.rows) % state.rows + }; + + const isEating = Boolean(state.food && isSamePosition(nextHead, state.food)); + const collisionBody = isEating ? state.snake : state.snake.slice(0, -1); + + if (collisionBody.some((part) => isSamePosition(part, nextHead))) { + return { + ...state, + direction: nextDirection, + queuedDirection: null, + phase: 'gameover' + }; + } + + const snake = [nextHead, ...state.snake]; + if (!isEating) { + snake.pop(); + } + + let food = state.food; + let score = state.score; + let phase: SnakeGameState['phase'] = state.phase; + + if (isEating) { + score += 1; + food = spawnFood(snake, state.cols, state.rows, rng); + if (!food) { + phase = 'gameover'; + } + } + + return { + ...state, + snake, + food, + score, + tick: state.tick + 1, + direction: nextDirection, + queuedDirection: null, + phase + }; +} diff --git a/src/lib/cards/index.ts b/src/lib/cards/index.ts index 1f915c5..a374131 100644 --- a/src/lib/cards/index.ts +++ b/src/lib/cards/index.ts @@ -6,6 +6,7 @@ import { BlueskyPostCardDefinition } from './social/BlueskyPostCard'; import { BlueskyFeedCardDefinition } from './social/BlueskyFeedCard'; import { LatestBlueskyPostCardDefinition } from './social/LatestBlueskyPostCard'; import { DinoGameCardDefinition } from './games/DinoGameCard'; +import { SnakeGameCardDefinition } from './games/SnakeGameCard'; import { EmbedCardDefinition } from './media/EmbedCard'; import { TetrisCardDefinition } from './games/TetrisCard'; import { ImageCardDefinition } from './core/ImageCard'; @@ -71,6 +72,7 @@ export const AllCardDefinitions = [ SectionCardDefinition, BlueskyMediaCardDefinition, DinoGameCardDefinition, + SnakeGameCardDefinition, BlueskyProfileCardDefinition, GithubProfileCardDefitition, TetrisCardDefinition, -- 2.51.2