Created
May 8, 2024 13:37
-
-
Save cfreshman/715c15667a25849df5b6444db85de77b to your computer and use it in GitHub Desktop.
the most basic web implementation of snake i could come up with. play at https://freshman.dev/snake_ultra_hydra.html (desktop only)
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<!DOCTYPE html> | |
<html> | |
<head> | |
<meta charset=utf-8> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>snake ultra hydra</title> | |
<link rel="icon" href="https://i.guim.co.uk/img/media/993cc4a2107b870f78d1228874906ad9646fb204/0_144_2160_1296/master/2160.jpg?width=1200&height=1200&quality=85&auto=format&fit=crop&s=708569290282f1d206da9d72d5c2046c"> | |
<style>body{display:flex;flex-direction:column;margin:.5em;font-family:system-ui}</style> | |
<style> | |
#game-canvas { | |
image-rendering: pixelated; | |
/* 30rem by default, but shrink to window height w/ .5em margin */ | |
width: min(30rem, calc(100vh - 1em)); | |
height: min(30rem, calc(100vh - 1em)); | |
} | |
</style> | |
<script> | |
// COMMON CODE | |
// truthy: interpret the value as a boolean | |
const truthy = x => Boolean(x) | |
// V2: a two-dimensional vector value | |
// - add: add to another vector | |
// - equals: check if this vector equals another vector (e.g. same position) | |
class V2 { | |
constructor(x_or_vector, y) { | |
let x, vector | |
if (typeof x_or_vector === 'number') { | |
x = x_or_vector | |
} else { | |
vector = x_or_vector | |
} | |
if (vector) { | |
// allow copying another vector | |
this.x = vector.x | |
this.y = vector.y | |
} else { | |
this.x = x | |
this.y = y | |
} | |
} | |
add(other) { | |
return new V2(this.x + other.x, this.y + other.y) | |
} | |
equals(other) { | |
return this.x === other.x && this.y === other.y | |
} | |
} | |
// ListNode: linked list implementation | |
// store reference to first node to access entire list | |
class ListNode { | |
constructor(value, next) { | |
this.value = value | |
this.next = next | |
} | |
map(func) { | |
let curr = this | |
const results = [] | |
while (curr) { | |
results.push(func(curr.value)) | |
curr = curr.next | |
} | |
return results | |
} | |
} | |
</script> | |
</head> | |
<body> | |
<div> | |
WASD or ARROW KEYS to move. any other key to reset | |
</div> | |
<canvas id="game-canvas"></canvas> | |
<div> | |
(DESKTOP ONLY) | |
</div> | |
<script> | |
// board size 20x20 pixels | |
const SIZE = 20 | |
// cardinal directions the snake can move in | |
const DIRS = { | |
RIGHT: new V2(1, 0), | |
UP: new V2(0, -1), | |
LEFT: new V2(-1, 0), | |
DOWN: new V2(0, 1), | |
} | |
// set the canvas to SIZE width and height | |
const canvas = document.querySelector('#game-canvas') | |
canvas.width = canvas.height = SIZE | |
// get the canvas graphics context for drawing the game pixels | |
const ctx = canvas.getContext('2d') | |
// game colors | |
const COLORS = { | |
BACKGROUND: '#000000', | |
SNAKE: '#34eb7d', | |
APPLE: '#ff0000', | |
} | |
// Pixel: a drawable position | |
// - draw: draw a colored square at this position | |
// - randomize: move pixel to random position | |
class Pixel extends V2 { | |
constructor(x, y, color) { | |
super(x, y) | |
this.color = color | |
} | |
draw(ctx, color) { | |
ctx.fillStyle = color | |
ctx.fillRect(this.x, this.y, 1, 1) | |
return this | |
} | |
randomize() { | |
this.x = Math.floor(Math.random() * SIZE) | |
this.y = Math.floor(Math.random() * SIZE) | |
return this | |
} | |
} | |
// snake behaviors/functions | |
function snake_move(head, dir, do_grow=false) { | |
// moving the snake: | |
// body nodes don't actually change | |
// a new head is added to the front (in input direction) | |
// if the snake isn't growing, the last node is removed | |
const new_head = new ListNode(new Pixel(head.value.x + dir.x, head.value.y + dir.y, head.color), head) | |
if (!do_grow) { | |
let curr = head | |
while (curr.next.next) curr = curr.next | |
curr.next = undefined | |
} | |
return new_head | |
} | |
function snake_intersects(head, position) { | |
// true if any body parts are placed at the position | |
return head ? head.map(part => part.equals(position)).some(truthy) : false | |
} | |
// === GAME STATE === | |
// start the snake heading towards the right | |
let head = new ListNode(new Pixel(SIZE/4, SIZE/2, COLORS.SNAKE)) | |
let direction = DIRS.RIGHT | |
// how many new segments to grow | |
let growth = 2 | |
// place the apple at a random position | |
const apple = new Pixel(0, 0, COLORS.APPLE).randomize() | |
// is the game over | |
let gameover = false | |
// last is used to avoid letting the snake do a 180 | |
let last = direction | |
// === GAME LOOP === | |
function loop() { | |
if (gameover) return | |
// we check against 'last' in the input handler to prevent a 180 | |
// 'last' allows us to update direction immediately but always check the new direction against the actual last direction | |
last = direction | |
// move the snake | |
// gameover if snake intersects itself or went out of bounds | |
head = snake_move(head, direction, growth) | |
if (growth > 0) growth-- | |
if (snake_intersects(head.next, head.value) | |
|| head.value.x < 0 || head.value.x > SIZE-1 | |
|| head.value.y < 0 || head.value.y > SIZE-1) { | |
gameover = true | |
return | |
} | |
// check if the head is at the same position as the apple | |
// if so, grow the body and move the apple to a new location | |
if (head.value.equals(apple)) { | |
growth += 1 | |
do { | |
apple.randomize() | |
} while (snake_intersects(head, apple)) | |
} | |
// draw the scene: | |
// 1) cover the entire scene with black | |
// 2) fill each snake segment with green | |
// 3) fill the apple position with red | |
ctx.fillStyle = COLORS.BACKGROUND | |
ctx.fillRect(0, 0, SIZE, SIZE) | |
head.map(part => part.draw(ctx, COLORS.SNAKE)) | |
apple.draw(ctx, COLORS.APPLE) | |
} | |
// === GAME INPUT === | |
// listen for pressed keys to re-direct the snake | |
window.addEventListener('keydown', e => { | |
switch (e.key) { | |
case 'w': case 'ArrowUp': | |
if (last !== DIRS.DOWN) direction = DIRS.UP | |
break | |
case 'a': case 'ArrowLeft': | |
if (last !== DIRS.RIGHT) direction = DIRS.LEFT | |
break | |
case 's': case 'ArrowDown': | |
if (last !== DIRS.UP) direction = DIRS.DOWN | |
break | |
case 'd': case 'ArrowRight': | |
if (last !== DIRS.LEFT) direction = DIRS.RIGHT | |
break | |
default: | |
if (gameover) location.reload() | |
break | |
} | |
}) | |
// run loop() 10 times per second (every 100ms) (snake moves 10 cells/sec) | |
setInterval(loop, 100) | |
</script> | |
</body> | |
</html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment