|
3 | 3 | * Game logic is coordinated through pub/sub events, with dynamic fragment injection for blocks and effects. |
4 | 4 | */ |
5 | 5 |
|
6 | | -import { css, CFG } from '~/css'; |
7 | | -import { ModalClose } from '~/components/modules/Modal'; |
8 | 6 | import { DESCRIPTION, PREVIEW, CFG as SNAKE_CFG, TITLE } from './constants'; |
9 | 7 | import { Game } from '~/components/atoms/Game'; |
10 | 8 | import { GameConfig } from '~/components/atoms/GameConfig'; |
11 | 9 | import { GameExplanation } from '~/components/atoms/GameExplanation'; |
12 | 10 | import { GameSidebar } from '~/components/atoms/GameSidebar'; |
13 | 11 | import { KeyArrowsAll } from '~/components/atoms/Keys'; |
14 | 12 | import { Script } from '~/script'; |
| 13 | +import { GameModal } from '~/components/atoms/GameModal'; |
| 14 | + |
| 15 | +type Dir = 'up' | 'down' | 'left' | 'right'; |
15 | 16 |
|
16 | 17 | /* Defines all event types the Snake game will use. */ |
17 | 18 | export type SnakeGameEvents = { |
18 | 19 | 'snake:start': void; |
19 | 20 | 'snake:pause': void; |
20 | 21 | '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}; |
22 | 24 | }; |
23 | 25 |
|
24 | 26 | 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/', |
70 | 48 | }, |
| 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 | + }; |
71 | 197 | }} |
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>); |
82 | 201 | } |
0 commit comments