mirror of
https://github.com/tiennm99/try-claudekit.git
synced 2026-04-17 15:21:21 +00:00
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:
26
src/constants.js
Normal file
26
src/constants.js
Normal 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
20
src/fruits.js
Normal 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
49
src/fruits.test.js
Normal 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
123
src/game.js
Normal 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
44
src/input.js
Normal 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
30
src/input.test.js
Normal 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
9
src/main.js
Normal 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
58
src/merger.js
Normal 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
35
src/merger.test.js
Normal 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
64
src/physics.js
Normal 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
79
src/physics.test.js
Normal 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
212
src/renderer.js
Normal 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
29
src/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user