diff options
| author | LLLL Colonq <llll@colonq> | 2026-08-04 04:35:44 -0400 |
|---|---|---|
| committer | LLLL Colonq <llll@colonq> | 2026-08-04 04:35:44 -0400 |
| commit | 3c7796f066647090ed8436778bdfe25f11844c05 (patch) | |
| tree | 0644a101004d91af4fcdc2f51fcc2e03f7f255ce /2026/games/pralkarz/dzem.js | |
| parent | a75221ab93f59d688f90cd83f0284ff98951fcfa (diff) | |
Prepare for upload
Diffstat (limited to '2026/games/pralkarz/dzem.js')
| -rw-r--r-- | 2026/games/pralkarz/dzem.js | 483 |
1 files changed, 483 insertions, 0 deletions
diff --git a/2026/games/pralkarz/dzem.js b/2026/games/pralkarz/dzem.js new file mode 100644 index 0000000..5703577 --- /dev/null +++ b/2026/games/pralkarz/dzem.js @@ -0,0 +1,483 @@ +const WIDTH = 240; +const HEIGHT = 160; + +const HOOK_WIDTH = 24; +const HOOK_HEIGHT = 40; + +// TODO: Misters of size 24 are tweaking. +const MISTER_SIZES = [16, 20, 28, 32]; + +class AssetManager { + /** @type {HTMLImageElement[]} */ + images = []; + /** @type {HTMLAudioElement[]} */ + sounds = []; + + /** @type {HTMLImageElement} */ + green; + /** @type {HTMLImageElement} */ + hook; + /** @type {HTMLImageElement} */ + red; + /** @type {HTMLImageElement} */ + water; + + /** @type {HTMLAudioElement} */ + lose; + /** @type {HTMLAudioElement} */ + win; + + constructor() { + this.green = new Image(); + this.green.src = "assets/green.webp"; + this.images.push(this.green); + + this.hook = new Image(); + this.hook.src = "assets/hook.png"; + this.images.push(this.hook); + + this.numbers = new Image(); + this.numbers.src = "assets/numbers.png"; + this.images.push(this.numbers); + + this.red = new Image(); + this.red.src = "assets/red.webp"; + this.images.push(this.red); + + this.water = new Image(); + this.water.src = "assets/water.png"; + this.images.push(this.water); + + this.lose = new Audio(); + this.lose.src = "assets/lose.wav"; + this.lose.load(); + this.sounds.push(this.lose); + + this.win = new Audio(); + this.win.src = "assets/win.wav"; + this.win.load(); + this.sounds.push(this.win); + } +} + +const state = { + /** @type {?HTMLCanvasElement} */ + canvas: null, + /** @type {?CanvasRenderingContext2D} */ + ctx: null, + + /** @type {boolean} */ + ready: false, + + /** @type {boolean} */ + started: false, + /** @type {number} */ + difficulty: 0, + + /** @type {number} */ + frameCounter: 0, + /** @type {number} */ + previousFrameTime: 0, + /** @type {boolean} */ + resetPreviousFrameTime: false, + /** @type {number} */ + targetFrameTime: 1000 / 60, + + /** @type {number} */ + mousePosX: 0, + /** @type {boolean} */ + lmbPressed: false, + /** @type {boolean} */ + rmbPressed: false, + + /** @type {AssetManager} */ + assetManager: new AssetManager(), + + /** @type {number} */ + hookX: 0, + /** @type {number} */ + hookY: 0, + /** @type {number} */ + hookVelocity: -1, + + /** @type {Mister[]} */ + misters: [], + /** @type {boolean} */ + yoinkingMister: false, + /** @type {boolean} */ + ceaseYoinkage: false, + + /** @type {number} */ + elapsed: 0, + /** @type {number} */ + timer: 7, + + /** @type {boolean} */ + won: false, + /** @type {number} */ + wonFrames: 0, + /** @type {boolean} */ + lost: false, + /** @type {number} */ + lostFrames: 0, +}; + +const resetState = () => { + state.ceaseYoinkage = false; + state.difficulty = 0; + state.elapsed = 0; + state.hookVelocity = -1; + state.lmbPressed = false; + state.lost = false; + state.lostFrames = 0; + state.misters = []; + state.previousFrameTime = 0; + state.rmbPressed = false; + state.started = false; + state.timer = 7; + state.won = false; + state.wonFrames = 0; + state.yoinkingMister = false; +}; + +class Mister { + /** @type {HTMLImageElement} */ + image; + + /** @type {boolean} */ + isBad; + + /** @type {number} */ + size; + + /** @type {number} */ + x; + /** @type {number} */ + y; + + /** @type {number} */ + velocity; + + /** @type {boolean} */ + canBeYoinked; + /** @type {number} */ + yoinkCooldown; + /** @type {boolean} */ + isBeingYoinked; + + /** @type {boolean} */ + shouldBePurged; + + /** + * @param {HTMLImageElement} image + * @param {boolean} isBad + */ + constructor(image, isBad) { + this.image = image; + this.isBad = isBad; + + this.size = MISTER_SIZES[Math.floor(Math.random() * MISTER_SIZES.length)]; + + const left = Math.random() < 0.5; + this.x = left ? -this.size : WIDTH; + + const minY = this.isBad ? HOOK_HEIGHT + 15 : HEIGHT - 64; + this.y = getRandomInt(minY, HEIGHT - this.size / 2); + + const maxSpeed = 2 + Math.floor(state.difficulty / 5); + const speed = getRandomInt(1, maxSpeed); + this.velocity = left ? speed : -speed; + + this.canBeYoinked = true; + this.yoinkCooldown = 0; + this.isBeingYoinked = false; + + this.shouldBePurged = false; + } + + draw() { + state.ctx.drawImage(this.image, this.x, this.y, this.size, this.size); + } + + async update() { + this.yoinkCooldown -= 1; + if (this.yoinkCooldown < 0) { + this.canBeYoinked = true; + } + + if (!this.isBeingYoinked) { + this.x += this.velocity; + } else { + if (state.ceaseYoinkage) { + state.yoinkingMister = false; + state.ceaseYoinkage = false; + + this.canBeYoinked = false; + this.yoinkCooldown = 60; + this.isBeingYoinked = false; + + this.x += this.velocity; + } else { + this.x = state.hookX - this.size / 4; + this.y = state.hookY + HOOK_HEIGHT / 2; + } + } + + if (this.isBeingYoinked && this.y <= -5) { + if (this.isBad) { + state.assetManager.lose.play(); + state.lost = true; + state.lostFrames = 5; + } else { + state.assetManager.win.play(); + state.won = true; + state.wonFrames = 5; + } + + return; + } + + if ( + !state.yoinkingMister && + this.canBeYoinked && + state.hookVelocity < 0 && + aabb( + this.x, + this.x + this.size, + this.y, + this.y + this.size, + state.hookX, + state.hookX + 15, + state.hookY + 20, + state.hookY + 30, + ) + ) { + state.yoinkingMister = true; + this.isBeingYoinked = true; + } + + this.shouldBePurged = this.x < 0 - this.size || this.x > WIDTH; + } +} + +const getRandom = (min, max) => { + return Math.random() * (max - min) + min; +}; + +/** + * @param {number} min + * @param {number} max + */ +const getRandomInt = (min, max) => { + return Math.floor(Math.random() * (max - min + 1) + min); +}; + +/** + * @param {number} left1 + * @param {number} right1 + * @param {number} top1 + * @param {number} bottom1 + * @param {number} left2 + * @param {number} right2 + * @param {number} top2 + * @param {number} bottom2 + */ +const aabb = (left1, right1, top1, bottom1, left2, right2, top2, bottom2) => { + return left1 < right2 && right1 > left2 && top1 < bottom2 && bottom1 > top2; +}; + +/** + * @param {number} currentTime + */ +const update = (currentTime) => { + state.frameCounter += 1; + + if (state.resetPreviousFrameTime) { + state.previousFrameTime = currentTime; + state.resetPreviousFrameTime = false; + } + + state.elapsed += (currentTime - state.previousFrameTime) / 1000; + + if (state.won && state.wonFrames <= 0) { + window.parent.postMessage({ op: "done", win: true }); + resetState(); + return; + } + + if (state.lost && state.lostFrames <= 0) { + window.parent.postMessage({ op: "done", win: false }); + resetState(); + return; + } + + if (state.wonFrames > 0 || state.lostFrames > 0) { + state.misters = []; + + if (state.frameCounter % 24 === 0) { + state.wonFrames -= 1; + state.lostFrames -= 1; + } + + return; + } + + if (state.timer - Math.floor(state.elapsed) <= 0) { + state.assetManager.lose.play(); + state.lost = true; + state.lostFrames = 5; + return; + } + + state.hookY = Math.max( + Math.min(state.hookY + state.hookVelocity, 160 - HOOK_HEIGHT), + -((HOOK_HEIGHT / 4) * 3), + ); + + const spawnRate = 0.03 + Math.min(state.difficulty, 10) * 0.01; + if (Math.random() < 0.03 + spawnRate) { + const isBad = + Math.random() > + 0.3 - + (state.difficulty > 10 ? 0.1 : 0) + + Math.floor(state.elapsed / 3) * 0.05; + state.misters.push( + new Mister( + isBad ? state.assetManager.red : state.assetManager.green, + isBad, + ), + ); + } + + state.misters.forEach((mister) => mister.update()); + state.misters = state.misters.filter((mister) => !mister.shouldBePurged); + + state.previousFrameTime = currentTime; +}; + +const draw = () => { + state.ctx.drawImage(state.assetManager.water, 0, 0); + + if (state.lostFrames > 0 || state.wonFrames > 0) { + const size = + 16 * 2 ** (5 - (state.won ? state.wonFrames : state.lostFrames)); + state.ctx.drawImage( + state.won ? state.assetManager.green : state.assetManager.red, + WIDTH / 2 - size / 2, + HEIGHT / 2 - size / 2, + size, + size, + ); + return; + } + + const hook = state.assetManager.hook; + state.hookX = Math.max(state.mousePosX - hook.width / 2, 0); + state.hookX = Math.floor(Math.min(WIDTH - hook.width, state.hookX)); + state.ctx.drawImage(hook, state.hookX, state.hookY); + + if (state.hookY > 0) { + state.ctx.lineWidth = 1; + state.ctx.beginPath(); + state.ctx.moveTo(state.hookX + 18 - 0.5, 0); + state.ctx.lineTo(state.hookX + 18 - 0.5, state.hookY); + state.ctx.stroke(); + } + + state.misters.forEach((mister) => mister.draw()); + + const timerStr = (state.timer - Math.floor(state.elapsed)).toString(); + + // The magic `4` is the padding between digits so that they're not glued together. + let x = WIDTH - 4 - (timerStr.length * 16 + (timerStr.length - 1) * 4); + for (const strDigit of timerStr) { + const digit = Number(strDigit); + state.ctx.drawImage( + state.assetManager.numbers, + digit * 16, + 0, + 16, + 24, + x, + 4, + 16, + 24, + ); + x += 16 + 4; + } +}; + +/** + * @param {number} currentTime + */ +const frame = (currentTime) => { + if ( + !state.ready && + state.assetManager.images.every((img) => img.complete) && + state.assetManager.sounds.every((sound) => sound.readyState === 4) + ) { + window.parent.postMessage({ op: "ready" }); + state.ready = true; + + state.previousFrameTime = currentTime; + } + + if (state.started) { + if (currentTime - state.previousFrameTime >= state.targetFrameTime) { + update(currentTime); + } + + draw(); + } + + requestAnimationFrame(frame); +}; + +const initialize = () => { + window.addEventListener("message", (ev) => { + switch (ev.data.op) { + case "start": + state.started = true; + state.difficulty = ev.data.difficulty; + state.timer = 10 + Math.floor(state.difficulty / 5); + // This makes sure the countdown is correct. Ugly and there's probably a better solution. + state.resetPreviousFrameTime = true; + + window.parent.postMessage({ op: "started", verb: "yoink!" }); + break; + default: + console.log(`[dżem] Unexpected event: ${ev}`); + break; + } + }); + + state.canvas = document.getElementById("dzem"); + state.ctx = state.canvas.getContext("2d"); + state.ctx.imageSmoothingEnabled = false; + + state.canvas.addEventListener("mousemove", (e) => { + state.mousePosX = e.clientX; + }); + + state.canvas.addEventListener("mousedown", (e) => { + if (e.button === 0) { + state.hookVelocity = 2; + } + }); + + state.canvas.addEventListener("mouseup", (e) => { + if (e.button === 0) { + state.hookVelocity = -2; + } else if (e.button === 2) { + state.ceaseYoinkage = true; + } + }); + + state.canvas.addEventListener("contextmenu", (e) => { + e.preventDefault(); + }); + + frame(0); +}; + +initialize(); |
