add byte beasties escape webtoy

This commit is contained in:
Eric Woodward 2024-04-29 23:42:44 -04:00
parent c4d3da4dc2
commit 4914e72a3e
21 changed files with 889 additions and 0 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View File

@ -0,0 +1,74 @@
<!DOCTYPE html>
<html>
<meta name="viewport" content="width=device-width, initial-scale=1" />
<head>
<title>Byte Beasties: Escape!</title>
<meta charset="UTF-8">
<link rel="stylesheet" href="styles.css">
</head>
<body>
<div class="content" id="content">
<h1>Byte Beasties: Escape!</h1>
<noscript>
Sorry, this game requires JS to function.
</noscript>
<div class="game" id="game">
<div class="settings" id="settings">
<div>
<h2>Story</h2>
<p>
The Byte Beasties are on the run from the GTP (General Thinking Program),
an evil AI that wants to absorb them into its software.
</p>
<p>
Help the Byte Beasties escape GTP's yellow absorption wave by
hitting the green logic gates and avoiding the red NULL gates.
</p>
</div>
<hr />
<form id="settingsForm" name="settings"" method="dialog"">
<h2>Settings</h2>
<p>
<label>Mode:
<select name="mode" id="settingsMode">
<option value="easy">Easy</option>
<option value="medium">Medium</option>
<option value="hard">Hard</option>
<option value="endless">Endless</option>
</select>
</label>
</p>
<p class="beastiePickerWrapper">
Beastie:
<div class="settingsBeastie" id="settingsBeastie"></div>
</p>
<p class="settingsFooter">
<button id="cancelSettings">Cancel</button>
<button id="applySettings">Apply & Reset</button>
</p>
</form>
</div>
</div>
<h2>Controls</h2>
<ul class="controls">
<li><strong>&larr; OR &rarr;</strong>: Move the beastie</dd>
<li><strong>P</strong>: Pause</li>
<li><strong>R</strong> Restart game</li>
<li><strong>S</strong>: Show Settings</dd>
</ul>
</div>
<!-- We load this library via "module" script-tag to guarantee ES6 minimum functionality -->
<script type="module" src="scripts/main.js"></script>
</body>
</html>

View File

@ -0,0 +1,656 @@
import { createLightning, getMousePos } from "./lightning.js";
const startGame = (canvas) => {
const ctx = canvas.getContext("2d"),
BEASTIE_IMAGES = [
"blinker_shadow_red.png",
"creeper_shadow_red.png",
"grabber_shadow_red.png",
"scanner_shadow_red.png",
"bobbler_shadow_yellow.png",
"gawker_shadow_yellow.png",
"reacher_shadow_yellow.png",
"shuffler_shadow_yellow.png",
"puffer_shadow_green.png",
"seer_shadow_green.png",
"spooker_shadow_green.png",
"thinker_shadow_green.png",
"chomper_shadow_blue.png",
"diver_shadow_blue.png",
"snapper_shadow_blue.png",
"walker_shadow_blue.png",
],
GATE = {
HEIGHT: 20,
MARGIN: 25,
MAX_SPACE: 140,
MIN_SPACE: 60,
START: canvas.height - 200,
WIDTH: 65,
},
game = {
interval: 1000 / 90, // ms/f = 1000 ms/s / 90 f/s
winScore: 15000,
winScores: {
easy: 15000,
medium: 30000,
hard: 50000,
endless: 0,
},
blockerFrequency: 0.3,
blockerFrequencies: {
easy: 0.3,
medium: 0.4,
hard: 0.5,
endless: 0.3,
},
hasBlockers: true,
isOver: false,
isPaused: false,
showRestartModal: false,
maxLead: 400,
maxSpeed: -8,
speed: -1,
speedBoost: {
duration: 1,
multiplier: 2,
},
},
drag = 0.3,
beastie = {
width: 48,
height: 48,
x: canvas.width / 2 - 20,
y: GATE.START + 100,
// velocity
dx: 0,
dy: 0,
// for tracking speed boosts
boostEnd: 0,
score: 0,
image: null,
},
player = {
dx: 0,
dy: 0,
},
enemy = {
width: canvas.width,
height: canvas.height,
x: 0,
y: canvas.height - 50,
delaySeconds: 5,
speed: -2,
},
random = (min, max) => Math.random() * (max - min) + min,
images = BEASTIE_IMAGES.reduce((acc, name) => {
const tempImg = new Image();
tempImg.src = `/webtoys/bbe/beasties/${name}`;
acc[name] = tempImg;
return acc;
}, {}),
img = new Image(),
hasCollision = (gate) =>
// Collision check: AAB (Axis-Aligned Bounding Box)
beastie.x < gate.x + GATE.WIDTH &&
beastie.x > gate.x - beastie.width &&
beastie.y < gate.y + GATE.HEIGHT &&
beastie.y > gate.y - beastie.height,
settingsBeastie = document.getElementById("settingsBeastie");
let // vertical space between gates
minPlatformSpace = GATE.MIN_SPACE,
maxPlatformSpace = GATE.MAX_SPACE,
gates = [
{
x: canvas.width / 2 - GATE.WIDTH / 2,
y: GATE.START,
touched: false,
blocker: false,
},
],
y = GATE.START,
keydown = false,
runtime = 0,
lastTimestamp;
img.onload = () => {
ctx.drawImage(img, beastie.x, beastie.y);
};
// img.src = "/webtoys/bbe/beasties/blinker_shadow_red.png";
Object.keys(images).forEach((key, i) => {
const input = document.createElement("input"),
label = document.createElement("label"),
bImage = images[key];
label.className = "beastieImageInputWrapper";
input.name = "beastie";
input.type = "radio";
// TODO: Add Cookie support
input.checked = i === 0;
input.value = key;
label.appendChild(input);
bImage.className = "beastieImage";
label.appendChild(bImage);
settingsBeastie.appendChild(label);
});
const renderLightning = () => {
var color = "hsla(60, 100%, 50%, .7)";
ctx.save();
ctx.globalCompositeOperation = "lighter";
ctx.strokeStyle = color;
ctx.shadowColor = color;
ctx.fillStyle = color;
ctx.fillRect(enemy.x, enemy.y, enemy.width, canvas.height);
ctx.fillStyle = "hsla(0, 0%, 10%, 0.2)";
ctx.shadowBlur = 0;
ctx.globalCompositeOperation = "source-over";
ctx.fillRect(enemy.x, enemy.y, enemy.width, canvas.height);
ctx.globalCompositeOperation = "lighter";
ctx.shadowBlur = 15;
const lightning = createLightning(canvas, enemy);
ctx.beginPath();
for (let i = 0; i < lightning.length; i++) {
ctx.lineTo(lightning[i].x, Math.max(lightning[i].y, enemy.y));
}
ctx.stroke();
// requestAnimationFrame(renderLightning);
ctx.restore();
};
const restartGame = () => {
const whichBeastie = document.querySelector(
'input[name="beastie"]:checked'
).value,
settingsMode = document.getElementById("settingsMode");
if (settingsMode) {
const { value } = settingsMode;
game.winScore = game.winScores[value];
game.blockerFrequency = game.blockerFrequencies[value];
}
img.src = `/webtoys/bbe/beasties/${whichBeastie}`;
game.isPaused = false;
game.isOver = false;
game.showRestartModal = false;
y = GATE.START;
minPlatformSpace = GATE.MIN_SPACE;
maxPlatformSpace = GATE.MAX_SPACE;
// all the gates - starts in the bottom middle
gates = [
{
x: canvas.width / 2 - GATE.WIDTH / 2,
y: GATE.START,
touched: false,
blocker: false,
},
];
beastie.dx = 0;
beastie.dy = 0;
beastie.boostEnd = 0;
beastie.score = 0;
beastie.x = canvas.width / 2 - 20;
beastie.y = canvas.height - 50;
player.dx = 0;
player.dy = 0;
enemy.y = canvas.height - 50;
runtime = 0;
while (y > 0) {
// the next gate can be placed above the previous one with a space
// somewhere between the min and max space
y -= GATE.HEIGHT + random(minPlatformSpace, maxPlatformSpace);
gates.push({
x: random(
GATE.MARGIN,
canvas.width - GATE.MARGIN - GATE.WIDTH
),
y,
touched: false,
});
}
},
// game loop
loop = (timestamp) => {
requestAnimationFrame(loop);
// limit FPS
if (timestamp - lastTimestamp <= game.interval) return;
if (!(game.isOver || game.isPaused))
runtime += timestamp - lastTimestamp;
lastTimestamp = timestamp;
if (game.isOver) {
ctx.save();
ctx.fillStyle = "yellow";
ctx.fillRect(
canvas.width / 4,
canvas.height / 4,
enemy.width / 2,
canvas.height / 2
);
ctx.fillStyle = "black";
ctx.font = "32px sans";
ctx.fillText(
beastie.y <= 0 ? "You Win!" : "Game Over",
canvas.width / 3,
canvas.height / 3,
canvas.width / 3
);
ctx.fillText(
`Score:
${beastie.score}`,
canvas.width / 3,
(canvas.height / 3) * 2,
canvas.width / 3
);
ctx.restore();
return;
}
if (game.showRestartModal) {
ctx.save();
ctx.fillStyle = "yellow";
ctx.fillRect(
canvas.width / 4,
canvas.height / 4,
enemy.width / 2,
canvas.height / 2
);
ctx.fillStyle = "black";
ctx.font = "48px sans";
ctx.fillText(
`Restart? (Y / N)`,
canvas.width / 3,
canvas.height / 3,
canvas.width / 3
);
ctx.restore();
return;
}
if (game.isPaused) {
ctx.save();
ctx.fillStyle = "yellow";
ctx.fillRect(
canvas.width / 4,
canvas.height / 3,
canvas.width / 2,
canvas.height / 3
);
ctx.fillStyle = "black";
ctx.font = "40px sans";
ctx.fillText(`Paused`, canvas.width / 3 - 5, canvas.height / 2);
ctx.restore();
return;
}
ctx.clearRect(0, 0, canvas.width, canvas.height);
// if beastie reaches the middle of the screen, move the gates down
// instead of beastie up to make it look like beastie is going up
if (
beastie.y < canvas.height - canvas.height / 3 &&
beastie.dy < 0 &&
(beastie.score <= game.winScore || !game.winScore)
) {
gates = gates.map(({ y, ...otherProps }) => ({
...otherProps,
y: y - beastie.dy,
}));
// add more gates to the top of the screen as beastie moves up
while (gates[gates.length - 1].y > 0) {
gates.push({
x: random(25, canvas.width - 25 - GATE.WIDTH),
y:
gates[gates.length - 1].y -
(GATE.HEIGHT +
random(minPlatformSpace, maxPlatformSpace)),
touched: false,
blocker:
game.hasBlockers &&
gates.filter(({ blocker }) => blocker).length /
gates.length <
game.blockerFrequency &&
Math.random() <= game.blockerFrequency
? true
: false,
});
// add a bit to the min/max gate space as the player goes up
minPlatformSpace += 0.5;
maxPlatformSpace += 0.5;
// cap max space
maxPlatformSpace = Math.min(
maxPlatformSpace,
canvas.height / 2
);
}
// move enemy backwards
enemy.y -= beastie.dy;
} else {
if (beastie.y <= 0) beastie.dy = -10;
beastie.y += beastie.dy;
}
// apply drag when key not pressed
if (!keydown) {
if (player.dx < 0) {
beastie.dx += drag;
// don't let dx go above 0
if (beastie.dx > 0) {
beastie.dx = 0;
player.dx = 0;
}
} else if (player.dx > 0) {
beastie.dx -= drag;
if (beastie.dx < 0) {
beastie.dx = 0;
player.dx = 0;
}
}
if (
player.dy === 0 ||
(player.dy < 0 && Date.now() > beastie.boostEnd)
) {
beastie.dy += drag;
// don't let dx go above 0
if (beastie.dy > game.speed) {
beastie.dy = game.speed;
player.dy = game.speed;
}
} else if (player.dy > 0) {
beastie.dy -= drag;
if (beastie.dy < 0) {
beastie.dy = 0;
player.dy = 0;
}
}
}
beastie.x += beastie.dx;
// make beastie wrap the screen
if (beastie.x + beastie.width < 0) {
beastie.x = canvas.width;
} else if (beastie.x > canvas.width) {
beastie.x = -beastie.width;
}
ctx.fillStyle = "green";
gates = gates.map((gate) => {
// draw gates
ctx.save();
if (gate.blocker) ctx.fillStyle = "red";
else ctx.fillStyle = gate.touched ? "lime" : "green";
ctx.fillRect(gate.x, gate.y, GATE.WIDTH, GATE.HEIGHT);
if (gate.blocker) {
ctx.save();
ctx.lineWidth = 4;
ctx.strokeStyle = "black";
ctx.beginPath();
ctx.moveTo(gate.x, gate.y);
ctx.lineTo(gate.x + GATE.WIDTH, gate.y + GATE.HEIGHT);
ctx.moveTo(gate.x + GATE.WIDTH, gate.y);
ctx.lineTo(gate.x, gate.y + GATE.HEIGHT);
ctx.stroke();
ctx.restore();
}
// make beastie stop if it collides with a blocking gate
if (gate.blocker && hasCollision(gate)) {
beastie.dy = game.speed * 0.5;
gate.touched = true;
}
// make beastie jump if it collides with a gate from above
if (
// beastie is falling
// beastie.dy > 0 &&
// beastie was previous above the gate
// prevDoodleY + doodle.height <= gate.y &&
// doodle collides with gate
!gate.blocker &&
// !gate.touched &&
hasCollision(gate)
) {
// reset beastie position so it's on top of the gate
// beastie.y = gate.y - beastie.height;
if (!gate.touched) {
beastie.dy = Math.max(
game.maxSpeed,
game.speedBoost.multiplier *
Math.min(beastie.dy, game.speed)
);
beastie.score += 100;
}
beastie.boostEnd =
Date.now() + game.speedBoost.duration * 1000;
gate.touched = true;
}
return gate;
});
// check on enemy
if (!runtime) runtime = 0;
if (runtime > enemy.delaySeconds * 1000)
enemy.y = enemy.y + enemy.speed;
if (enemy.y < 1) {
enemy.y = 0;
game.isOver = true;
} else if (enemy.y > beastie.y + game.maxLead)
enemy.y = beastie.y + game.maxLead;
// draw beastie
ctx.drawImage(
img,
beastie.x,
beastie.y,
beastie.width,
beastie.height
);
// +100 points for each offscreen blocker that wasn't touched
beastie.score +=
100 *
gates.filter(
(gate) =>
gate.y > canvas.height && gate.blocker && !gate.touched
).length;
// remove any offscreen gates
gates = gates.filter((gate) => gate.y <= canvas.height);
beastie.score += Math.floor(Math.abs(beastie.dy));
ctx.fillStyle = "white";
ctx.font = "12px sans";
ctx.fillText(`Score: ${beastie.score}`, 10, 20);
renderLightning();
};
// listen to keyboard events to move beastie
document.addEventListener("keydown", (e) => {
if (keydown) return;
if (e.code === "ArrowLeft") {
keydown = true;
player.dx = -1;
beastie.dx = -3;
} else if (e.code === "ArrowRight") {
keydown = true;
player.dx = 1;
beastie.dx = 3;
} else if (e.code === "KeyP") {
keydown = true;
game.isPaused = !game.isPaused;
} else if (e.code === "KeyR") {
keydown = true;
if (game.isOver) {
restartGame();
return;
}
game.isPaused = true;
game.showRestartModal = true;
} else if (e.code === "KeyS") {
keydown = true;
game.isPaused = true;
document.getElementById("settings").classList.add("showSettings");
} else if (e.code === "KeyY") {
keydown = true;
if (game.showRestartModal) restartGame();
} else if (e.code === "KeyN") {
keydown = true;
if (game.showRestartModal) {
game.showRestartModal = false;
game.isPaused = false;
}
/*
} else if (e.code === "ArrowDown") {
keydown = true;
player.dy = 1;
beastie.dy = 3;
} else if (e.code === "ArrowUp") {
keydown = true;
player.dy = -1;
beastie.dy = -3;
*/
}
});
document.addEventListener("keyup", () => {
keydown = false;
});
canvas.addEventListener(
"mousedown",
(e) => {
keydown = true;
const result = getMousePos(canvas, e);
if (result.x < canvas.width / 2) {
player.dx = -1;
beastie.dx = -3;
} else {
player.dx = 1;
beastie.dx = 3;
}
},
false
);
canvas.addEventListener(
"mousemove",
(e) => {
if (!keydown) return;
const result = getMousePos(canvas, e);
if (result.x < beastie.x) {
player.dx = -1;
beastie.dx = -3;
} else {
player.dx = 1;
beastie.dx = 3;
}
},
false
);
canvas.addEventListener(
"mouseup",
() => {
keydown = false;
},
false
);
canvas.addEventListener(
"touchstart",
(e) => {
mousePos = getTouchPos(canvas, e);
const touch = e.touches[0],
mouseEvent = new MouseEvent("mousedown", {
clientX: touch.clientX,
clientY: touch.clientY,
});
canvas.dispatchEvent(mouseEvent);
},
false
);
canvas.addEventListener(
"touchend",
() => {
var mouseEvent = new MouseEvent("mouseup", {});
canvas.dispatchEvent(mouseEvent);
},
false
);
canvas.addEventListener(
"touchmove",
(e) => {
const touch = e.touches[0];
keydown = true;
if (touch.clientX < beastie.x) {
player.dx = -1;
beastie.dx = -3;
} else {
player.dx = 1;
beastie.dx = 3;
}
canvas.dispatchEvent(mouseEvent);
},
false
);
document.getElementById("cancelSettings").addEventListener("click", (e) => {
// close modal
document.getElementById("settings").classList.remove("showSettings");
// leave game paused
});
document.getElementById("applySettings").addEventListener("click", (e) => {
// close modal
document.getElementById("settings").classList.remove("showSettings");
// restart game
restartGame();
});
document
.getElementById("toggleSettings")
?.addEventListener("click", (e) => {
// pause game
game.isPaused = true;
// show settings
document
.getElementById("settings")
.classList.toggle("showSettings");
});
// start the game
restartGame();
requestAnimationFrame(loop);
};
export { startGame };

View File

@ -0,0 +1,45 @@
export const createLightning = (canvas, enemy) => {
const { y: size } = enemy;
var center = { x: canvas.width / 2, y: canvas.height };
var minSegmentHeight = 5;
var roughness = 2;
var maxDifference = size / 5;
let lightning = [];
let segmentHeight = size / 3;
lightning.push({
x: center.x,
y: center.y + 200,
});
lightning.push({
x: Math.random() * (canvas.width - 100) + 50,
y: Math.abs((Math.random() - 0.9) * 100),
});
let currDiff = maxDifference;
while (segmentHeight > minSegmentHeight) {
const newSegments = [];
for (var i = 0; i < lightning.length - 1; i++) {
const start = lightning[i],
end = lightning[i + 1],
midX = (start.x + end.x) / 2,
newX = midX + (Math.random() * 2 - 1) * currDiff;
newSegments.push(start, { x: newX, y: (start.y + end.y) / 2 });
}
newSegments.push(lightning.pop());
lightning = newSegments;
currDiff /= roughness;
segmentHeight /= 2;
}
return lightning;
};
// Get the position of the mouse relative to the canvas
export const getMousePos = (canvasDom, mouseEvent) => {
var rect = canvasDom.getBoundingClientRect();
return {
x: mouseEvent.clientX - rect.left,
y: mouseEvent.clientY - rect.top,
};
};

View File

@ -0,0 +1,32 @@
// @license magnet:?xt=urn:btih:90dc5c0be029de84e523b9b3922520e79e0e6f08&dn=cc0.txt CC0-1.0
/****************************************************************************
* Planar Vagabond's Guide to the Multiverse (planarvagabond.com)
*
* Copyright 2023-2024 Eric Woodward
* Source released under CC0 Public Domain License v1.0
* https://www.planarvagabond.com/licenses/cc0/
* http://creativecommons.org/publicdomain/zero/1.0/
****************************************************************************/
import { startGame } from "./bbe.js";
export default (() => {
// we load this library as a module to guarantee baseline ES6 functionality
// Indicate JS is loaded
document.documentElement.className =
document.documentElement.className.replace("no-js", "js");
setTimeout(() => {
const canvas = document.createElement("canvas"),
contentDiv = document.getElementById("game");
canvas.setAttribute("width", "375");
canvas.setAttribute("height", "667");
canvas.setAttribute("id", "game");
contentDiv.appendChild(canvas);
startGame(canvas);
}, 1);
})();
// @license-end

View File

@ -0,0 +1,82 @@
html, body {
height: 100%;
margin: 0;
}
body {
display: flex;
align-items: center;
justify-content: center;
background-color: #333;
color: #ddd;
}
canvas {
border: 1px solid black;
z-index: 0;
}
.beastieImage {
max-width: 2rem;
}
.beastieImageInputWrapper {
align-items: center;
display: flex;
flex-direction: row;
justify-content: center;
padding: .5rem;
}
.content {
display: flex;
flex-direction: column;
max-width: 375px;
}
.game {
position: relative;
}
.settings {
background-color: rgba(33, 33, 33, .95);
bottom: 0;
left: 0;
opacity: 0;
padding: 0 1rem 1rem;
position: absolute;
right: 0;
top: 0;
transition: opacity ease 0.3s;
z-index: 1;
}
.settings.showSettings {
opacity: 1;
}
.settingsBeastie {
align-items: center;
display: flex;
flex-wrap: wrap;
justify-content: center;
}
.settingsBeastie label {
padding: .5rem;
}
.settingsFooter {
margin: 1rem;
text-align: right;
}
.toggleSettings {
background-color: rgba(33, 33, 33, .9);
border: 1px solid rgba(33, 33, 33, .9);
cursor: pointer;
position: absolute;
right: 10px;
top: 5px;
z-index: 2;
}