Skip to content

Commit cc51f87

Browse files
committed
[feat][wip] Snake game
1 parent 4c7216e commit cc51f87

File tree

6 files changed

+357
-65
lines changed

6 files changed

+357
-65
lines changed

package-lock.json

Lines changed: 5 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212
"license": "MIT",
1313
"homepage": "https://arcade.trifrost.dev",
1414
"dependencies": {
15-
"@trifrost/core": "^0.47.4"
15+
"@trifrost/core": "^0.47.5"
1616
},
1717
"devDependencies": {
1818
"@cloudflare/workers-types": "^4.20250705.0",

src/routes/snake/Game.tsx

Lines changed: 177 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -3,80 +3,199 @@
33
* Game logic is coordinated through pub/sub events, with dynamic fragment injection for blocks and effects.
44
*/
55

6-
import { css, CFG } from '~/css';
7-
import { ModalClose } from '~/components/modules/Modal';
86
import { DESCRIPTION, PREVIEW, CFG as SNAKE_CFG, TITLE } from './constants';
97
import { Game } from '~/components/atoms/Game';
108
import { GameConfig } from '~/components/atoms/GameConfig';
119
import { GameExplanation } from '~/components/atoms/GameExplanation';
1210
import { GameSidebar } from '~/components/atoms/GameSidebar';
1311
import { KeyArrowsAll } from '~/components/atoms/Keys';
1412
import { Script } from '~/script';
13+
import { GameModal } from '~/components/atoms/GameModal';
14+
15+
type Dir = 'up' | 'down' | 'left' | 'right';
1516

1617
/* Defines all event types the Snake game will use. */
1718
export type SnakeGameEvents = {
1819
'snake:start': void;
1920
'snake:pause': void;
2021
'snake:gameover': void;
21-
'snake:evt:move': 'up' | 'down';
22+
'snake:food:consume': {uid:string};
23+
'snake:food:spawn': {uid:string; growth: number; fn: (pos:{row: number; col: number}) => void};
2224
};
2325

2426
export function SnakeGame() {
25-
return (
26-
<div
27-
className={css.use('f', 'fh', 'fj_sb', 'panel', {
28-
width: `${SNAKE_CFG.COLS * CFG.SIZE + 400}px`,
29-
height: `${SNAKE_CFG.ROWS * CFG.SIZE + 40}px`,
30-
position: 'relative',
31-
padding: css.$v.spaceL,
32-
})}
33-
>
34-
<GameConfig preview={PREVIEW} />
35-
<GameSidebar evtStart="snake:start" evtEnd="snake:gameover" width={30}>
36-
<GameExplanation
37-
title={TITLE}
38-
description={DESCRIPTION}
39-
keybindings={[
40-
{
41-
lbl: 'Arrow keys',
42-
description: 'move your snake',
43-
key: <KeyArrowsAll />,
44-
},
45-
]}
46-
sources={[
47-
{
48-
lbl: 'FXs source',
49-
url: 'https://pixabay.com/users/soundreality-31074404/',
50-
},
51-
{
52-
lbl: 'Track Source',
53-
url: 'https://pixabay.com/music/upbeat-level-iii-294428/',
54-
},
55-
]}
56-
/>
57-
</GameSidebar>
58-
<Game
59-
columns={SNAKE_CFG.COLS}
60-
rows={SNAKE_CFG.ROWS}
61-
evtStart={'snake:start'}
62-
evtPause={'snake:pause'}
63-
evtOver={'snake:gameover'}
64-
sound={{
65-
track: ['snaketrack.mp3', 'breakout2.mp3'],
66-
fx: {
67-
pong1: 'snake1.mp3',
68-
pong2: 'snake2.mp3',
69-
pong3: 'snake3.mp3',
27+
return (<GameModal columns={SNAKE_CFG.COLS}>
28+
<GameConfig preview={PREVIEW} />
29+
<GameSidebar evtStart="snake:start" evtEnd="snake:gameover" width={30}>
30+
<GameExplanation
31+
title={TITLE}
32+
description={DESCRIPTION}
33+
keybindings={[
34+
{
35+
lbl: 'Arrow keys',
36+
description: 'move your snake',
37+
key: <KeyArrowsAll />,
38+
},
39+
]}
40+
sources={[
41+
{
42+
lbl: 'FXs source',
43+
url: 'https://pixabay.com/users/soundreality-31074404/',
44+
},
45+
{
46+
lbl: 'Track Source',
47+
url: 'https://pixabay.com/music/upbeat-level-iii-294428/',
7048
},
49+
]}
50+
/>
51+
</GameSidebar>
52+
<Game
53+
columns={SNAKE_CFG.COLS}
54+
rows={SNAKE_CFG.ROWS}
55+
evtStart={'snake:start'}
56+
evtPause={'snake:pause'}
57+
evtOver={'snake:gameover'}
58+
sound={{
59+
track: ['snaketrack.mp3', 'breakout2.mp3'],
60+
fx: {
61+
pong1: 'snake1.mp3',
62+
pong2: 'snake2.mp3',
63+
pong3: 'snake3.mp3',
64+
},
65+
}}
66+
>
67+
<Script>
68+
{({ el, $ }) => {
69+
const SIZE = Number($.cssVar('boardSize'));
70+
const COLS = Number($.cssVar('snakeBoardCols'));
71+
const ROWS = Number($.cssVar('snakeBoardRows'));
72+
73+
let segments:[number,number][] = [];
74+
let dir: Dir = 'right';
75+
let dir_next: Dir = 'right';
76+
let food:{x:number; y:number;uid:string;growth:number}|null = null;
77+
let timer: number | null = null;
78+
let isPaused = false;
79+
80+
function reset() {
81+
const midX = Math.floor(COLS / 2);
82+
const midY = Math.floor(ROWS / 2);
83+
segments = [];
84+
for (let i = 0; i <= 3; i++) {
85+
segments.push([midX - i, midY]);
86+
}
87+
dir = dir_next = 'right';
88+
render();
89+
}
90+
91+
function clearTimer () {
92+
if (timer) clearInterval(timer);
93+
timer = null;
94+
}
95+
96+
function setTimer () {
97+
clearTimer();
98+
timer = setInterval(tick, 150);
99+
}
100+
101+
function render() {
102+
el.$publish('canvas:draw', {
103+
fn: (ctx) => {
104+
ctx.fillStyle = $.cssTheme('board_fg');
105+
ctx.strokeStyle = $.cssTheme('board_bg');
106+
for (const [x, y] of segments) {
107+
ctx.fillRect(x * SIZE + 1, y * SIZE + 1, SIZE - 2, SIZE - 2);
108+
ctx.strokeRect(x * SIZE, y * SIZE, SIZE, SIZE);
109+
}
110+
},
111+
});
112+
}
113+
114+
function tick() {
115+
if (isPaused) return;
116+
117+
dir = dir_next;
118+
119+
const n_x = segments[0][0] + (dir === 'left' ? -1 : dir === 'right' ? 1 : 0);
120+
const n_y = segments[0][1] + (dir === 'up' ? -1 : dir === 'down' ? 1 : 0);
121+
122+
if (
123+
(n_x < 0 || n_x >= COLS || n_y < 0 || n_y >= ROWS) /* Out of bounds */
124+
|| (segments.some(([x, y]) => x === n_x && y === n_y)) /* Hits self */
125+
) {
126+
el.$publish('snake:gameover');
127+
clearTimer();
128+
return;
129+
}
130+
131+
segments.unshift([n_x, n_y]);
132+
133+
if (food && n_x === food.x && n_y === food.y) {
134+
el.$publish('snake:food:consume', {uid: food.uid});
135+
for (let i = 1; i < food.growth; i++) segments.unshift([n_x, n_y]);
136+
food = null;
137+
spawnFood();
138+
} else {
139+
segments.pop();
140+
}
141+
142+
render();
143+
}
144+
145+
async function spawnFood() {
146+
const res = await $.fetch<DocumentFragment>('/snake/food');
147+
if (!res.ok || !res.content) return;
148+
149+
/* Pick random non-colliding position */
150+
const pos = (() => {
151+
let tries = 0;
152+
while (tries++ < 1000) {
153+
const col = Math.floor(Math.random() * COLS);
154+
const row = Math.floor(Math.random() * ROWS);
155+
if (!segments.some(([x, y]) => x === col && y === row)) return { col, row };
156+
}
157+
return { col: 0, row: 0 };
158+
})();
159+
160+
/* Listen for spawn metadata */
161+
el.$subscribeOnce('snake:food:spawn', ({uid, growth, fn}) => {
162+
food = {x: pos.col, y: pos.row, uid, growth };
163+
fn(pos);
164+
});
165+
166+
el.appendChild(res.content);
167+
}
168+
169+
/**
170+
* MARK: Pub/sub
171+
*/
172+
173+
el.$subscribe('snake:start', () => {
174+
isPaused = false;
175+
reset();
176+
spawnFood();
177+
setTimer();
178+
});
179+
el.$subscribe('snake:pause', () => isPaused = !isPaused);
180+
el.$subscribe('snake:gameover', () => clearTimer());
181+
182+
/* Key handler */
183+
const keyboardListener = $.on(document, 'keydown', (e) => {
184+
if (isPaused) return;
185+
switch (e.key) {
186+
case 'ArrowUp': return dir_next = dir !== 'down' ? 'up' : dir;
187+
case 'ArrowDown': return dir_next = dir !== 'up' ? 'down' : dir;
188+
case 'ArrowLeft': return dir_next = dir !== 'right' ? 'left' : dir;
189+
case 'ArrowRight': return dir_next = dir !== 'left' ? 'right' : dir;
190+
}
191+
});
192+
193+
el.$unmount = () => {
194+
clearTimer();
195+
keyboardListener();
196+
};
71197
}}
72-
>
73-
<Script>
74-
{({ el }) => {
75-
console.log('TODO');
76-
}}
77-
</Script>
78-
</Game>
79-
<ModalClose type="cross" />
80-
</div>
81-
);
198+
</Script>
199+
</Game>
200+
</GameModal>);
82201
}

0 commit comments

Comments
 (0)