feat: implement Suika Game (Watermelon Game)

Browser-based physics puzzle game where players drop fruits that merge
into larger fruits on collision, using Matter.js for 2D physics and
Canvas2D for rendering. Includes 11-fruit progression chain, scoring,
game-over detection, mouse/touch input, and Vitest test suite.
This commit is contained in:
2026-04-12 10:26:38 +07:00
parent 00d6bb117b
commit fbec9c89fd
19 changed files with 2385 additions and 0 deletions

26
src/constants.js Normal file
View File

@@ -0,0 +1,26 @@
// Game dimensions
export const CANVAS_WIDTH = 500;
export const CANVAS_HEIGHT = 700;
export const CONTAINER_WIDTH = 400;
export const CONTAINER_HEIGHT = 600;
export const CONTAINER_X = (CANVAS_WIDTH - CONTAINER_WIDTH) / 2;
export const CONTAINER_Y = CANVAS_HEIGHT - CONTAINER_HEIGHT - 20;
export const WALL_THICKNESS = 10;
// Danger line: ~50px below the top of the container walls
export const DANGER_LINE_Y = CONTAINER_Y + 50;
// Physics
export const GRAVITY = { x: 0, y: 1.5 };
export const FRUIT_BODY_OPTIONS = {
restitution: 0.2,
friction: 0.5,
frictionAir: 0.01,
density: 0.001,
};
// Timing
export const DROP_COOLDOWN_MS = 500;
export const NEW_FRUIT_GRACE_MS = 1000;

20
src/fruits.js Normal file
View File

@@ -0,0 +1,20 @@
export const FRUITS = [
{ tier: 0, name: 'Cherry', radius: 12, color: '#E74C3C', points: 1 },
{ tier: 1, name: 'Strawberry', radius: 16, color: '#FF6B6B', points: 3 },
{ tier: 2, name: 'Grapes', radius: 20, color: '#9B59B6', points: 6 },
{ tier: 3, name: 'Dekopon', radius: 26, color: '#F39C12', points: 10 },
{ tier: 4, name: 'Persimmon', radius: 32, color: '#E67E22', points: 15 },
{ tier: 5, name: 'Apple', radius: 36, color: '#E74C3C', points: 21 },
{ tier: 6, name: 'Pear', radius: 42, color: '#A8D648', points: 28 },
{ tier: 7, name: 'Peach', radius: 48, color: '#FDCB6E', points: 36 },
{ tier: 8, name: 'Pineapple', radius: 57, color: '#F1C40F', points: 45 },
{ tier: 9, name: 'Melon', radius: 64, color: '#2ECC71', points: 55 },
{ tier: 10, name: 'Watermelon', radius: 77, color: '#27AE60', points: 66 },
];
// Only the 5 smallest fruits can be randomly selected for dropping
export const DROPPABLE_MAX_TIER = 4;
export function getRandomDroppableTier() {
return Math.floor(Math.random() * (DROPPABLE_MAX_TIER + 1));
}

49
src/fruits.test.js Normal file
View File

@@ -0,0 +1,49 @@
import { describe, it, expect } from 'vitest';
import { FRUITS, DROPPABLE_MAX_TIER, getRandomDroppableTier } from './fruits.js';
describe('fruits definitions', () => {
it('defines exactly 11 fruit tiers (0-10)', () => {
expect(FRUITS).toHaveLength(11);
FRUITS.forEach((fruit, i) => {
expect(fruit.tier).toBe(i);
});
});
it('has monotonically increasing radii', () => {
for (let i = 1; i < FRUITS.length; i++) {
expect(FRUITS[i].radius).toBeGreaterThan(FRUITS[i - 1].radius);
}
});
it('has monotonically increasing points', () => {
for (let i = 1; i < FRUITS.length; i++) {
expect(FRUITS[i].points).toBeGreaterThan(FRUITS[i - 1].points);
}
});
it('every fruit has required properties', () => {
for (const fruit of FRUITS) {
expect(fruit).toHaveProperty('tier');
expect(fruit).toHaveProperty('name');
expect(fruit).toHaveProperty('radius');
expect(fruit).toHaveProperty('color');
expect(fruit).toHaveProperty('points');
expect(typeof fruit.name).toBe('string');
expect(fruit.radius).toBeGreaterThan(0);
expect(fruit.color).toMatch(/^#[0-9A-Fa-f]{6}$/);
}
});
it('DROPPABLE_MAX_TIER is 4', () => {
expect(DROPPABLE_MAX_TIER).toBe(4);
});
it('getRandomDroppableTier returns tier 0-4', () => {
for (let i = 0; i < 100; i++) {
const tier = getRandomDroppableTier();
expect(tier).toBeGreaterThanOrEqual(0);
expect(tier).toBeLessThanOrEqual(DROPPABLE_MAX_TIER);
expect(Number.isInteger(tier)).toBe(true);
}
});
});

123
src/game.js Normal file
View File

@@ -0,0 +1,123 @@
import {
createEngine,
createWalls,
createFruitBody,
addToWorld,
getAllBodies,
stepEngine,
} from './physics.js';
import { setupMergeHandler } from './merger.js';
import { render } from './renderer.js';
import { clampX } from './input.js';
import { FRUITS, getRandomDroppableTier } from './fruits.js';
import {
CANVAS_WIDTH,
CONTAINER_Y,
DANGER_LINE_Y,
DROP_COOLDOWN_MS,
NEW_FRUIT_GRACE_MS,
} from './constants.js';
export class Game {
constructor(ctx) {
this.ctx = ctx;
this.engine = null;
this.mergeHandler = null;
this.state = null;
this.lastTime = 0;
this.animFrameId = null;
this.cooldownTimer = null;
this.init();
}
init() {
this.engine = createEngine();
const walls = createWalls();
addToWorld(this.engine, walls);
this.state = {
score: 0,
nextFruitTier: getRandomDroppableTier(),
isDropCooldown: false,
isGameOver: false,
cursorX: CANVAS_WIDTH / 2,
};
this.mergeHandler = setupMergeHandler(
this.engine,
() => this.state,
(points) => { this.state.score += points; }
);
}
start() {
this.lastTime = performance.now();
this.loop(this.lastTime);
}
loop(time) {
const delta = time - this.lastTime;
this.lastTime = time;
if (!this.state.isGameOver) {
stepEngine(this.engine, delta);
this.mergeHandler.flushMerges();
this.checkGameOver();
}
const bodies = getAllBodies(this.engine);
render(this.ctx, bodies, this.state);
this.animFrameId = requestAnimationFrame((t) => this.loop(t));
}
setCursorX(x) {
this.state.cursorX = clampX(x, this.state.nextFruitTier);
}
drop() {
if (this.state.isDropCooldown || this.state.isGameOver) return;
const tier = this.state.nextFruitTier;
const x = this.state.cursorX;
const y = CONTAINER_Y - 5;
const fruit = createFruitBody(tier, x, y);
addToWorld(this.engine, fruit);
this.state.nextFruitTier = getRandomDroppableTier();
this.state.isDropCooldown = true;
this.cooldownTimer = setTimeout(() => {
this.state.isDropCooldown = false;
}, DROP_COOLDOWN_MS);
}
checkGameOver() {
const now = Date.now();
const bodies = getAllBodies(this.engine);
for (const body of bodies) {
if (body.fruitTier === undefined || body.isStatic || body.removing) continue;
// Grace period for newly dropped fruits
if (now - body.dropTime < NEW_FRUIT_GRACE_MS) continue;
const fruit = FRUITS[body.fruitTier];
const topEdge = body.position.y - fruit.radius;
if (topEdge < DANGER_LINE_Y) {
this.state.isGameOver = true;
return;
}
}
}
restart() {
if (this.cooldownTimer) clearTimeout(this.cooldownTimer);
if (this.animFrameId) cancelAnimationFrame(this.animFrameId);
// createEngine() in init() creates a fresh engine object;
// old engine is abandoned and GC'd with its event listeners.
this.init();
this.start();
}
}

44
src/input.js Normal file
View File

@@ -0,0 +1,44 @@
import { FRUITS } from './fruits.js';
import { CONTAINER_X, CONTAINER_WIDTH } from './constants.js';
export function clampX(x, fruitTier) {
const radius = FRUITS[fruitTier].radius;
const minX = CONTAINER_X + radius;
const maxX = CONTAINER_X + CONTAINER_WIDTH - radius;
return Math.max(minX, Math.min(maxX, x));
}
export function setupInput(canvas, game) {
function getCanvasX(clientX) {
const rect = canvas.getBoundingClientRect();
const scaleX = canvas.width / rect.width;
return (clientX - rect.left) * scaleX;
}
canvas.addEventListener('mousemove', (e) => {
game.setCursorX(getCanvasX(e.clientX));
});
canvas.addEventListener('click', (e) => {
if (game.state.isGameOver) {
game.restart();
} else {
game.drop();
}
});
canvas.addEventListener('touchmove', (e) => {
e.preventDefault();
const touch = e.touches[0];
game.setCursorX(getCanvasX(touch.clientX));
}, { passive: false });
canvas.addEventListener('touchend', (e) => {
e.preventDefault();
if (game.state.isGameOver) {
game.restart();
} else {
game.drop();
}
}, { passive: false });
}

30
src/input.test.js Normal file
View File

@@ -0,0 +1,30 @@
import { describe, it, expect } from 'vitest';
import { clampX } from './input.js';
import { FRUITS } from './fruits.js';
import { CONTAINER_X, CONTAINER_WIDTH } from './constants.js';
describe('input clampX', () => {
it('clamps X to stay within container for tier 0 (Cherry)', () => {
const radius = FRUITS[0].radius;
const minX = CONTAINER_X + radius;
const maxX = CONTAINER_X + CONTAINER_WIDTH - radius;
expect(clampX(0, 0)).toBe(minX);
expect(clampX(1000, 0)).toBe(maxX);
expect(clampX(250, 0)).toBe(250); // center is fine
});
it('clamps differently for larger fruits', () => {
const tier4Radius = FRUITS[4].radius; // Persimmon, radius 32
const minX = CONTAINER_X + tier4Radius;
const maxX = CONTAINER_X + CONTAINER_WIDTH - tier4Radius;
expect(clampX(0, 4)).toBe(minX);
expect(clampX(1000, 4)).toBe(maxX);
});
it('does not change X when within bounds', () => {
const x = CONTAINER_X + CONTAINER_WIDTH / 2;
expect(clampX(x, 2)).toBe(x);
});
});

9
src/main.js Normal file
View File

@@ -0,0 +1,9 @@
import { createCanvas } from './renderer.js';
import { setupInput } from './input.js';
import { Game } from './game.js';
import './style.css';
const { ctx, canvas } = createCanvas();
const game = new Game(ctx);
setupInput(canvas, game);
game.start();

58
src/merger.js Normal file
View File

@@ -0,0 +1,58 @@
import Matter from 'matter-js';
import { FRUITS } from './fruits.js';
import { createFruitBody, addToWorld, removeFromWorld } from './physics.js';
const { Events } = Matter;
export function setupMergeHandler(engine, getState, addScore) {
const pendingMerges = [];
Events.on(engine, 'collisionStart', (event) => {
for (const pair of event.pairs) {
const { bodyA, bodyB } = pair;
if (
bodyA.fruitTier !== undefined &&
bodyB.fruitTier !== undefined &&
bodyA.label === bodyB.label &&
!bodyA.removing &&
!bodyB.removing
) {
bodyA.removing = true;
bodyB.removing = true;
pendingMerges.push({ bodyA, bodyB, tier: bodyA.fruitTier });
}
}
});
function flushMerges() {
for (const { bodyA, bodyB, tier } of pendingMerges) {
const midX = (bodyA.position.x + bodyB.position.x) / 2;
const midY = (bodyA.position.y + bodyB.position.y) / 2;
removeFromWorld(engine, bodyA, bodyB);
if (tier < 10) {
const newFruit = createFruitBody(tier + 1, midX, midY);
addToWorld(engine, newFruit);
}
const points = FRUITS[tier + 1]?.points ?? 66;
addScore(points);
}
pendingMerges.length = 0;
}
return { flushMerges };
}
export function computeMergePoints(tier) {
return FRUITS[tier + 1]?.points ?? 66;
}
export function computeMidpoint(bodyA, bodyB) {
return {
x: (bodyA.position.x + bodyB.position.x) / 2,
y: (bodyA.position.y + bodyB.position.y) / 2,
};
}

35
src/merger.test.js Normal file
View File

@@ -0,0 +1,35 @@
import { describe, it, expect } from 'vitest';
import { computeMergePoints, computeMidpoint } from './merger.js';
import { FRUITS } from './fruits.js';
describe('merger', () => {
describe('computeMergePoints', () => {
it('returns points of the next tier fruit', () => {
expect(computeMergePoints(0)).toBe(FRUITS[1].points); // Cherry -> Strawberry points
expect(computeMergePoints(3)).toBe(FRUITS[4].points); // Dekopon -> Persimmon points
expect(computeMergePoints(9)).toBe(FRUITS[10].points); // Melon -> Watermelon points
});
it('returns 66 when merging two watermelons (tier 10)', () => {
expect(computeMergePoints(10)).toBe(66);
});
});
describe('computeMidpoint', () => {
it('calculates the midpoint of two bodies', () => {
const bodyA = { position: { x: 100, y: 200 } };
const bodyB = { position: { x: 300, y: 400 } };
const mid = computeMidpoint(bodyA, bodyB);
expect(mid.x).toBe(200);
expect(mid.y).toBe(300);
});
it('handles same position', () => {
const bodyA = { position: { x: 150, y: 250 } };
const bodyB = { position: { x: 150, y: 250 } };
const mid = computeMidpoint(bodyA, bodyB);
expect(mid.x).toBe(150);
expect(mid.y).toBe(250);
});
});
});

64
src/physics.js Normal file
View File

@@ -0,0 +1,64 @@
import Matter from 'matter-js';
import { FRUITS } from './fruits.js';
import {
CANVAS_WIDTH,
CONTAINER_WIDTH,
CONTAINER_HEIGHT,
CONTAINER_X,
CONTAINER_Y,
WALL_THICKNESS,
GRAVITY,
FRUIT_BODY_OPTIONS,
} from './constants.js';
const { Engine, Bodies, Composite } = Matter;
export function createEngine() {
return Engine.create({ gravity: GRAVITY });
}
export function createWalls() {
const leftX = CONTAINER_X - WALL_THICKNESS / 2;
const rightX = CONTAINER_X + CONTAINER_WIDTH + WALL_THICKNESS / 2;
const wallHeight = CONTAINER_HEIGHT;
const wallY = CONTAINER_Y + CONTAINER_HEIGHT / 2;
const floorY = CONTAINER_Y + CONTAINER_HEIGHT + WALL_THICKNESS / 2;
const floorWidth = CONTAINER_WIDTH + WALL_THICKNESS * 2;
const wallOptions = { isStatic: true, friction: 0.5, render: { visible: false } };
const leftWall = Bodies.rectangle(leftX, wallY, WALL_THICKNESS, wallHeight, wallOptions);
const rightWall = Bodies.rectangle(rightX, wallY, WALL_THICKNESS, wallHeight, wallOptions);
const floor = Bodies.rectangle(CANVAS_WIDTH / 2, floorY, floorWidth, WALL_THICKNESS, wallOptions);
return [leftWall, rightWall, floor];
}
export function createFruitBody(tier, x, y) {
const fruit = FRUITS[tier];
const body = Bodies.circle(x, y, fruit.radius, {
...FRUIT_BODY_OPTIONS,
label: `fruit_${tier}`,
});
body.fruitTier = tier;
body.removing = false;
body.dropTime = Date.now();
return body;
}
export function addToWorld(engine, ...bodies) {
Composite.add(engine.world, bodies.flat());
}
export function removeFromWorld(engine, ...bodies) {
Composite.remove(engine.world, bodies.flat());
}
export function getAllBodies(engine) {
return Composite.allBodies(engine.world);
}
export function stepEngine(engine, delta) {
Engine.update(engine, delta);
}

79
src/physics.test.js Normal file
View File

@@ -0,0 +1,79 @@
import { describe, it, expect } from 'vitest';
import Matter from 'matter-js';
import {
createEngine,
createWalls,
createFruitBody,
addToWorld,
getAllBodies,
stepEngine,
} from './physics.js';
describe('physics integration', () => {
it('creates an engine with correct gravity', () => {
const engine = createEngine();
expect(engine.gravity.x).toBe(0);
expect(engine.gravity.y).toBe(1.5);
});
it('creates walls as static bodies', () => {
const walls = createWalls();
expect(walls).toHaveLength(3);
walls.forEach((wall) => {
expect(wall.isStatic).toBe(true);
});
});
it('creates fruit body with correct tier and label', () => {
const body = createFruitBody(3, 250, 100);
expect(body.fruitTier).toBe(3);
expect(body.label).toBe('fruit_3');
expect(body.removing).toBe(false);
});
it('fruit falls under gravity when engine steps', () => {
const engine = createEngine();
const walls = createWalls();
addToWorld(engine, walls);
const fruit = createFruitBody(0, 250, 100);
addToWorld(engine, fruit);
const initialY = fruit.position.y;
// Step the engine several times
for (let i = 0; i < 10; i++) {
stepEngine(engine, 1000 / 60);
}
expect(fruit.position.y).toBeGreaterThan(initialY);
});
it('same-tier fruit collision triggers merge via collision event', () => {
const engine = createEngine();
const walls = createWalls();
addToWorld(engine, walls);
// Place two same-tier fruits very close so they collide
const fruitA = createFruitBody(2, 250, 400);
const fruitB = createFruitBody(2, 252, 400);
addToWorld(engine, fruitA, fruitB);
let mergeDetected = false;
Matter.Events.on(engine, 'collisionStart', (event) => {
for (const pair of event.pairs) {
if (pair.bodyA.label === pair.bodyB.label &&
pair.bodyA.fruitTier !== undefined) {
mergeDetected = true;
}
}
});
// Step engine to trigger collision
for (let i = 0; i < 60; i++) {
stepEngine(engine, 1000 / 60);
}
expect(mergeDetected).toBe(true);
});
});

212
src/renderer.js Normal file
View File

@@ -0,0 +1,212 @@
import { FRUITS } from './fruits.js';
import {
CANVAS_WIDTH,
CANVAS_HEIGHT,
CONTAINER_WIDTH,
CONTAINER_X,
CONTAINER_Y,
CONTAINER_HEIGHT,
WALL_THICKNESS,
DANGER_LINE_Y,
} from './constants.js';
export function createCanvas() {
const canvas = document.getElementById('game-canvas');
canvas.width = CANVAS_WIDTH;
canvas.height = CANVAS_HEIGHT;
const ctx = canvas.getContext('2d');
return { canvas, ctx };
}
export function render(ctx, bodies, state) {
ctx.clearRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
drawBackground(ctx);
drawContainer(ctx);
drawDangerLine(ctx);
drawFruits(ctx, bodies);
drawNextFruitPreview(ctx, state);
drawNextFruitPanel(ctx, state);
drawScore(ctx, state.score);
if (state.isGameOver) {
drawGameOverOverlay(ctx, state.score);
}
}
function drawBackground(ctx) {
ctx.fillStyle = '#FFF8E7';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
}
function drawContainer(ctx) {
ctx.fillStyle = '#8B7355';
// Left wall
ctx.fillRect(
CONTAINER_X - WALL_THICKNESS,
CONTAINER_Y,
WALL_THICKNESS,
CONTAINER_HEIGHT + WALL_THICKNESS
);
// Right wall
ctx.fillRect(
CONTAINER_X + CONTAINER_WIDTH,
CONTAINER_Y,
WALL_THICKNESS,
CONTAINER_HEIGHT + WALL_THICKNESS
);
// Floor
ctx.fillRect(
CONTAINER_X - WALL_THICKNESS,
CONTAINER_Y + CONTAINER_HEIGHT,
CONTAINER_WIDTH + WALL_THICKNESS * 2,
WALL_THICKNESS
);
// Inner background
ctx.fillStyle = '#FFFDF5';
ctx.fillRect(CONTAINER_X, CONTAINER_Y, CONTAINER_WIDTH, CONTAINER_HEIGHT);
}
function drawDangerLine(ctx) {
ctx.save();
ctx.setLineDash([8, 6]);
ctx.strokeStyle = '#E74C3C';
ctx.lineWidth = 2;
ctx.globalAlpha = 0.6;
ctx.beginPath();
ctx.moveTo(CONTAINER_X, DANGER_LINE_Y);
ctx.lineTo(CONTAINER_X + CONTAINER_WIDTH, DANGER_LINE_Y);
ctx.stroke();
ctx.restore();
}
function drawFruits(ctx, bodies) {
for (const body of bodies) {
if (body.fruitTier === undefined || body.removing) continue;
const fruit = FRUITS[body.fruitTier];
const { x, y } = body.position;
// Circle
ctx.beginPath();
ctx.arc(x, y, fruit.radius, 0, Math.PI * 2);
ctx.fillStyle = fruit.color;
ctx.fill();
// Darker border
ctx.strokeStyle = darkenColor(fruit.color, 0.2);
ctx.lineWidth = 2;
ctx.stroke();
// Label
ctx.fillStyle = '#FFFFFF';
ctx.font = `bold ${Math.max(10, fruit.radius * 0.6)}px sans-serif`;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(fruit.name.slice(0, 2), x, y);
}
}
function drawNextFruitPreview(ctx, state) {
if (state.isGameOver) return;
const fruit = FRUITS[state.nextFruitTier];
const x = state.cursorX;
const y = CONTAINER_Y - fruit.radius - 10;
ctx.save();
ctx.globalAlpha = state.isDropCooldown ? 0.3 : 0.7;
ctx.beginPath();
ctx.arc(x, y, fruit.radius, 0, Math.PI * 2);
ctx.fillStyle = fruit.color;
ctx.fill();
ctx.strokeStyle = darkenColor(fruit.color, 0.2);
ctx.lineWidth = 2;
ctx.stroke();
// Drop guide line
if (!state.isDropCooldown) {
ctx.setLineDash([4, 4]);
ctx.strokeStyle = 'rgba(0, 0, 0, 0.15)';
ctx.lineWidth = 1;
ctx.beginPath();
ctx.moveTo(x, y + fruit.radius);
ctx.lineTo(x, CONTAINER_Y + CONTAINER_HEIGHT);
ctx.stroke();
}
ctx.restore();
}
function drawNextFruitPanel(ctx, state) {
if (state.isGameOver) return;
const panelX = CONTAINER_X + CONTAINER_WIDTH + WALL_THICKNESS + 10;
const panelY = CONTAINER_Y;
const panelSize = 60;
ctx.fillStyle = '#FFFDF5';
ctx.strokeStyle = '#8B7355';
ctx.lineWidth = 2;
ctx.beginPath();
ctx.roundRect(panelX, panelY, panelSize, panelSize + 20, 6);
ctx.fill();
ctx.stroke();
ctx.fillStyle = '#8B7355';
ctx.font = 'bold 11px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText('NEXT', panelX + panelSize / 2, panelY + 4);
const fruit = FRUITS[state.nextFruitTier];
const previewRadius = Math.min(fruit.radius, 22);
const cx = panelX + panelSize / 2;
const cy = panelY + 46;
ctx.beginPath();
ctx.arc(cx, cy, previewRadius, 0, Math.PI * 2);
ctx.fillStyle = fruit.color;
ctx.fill();
ctx.strokeStyle = darkenColor(fruit.color, 0.2);
ctx.lineWidth = 2;
ctx.stroke();
}
function drawScore(ctx, score) {
ctx.fillStyle = '#2C3E50';
ctx.font = 'bold 28px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'top';
ctx.fillText(`Score: ${score}`, CANVAS_WIDTH / 2, 10);
}
function drawGameOverOverlay(ctx, score) {
ctx.save();
ctx.fillStyle = 'rgba(0, 0, 0, 0.6)';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
ctx.fillStyle = '#FFFFFF';
ctx.font = 'bold 48px sans-serif';
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText('Game Over', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 - 40);
ctx.font = 'bold 32px sans-serif';
ctx.fillText(`Score: ${score}`, CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 20);
ctx.font = '20px sans-serif';
ctx.fillStyle = '#CCCCCC';
ctx.fillText('Click to Restart', CANVAS_WIDTH / 2, CANVAS_HEIGHT / 2 + 70);
ctx.restore();
}
function darkenColor(hex, amount) {
const num = parseInt(hex.slice(1), 16);
const r = Math.max(0, ((num >> 16) & 0xff) * (1 - amount)) | 0;
const g = Math.max(0, ((num >> 8) & 0xff) * (1 - amount)) | 0;
const b = Math.max(0, (num & 0xff) * (1 - amount)) | 0;
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
}

29
src/style.css Normal file
View File

@@ -0,0 +1,29 @@
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
display: flex;
justify-content: center;
align-items: center;
min-height: 100vh;
background: #2C3E50;
font-family: sans-serif;
overflow: hidden;
}
#app {
display: flex;
justify-content: center;
align-items: center;
}
#game-canvas {
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
max-width: 100vw;
max-height: 100vh;
touch-action: none;
}