From 49356a99539a5a469f3be1a37abc5c3fa5b1fcf2 Mon Sep 17 00:00:00 2001 From: Add00 Date: Tue, 10 Dec 2024 18:23:47 -0500 Subject: [PATCH 1/5] added state management --- games/minesweeper/event.js | 192 +++++++++++++++++++++++++++++++++++++ 1 file changed, 192 insertions(+) create mode 100644 games/minesweeper/event.js diff --git a/games/minesweeper/event.js b/games/minesweeper/event.js new file mode 100644 index 0000000..dc373fa --- /dev/null +++ b/games/minesweeper/event.js @@ -0,0 +1,192 @@ +class EventEmitter { + _events; + + constructor() { + this._events = new Map(); + } + + /** + * Registers a listener for the specified event. + * @param {string} event - The event name. + * @param {Function} listener - The callback function to invoke when the event is emitted. + */ + on(event, listener) { + if (!this._events.has(event)) { + this._events.set(event, []); + } + this._events.get(event).push(listener); + } + + /** + * Emits an event, invoking all registered listeners with the provided arguments. + * @param {string} event - The event name. + * @param {...any} args - The arguments to pass to the listeners. + * @returns {boolean} - Returns true if the event had listeners, false otherwise. + */ + emit(event, ...args) { + if (!this._events.has(event)) { + return false; + } + for (const listener of this._events.get(event)) { + listener(...args); + } + return true; + } + + /** + * Removes a specific listener for the specified event. + * If no listener is provided, removes all listeners for the event. + * @param {string} event - The event name. + * @param {Function} [listener] - The listener to remove. + */ + off(event, listener) { + if (!this._events.has(event)) { + return; + } + if (!listener) { + this._events.delete(event); + } else { + const listeners = this._events.get(event); + const filteredListeners = listeners.filter(l => l !== listener); + if (filteredListeners.length > 0) { + this._events.set(event, filteredListeners); + } else { + this._events.delete(event); + } + } + } + + /** + * Removes all events and their listeners. + */ + clear() { + this._events.clear(); + } +} + +class EventSignal { + _value; + _listeners; + + /** + * @private + * Notifies all registered listeners of a value change. + */ + _notify() { + for (const listener of this._listeners) { + listener(this._value); + } + } + + constructor(value) { + this._listeners = new Set(); + this._value = value; + } + + /** + * Gets the current value of the signal. + * @returns {any} The current value. + */ + getValue() { + return this._value; + } + + /** + * Sets a new value and notifies listeners if it changes. + * @param {any} value - The new value. + */ + setValue(value) { + if (this._value !== value) { + this._value = value; + this._notify(); + } + } + + /** + * Subscribes a listener to value changes. + * @param {Function} listener - The function to invoke when the value changes. + * @returns {Function} A function to unsubscribe the listener. + */ + subscribe(listener) { + this._listeners.add(listener); + + return () => this._listeners.delete(listener); + } +} + +class InteractionTimer { + /** + * Formats a duration in milliseconds into a MM:SS string. + * @param {number} milliseconds - The duration to format. + * @returns {string} The formatted time. + */ + static format(milliseconds) { + const totalSeconds = Math.floor(milliseconds / 1000); + const minutes = Math.floor(totalSeconds / 60); + const seconds = totalSeconds % 60; + return `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`; + } + + _threshold; + _start; + _duration; + _paused; + + constructor(threshold = 250) { + this._threshold = threshold; + this._start = 0; + this._duration = 0; + this._paused = false; + } + + /** + * Starts the timer or resumes from a paused state. + */ + start() { + // Resume from paused state + if (this._paused) { + this._start = Date.now(); + this._paused = false; + } + // Start fresh + else { + this._start = Date.now(); + this._duration = 0; + } + } + + /** + * Ends the timer, recording the duration if not paused. + */ + end() { + if (!this._paused) { + this._duration = Date.now() - this._start; + } + } + + /** + * Pauses the timer, preserving the current duration. + */ + pause() { + if (!this._paused && this._start > 0) { + this._duration = Date.now() - this._start; + this._paused = true; + } + } + + /** + * Determines if the interaction was a click (short duration). + * @returns {boolean} True if the interaction was a click, false otherwise. + */ + isClick() { + return !this.isHold(); + } + + /** + * Determines if the interaction was a hold (long duration). + * @returns {boolean} True if the interaction was a hold, false otherwise. + */ + isHold() { + return this._duration >= this._threshold; + } +} \ No newline at end of file From d9aacd4dbfe98e6a47a74b85367c25a32de84894 Mon Sep 17 00:00:00 2001 From: Add00 Date: Tue, 10 Dec 2024 18:24:09 -0500 Subject: [PATCH 2/5] added minesweeper --- games/minesweeper/game.js | 462 +++++++++++++++++++++++++++++++++++ games/minesweeper/index.html | 43 ++++ 2 files changed, 505 insertions(+) create mode 100644 games/minesweeper/game.js create mode 100644 games/minesweeper/index.html diff --git a/games/minesweeper/game.js b/games/minesweeper/game.js new file mode 100644 index 0000000..d87bbb9 --- /dev/null +++ b/games/minesweeper/game.js @@ -0,0 +1,462 @@ +const SCREEN_WIDTH = innerWidth > 800 ? 800 : innerWidth; +const SCREEN_HEIGHT = SCREEN_WIDTH * 0.75; + +const COLOR = { + BLACK: 0, + SLATE: 1, + GREY: 2, + BEIGE: 3, + RED: 4, + MUSTARD: 5, + BLUE: 6, + TEAL: 7, + GREEN: 8, + LIME: 9, + BROWN: 10, + GOLD: 11 +}; + +const SYMBOL = { + MINE: '💣', + FLAG: '🚩', + CLOCK: '⏰', +}; + +const ROWS = 10; +const COLS = 10; +const DIFF = 10; +const TILE_SIZE = 34; + +const MODE = 'PLAY'; // DEBUG | PLAY + +class Tile { + _mine; + _hover; + _flagged; + _revealed; + _adjacent; + + constructor({ mine = false, hover = false, flagged = false, revealed = false, adjacent = 0 } = {}) { + this._mine = mine; + this._hover = hover; + this._flagged = flagged; + this._revealed = revealed; + this._adjacent = adjacent; + } + + activateMine() { + this._mine = true; + } + + setHover() { + this._hover = true; + } + + unsetHover() { + this._hover = false; + } + + deactivateMine() { + this._mine = false; + } + + setRevealed() { + this._revealed = true; + } + + toggleFlag() { + this._flagged = !this._flagged; + } +} + +class Coordinate { + _x; + _y; + + constructor(x, y) { + this._x = x; + this._y = y; + } + + set(x, y) { + this._x = x; + this._y = y; + } + + equals(coordinate) { + return this._x === coordinate._x && this._y === coordinate._y; + } +} + +class Board { + static _directions = [ + [-1, 0], // up + [1, 0], // down + [0, -1], // left + [0, 1], // right + [-1, -1], // top-left + [-1, 1], // top-right + [1, -1], // bottom-left + [1, 1] // bottom-right + ]; + + _layout; + _mines; + + constructor({ rows, cols, diff }) { + this._layout = []; + this._mines = new Set(); + + // create board + for (let r = 0; r < rows; r++) { + this._layout[r] = []; + for (let c = 0; c < cols; c++) { + this._layout[r][c] = new Tile(); + } + } + + // place mines + while (this._mines.size < diff) { + const randomIndex = Math.floor(rand() * rows * cols); + const row = Math.floor(randomIndex / cols); + const col = randomIndex % cols; + + if (!this.at({ x: row, y: col }).mine) { + this.at({ x: row, y: col }).activateMine(); + this._mines.add(new Coordinate(row, col)); + } + } + + // calculate adjacencies + for (const mine of this._mines) { + for (const [dx, dy] of Board._directions) { + const newRow = mine._x + dx; + const newCol = mine._y + dy; + + const tile = this.at({ x: newRow, y: newCol }); + + if ( + newRow >= 0 && newRow < this._layout.length && + newCol >= 0 && newCol < this._layout[0].length && + tile + ) { + tile._adjacent += 1; + } + } + } + } + + reveal({ row, col }) { + const result = new Set(); + + const revealTile = (r, c) => { + if (r < 0 || c < 0 || r >= this._layout.length || c >= this._layout[0].length) { + return; + } + + const tile = this.at({ x: r, y: c }); + + // If tile is already revealed, stop + if (tile?._revealed || tile?._mine || tile?._flagged) { + return; + } + + // Otherwise, mark the tile as revealed + tile?.setRevealed(); + result.add(new Coordinate(r, c)); + + if (tile?._adjacent === 0) { + // Recursively reveal adjacent tiles if the current tile has no adjacent mines + for (const [dx, dy] of Board._directions) { + const newRow = r + dx; + const newCol = c + dy; + + // Ensure the tile is within bounds + if (newRow >= 0 && newCol >= 0 && newRow < this._layout.length && newCol < this._layout[0].length) { + revealTile(newRow, newCol); + } + } + } + }; + + revealTile(row, col); + + return result; + } + + revealAll() { + for (const { row, col, tile } of board) { + if (!tile._revealed) { + tile.setRevealed(); + } + } + } + + at({ x, y }) { + return this._layout?.at(x)?.at(y); + } + + *[Symbol.iterator]() { + for (let row = 0; row < this._layout.length; row++) { + for (let col = 0; col < this._layout[row].length; col++) { + yield { row: row, col: col, tile: this._layout[row][col] }; + } + } + } +} + +const interactionTimer = new InteractionTimer(); +const gameTimer = new InteractionTimer(); + +const engine = litecanvas({ + width: SCREEN_WIDTH, + height: SCREEN_HEIGHT, + canvas: "#game canvas", + autoscale: false, +}); + +const board = new Board({ rows: ROWS, cols: COLS, diff: DIFF }); +const boardOffset = (WIDTH - board._layout[0].length * TILE_SIZE) / 2; + +let state = 'game:play'; +let flagCounter = DIFF; + +const emitter = new EventEmitter(); +emitter.on('tap:pending', (data) => { + const tile = board.at({ x: data.row, y: data.col }); + + if (tile._revealed || tile._hover || tile._flagged) { + return; + } + + tile.setHover(); +}); +emitter.on('tap:flag', (data) => { + const tile = board.at({ x: data.row, y: data.col }); + + if (tile._revealed) { + return; + } + + tile.unsetHover(); + tile.toggleFlag(); + + if (tile._flagged) { + flagCounter -= 1; + } + else { + flagCounter += 1; + } + + console.log(tile); +}); +emitter.on('tap:reveal', (data) => { + const tile = board.at({ x: data.row, y: data.col }); + + if (tile._flagged) { + return; + } + + if (tile._adjacent === 0) { + board.reveal({ row: data.row, col: data.col }); + } + + tile.unsetHover(); + tile.setRevealed(); + + if (tile._mine) { + emitter.emit('game:end', 'game:loss'); + + return; + } + + let hidden = []; + + for (const { row, col, tile } of board) { + if (!tile._revealed) { + hidden.push(tile); + } + } + + const won = hidden.every((tile) => tile._mine); + state = won ? 'game:won' : 'game:play'; + + if (won) { + emitter.emit('game:end', 'game:won'); + } + + console.log(tile); +}); +emitter.on('tap:cancel', (data) => { + const tile = board.at({ x: data.row, y: data.col }); + tile.unsetHover(); + + console.log(tile); +}); +emitter.on('game:end', (data) => { + state = data; + + gameTimer.pause(); + board.revealAll(); + emitter.clear(); + + console.log(board); +}); + +function init() { + gameTimer.start(); + + console.log(board); +} + +function draw() { + cls(COLOR.BLACK); + + gameTimer.end(); + + const flagCounterText = `${SYMBOL.FLAG}: ${flagCounter}`; + const flagCounterWidth = textmetrics(flagCounterText).width; + + const gameTimerText = `${SYMBOL.CLOCK}: ${InteractionTimer.format(gameTimer._duration)}`; + + text(0, TILE_SIZE, gameTimerText, COLOR.BEIGE); + text(WIDTH - flagCounterWidth, TILE_SIZE, flagCounterText, COLOR.BEIGE); + textalign('center'); + + switch (state) { + case 'game:loss': + text(WIDTH / 2, HEIGHT - TILE_SIZE, 'Game Lost!'); + break; + + case 'game:won': + text(WIDTH / 2, HEIGHT - TILE_SIZE, 'Game Won!'); + break; + + default: + break; + } + + for (const { row, col, tile } of board) { + const x = col * TILE_SIZE + boardOffset; + const y = row * TILE_SIZE + TILE_SIZE * 2; + + const color = + tile._adjacent === 1 ? COLOR.TEAL : + tile._adjacent === 2 ? COLOR.GREEN : + tile._adjacent === 3 ? COLOR.RED : + tile._adjacent === 4 ? COLOR.BLUE : + tile._adjacent === 5 ? COLOR.BROWN : + tile._adjacent === 6 ? COLOR.SLATE : + tile._adjacent === 7 ? COLOR.BLACK : + tile._adjacent === 8 ? COLOR.GREY : + COLOR.BEIGE; + + if (MODE === 'PLAY') { + // cell + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.GREY); + + if (tile._hover) { + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.BEIGE); + } + + if (tile._revealed) { + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.SLATE); + } + + if (tile._mine && tile._flagged && (state === 'game:loss' || state === 'game:won')) { + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.RED); + } + + // text + const message = + tile._flagged ? SYMBOL.FLAG : + tile._mine ? SYMBOL.MINE : + tile._adjacent > 0 ? tile._adjacent : + ' '; + + if (tile._revealed || tile._flagged) { + text(x + TILE_SIZE / 2, y + 2, message, color); + } + } + else { + // cell + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.GREY); + + if (tile._mine) { + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.RED); + } + + if (tile._revealed) { + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.SLATE); + } + + if (tile._flagged) { + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.BROWN); + } + + // text + text(x + TILE_SIZE / 2, y + 2, tile._adjacent, color); + } + } +} + +function tapVerify(x, y) { + const row = Math.floor((y - TILE_SIZE * 2) / TILE_SIZE); + const col = Math.floor((x - boardOffset) / TILE_SIZE); + + if (row < 0 || row >= board._layout.length || + col < 0 || col >= board._layout[0].length) { + console.log("Tap outside the board."); + + return null; + } + + return { row, col }; +} + +function tap(x, y, tapId) { + const position = tapVerify(x, y); + if (!position) { + return; + } + + const { row, col } = position; + + emitter.emit('tap:pending', { row, col }); + interactionTimer.start(); +} + +function untap(x, y, tapId) { + const position = tapVerify(x, y); + if (!position) { + return; + } + + const { row, col } = position; + + interactionTimer.end(); + + if (interactionTimer.isHold()) { + emitter.emit('tap:flag', { row, col }); + } else { + emitter.emit('tap:reveal', { row, col }); + } +} + +const pervious = new Coordinate(0, 0); + +function tapping(x, y, tapId) { + const position = tapVerify(x, y); + if (!position) { + return; + } + + const { row, col } = position; + + interactionTimer.end(); + + if (!pervious.equals(new Coordinate(row, col))) { + emitter.emit('tap:cancel', { row, col }); + pervious.set(row, col); + } +} diff --git a/games/minesweeper/index.html b/games/minesweeper/index.html new file mode 100644 index 0000000..56ab79a --- /dev/null +++ b/games/minesweeper/index.html @@ -0,0 +1,43 @@ + + + + + + + Minesweeper - Litecanvas + + + + +
+
+ +
+ +
+

Minesweeper

+ +

Rules

+

+ To win, you must uncover all safe squares without clicking on a mine. +

+

+ The game board is a grid of squares. Some squares contain hidden mines, and the rest are safe. You can click (or tap) a square to reveal what’s underneath. If the square contains a mine, the game is + over. If the square is safe, it will display a number or be blank. The number shows how many mines are + in the surrounding eight tiles. +

+ +

Controls (desktop only)

+
    +
  • Short ClickReveals a square
  • +
  • Long ClickPlaces a flag
  • +
+
+
+ + + + + + + \ No newline at end of file From 1eb85334962d12349ae2fdaf133e758dfade6546 Mon Sep 17 00:00:00 2001 From: Add00 Date: Tue, 10 Dec 2024 18:40:19 -0500 Subject: [PATCH 3/5] added sound --- games/minesweeper/game.js | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/games/minesweeper/game.js b/games/minesweeper/game.js index d87bbb9..feea102 100644 --- a/games/minesweeper/game.js +++ b/games/minesweeper/game.js @@ -24,7 +24,7 @@ const SYMBOL = { const ROWS = 10; const COLS = 10; -const DIFF = 10; +const DIFF = 1; const TILE_SIZE = 34; const MODE = 'PLAY'; // DEBUG | PLAY @@ -229,6 +229,8 @@ emitter.on('tap:pending', (data) => { return; } + // sfx([1,0,261.6256,.04,.2,.34,1,.3,0,0,0,0,.2,0,0,0,0,.83,.19,.08,0], rand(), 0.5); + tile.setHover(); }); emitter.on('tap:flag', (data) => { @@ -296,6 +298,13 @@ emitter.on('tap:cancel', (data) => { emitter.on('game:end', (data) => { state = data; + if (state === 'game:loss') { + sfx([.6,.05,80,.02,.18,.51,3,2,-6,0,0,0,0,1.3,0,.4,0,.49,.28,0,-2957]); + } + else if (state === 'game:won') { + sfx([1.5,.05,690,.03,.22,.44,0,2.2,0,0,0,0,.05,0,0,.2,.12,.83,.2,.13,0]); + } + gameTimer.pause(); board.revealAll(); emitter.clear(); @@ -304,6 +313,7 @@ emitter.on('game:end', (data) => { }); function init() { + volume(1); gameTimer.start(); console.log(board); @@ -363,8 +373,8 @@ function draw() { rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.SLATE); } - if (tile._mine && tile._flagged && (state === 'game:loss' || state === 'game:won')) { - rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.RED); + if (!tile._mine && tile._flagged && (state === 'game:loss' || state === 'game:won')) { + rectfill(x, y, TILE_SIZE - 1, TILE_SIZE - 1, COLOR.BROWN); } // text From 1b69bf4e977e8b28ef7312a7ddfb5fffd6aeda4a Mon Sep 17 00:00:00 2001 From: Add00 Date: Tue, 10 Dec 2024 18:48:17 -0500 Subject: [PATCH 4/5] Update to latest branch --- games/minesweeper/game.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/games/minesweeper/game.js b/games/minesweeper/game.js index feea102..fff5552 100644 --- a/games/minesweeper/game.js +++ b/games/minesweeper/game.js @@ -24,7 +24,7 @@ const SYMBOL = { const ROWS = 10; const COLS = 10; -const DIFF = 1; +const DIFF = 10; const TILE_SIZE = 34; const MODE = 'PLAY'; // DEBUG | PLAY From 5c7b202c6c1d34921db6098f6316bf2298234288 Mon Sep 17 00:00:00 2001 From: Add00 Date: Tue, 7 Jan 2025 09:34:18 -0500 Subject: [PATCH 5/5] replaced EventEmiter with builtin alternative --- games/minesweeper/event.js | 66 -------------------------------------- games/minesweeper/game.js | 24 +++++++------- 2 files changed, 12 insertions(+), 78 deletions(-) diff --git a/games/minesweeper/event.js b/games/minesweeper/event.js index dc373fa..f819bef 100644 --- a/games/minesweeper/event.js +++ b/games/minesweeper/event.js @@ -1,69 +1,3 @@ -class EventEmitter { - _events; - - constructor() { - this._events = new Map(); - } - - /** - * Registers a listener for the specified event. - * @param {string} event - The event name. - * @param {Function} listener - The callback function to invoke when the event is emitted. - */ - on(event, listener) { - if (!this._events.has(event)) { - this._events.set(event, []); - } - this._events.get(event).push(listener); - } - - /** - * Emits an event, invoking all registered listeners with the provided arguments. - * @param {string} event - The event name. - * @param {...any} args - The arguments to pass to the listeners. - * @returns {boolean} - Returns true if the event had listeners, false otherwise. - */ - emit(event, ...args) { - if (!this._events.has(event)) { - return false; - } - for (const listener of this._events.get(event)) { - listener(...args); - } - return true; - } - - /** - * Removes a specific listener for the specified event. - * If no listener is provided, removes all listeners for the event. - * @param {string} event - The event name. - * @param {Function} [listener] - The listener to remove. - */ - off(event, listener) { - if (!this._events.has(event)) { - return; - } - if (!listener) { - this._events.delete(event); - } else { - const listeners = this._events.get(event); - const filteredListeners = listeners.filter(l => l !== listener); - if (filteredListeners.length > 0) { - this._events.set(event, filteredListeners); - } else { - this._events.delete(event); - } - } - } - - /** - * Removes all events and their listeners. - */ - clear() { - this._events.clear(); - } -} - class EventSignal { _value; _listeners; diff --git a/games/minesweeper/game.js b/games/minesweeper/game.js index fff5552..1903cfa 100644 --- a/games/minesweeper/game.js +++ b/games/minesweeper/game.js @@ -222,7 +222,7 @@ let state = 'game:play'; let flagCounter = DIFF; const emitter = new EventEmitter(); -emitter.on('tap:pending', (data) => { +engine.listen('tap:pending', (data) => { const tile = board.at({ x: data.row, y: data.col }); if (tile._revealed || tile._hover || tile._flagged) { @@ -233,7 +233,7 @@ emitter.on('tap:pending', (data) => { tile.setHover(); }); -emitter.on('tap:flag', (data) => { +engine.listen('tap:flag', (data) => { const tile = board.at({ x: data.row, y: data.col }); if (tile._revealed) { @@ -252,7 +252,7 @@ emitter.on('tap:flag', (data) => { console.log(tile); }); -emitter.on('tap:reveal', (data) => { +engine.listen('tap:reveal', (data) => { const tile = board.at({ x: data.row, y: data.col }); if (tile._flagged) { @@ -267,7 +267,7 @@ emitter.on('tap:reveal', (data) => { tile.setRevealed(); if (tile._mine) { - emitter.emit('game:end', 'game:loss'); + engine.emit('game:end', 'game:loss'); return; } @@ -284,18 +284,18 @@ emitter.on('tap:reveal', (data) => { state = won ? 'game:won' : 'game:play'; if (won) { - emitter.emit('game:end', 'game:won'); + engine.emit('game:end', 'game:won'); } console.log(tile); }); -emitter.on('tap:cancel', (data) => { +engine.listen('tap:cancel', (data) => { const tile = board.at({ x: data.row, y: data.col }); tile.unsetHover(); console.log(tile); }); -emitter.on('game:end', (data) => { +engine.listen('game:end', (data) => { state = data; if (state === 'game:loss') { @@ -307,7 +307,7 @@ emitter.on('game:end', (data) => { gameTimer.pause(); board.revealAll(); - emitter.clear(); + unlisten(); console.log(board); }); @@ -432,7 +432,7 @@ function tap(x, y, tapId) { const { row, col } = position; - emitter.emit('tap:pending', { row, col }); + engine.emit('tap:pending', { row, col }); interactionTimer.start(); } @@ -447,9 +447,9 @@ function untap(x, y, tapId) { interactionTimer.end(); if (interactionTimer.isHold()) { - emitter.emit('tap:flag', { row, col }); + engine.emit('tap:flag', { row, col }); } else { - emitter.emit('tap:reveal', { row, col }); + engine.emit('tap:reveal', { row, col }); } } @@ -466,7 +466,7 @@ function tapping(x, y, tapId) { interactionTimer.end(); if (!pervious.equals(new Coordinate(row, col))) { - emitter.emit('tap:cancel', { row, col }); + engine.emit('tap:cancel', { row, col }); pervious.set(row, col); } }