mirror of
https://github.com/tiennm99/try-claudekit.git
synced 2026-04-17 13:21:43 +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:
@@ -1,4 +1,10 @@
|
|||||||
{
|
{
|
||||||
|
"permissions": {
|
||||||
|
"allow": [
|
||||||
|
"Bash(claudekit *)",
|
||||||
|
"Bash(claudekit-hooks *)"
|
||||||
|
]
|
||||||
|
},
|
||||||
"hooks": {
|
"hooks": {
|
||||||
"PreToolUse": [
|
"PreToolUse": [
|
||||||
{
|
{
|
||||||
|
|||||||
15
index.html
Normal file
15
index.html
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Suika Game</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<canvas id="game-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<script type="module" src="/src/main.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
1271
package-lock.json
generated
Normal file
1271
package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
20
package.json
Normal file
20
package.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"name": "suika-game",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "vite build",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"vite": "^8.0.4",
|
||||||
|
"vitest": "^4.1.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"matter-js": "^0.20.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
public/favicon.svg
Normal file
1
public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
294
specs/feat-suika-game.md
Normal file
294
specs/feat-suika-game.md
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
# Suika Game (Watermelon Game)
|
||||||
|
|
||||||
|
**Status**: Draft
|
||||||
|
**Authors**: Claude Code, 2026-04-12
|
||||||
|
**Type**: Feature
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
A browser-based clone of the Suika Game (Watermelon Game) -- a physics-based puzzle game where players drop fruits into a container. When two identical fruits collide, they merge into the next larger fruit in the chain. The goal is to create the largest fruit (watermelon) and achieve the highest score without overflowing the container.
|
||||||
|
|
||||||
|
## Background / Problem Statement
|
||||||
|
|
||||||
|
This is a greenfield project with no existing application code. The repository currently contains only ClaudeKit tooling configuration. The goal is to build a complete, playable Suika Game as a web application using modern frontend tooling.
|
||||||
|
|
||||||
|
## Goals
|
||||||
|
|
||||||
|
- Deliver a fully playable Suika Game in the browser
|
||||||
|
- Implement realistic 2D physics (gravity, collision, settling)
|
||||||
|
- Support the complete 11-fruit progression chain with merging
|
||||||
|
- Provide score tracking and game-over detection
|
||||||
|
- Responsive design that works on both desktop (mouse) and mobile (touch)
|
||||||
|
- Clean, maintainable JavaScript codebase
|
||||||
|
|
||||||
|
## Non-Goals
|
||||||
|
|
||||||
|
- Multiplayer or networked play
|
||||||
|
- Persistent leaderboards or backend/database integration
|
||||||
|
- Sound effects or music (can be added later)
|
||||||
|
- Animations beyond basic physics (no particle effects, screen shakes, etc.)
|
||||||
|
- User accounts or authentication
|
||||||
|
- PWA / offline support
|
||||||
|
|
||||||
|
## Technical Dependencies
|
||||||
|
|
||||||
|
| Dependency | Version | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| [Matter.js](https://brm.io/matter-js/) | ^0.20.0 | 2D rigid-body physics engine |
|
||||||
|
| [Vite](https://vitejs.dev/) | ^6.x | Build tooling and dev server |
|
||||||
|
|
||||||
|
No framework (React, etc.) is needed -- this will be a vanilla JavaScript + Canvas2D application to keep it simple and performant.
|
||||||
|
|
||||||
|
## Detailed Design
|
||||||
|
|
||||||
|
### Architecture
|
||||||
|
|
||||||
|
```
|
||||||
|
src/
|
||||||
|
main.js # Entry point: initializes game, attaches event listeners
|
||||||
|
game.js # Game class: orchestrates engine, renderer, input, state
|
||||||
|
physics.js # Matter.js engine setup, world configuration, walls
|
||||||
|
renderer.js # Canvas2D rendering: fruits, walls, UI overlay
|
||||||
|
input.js # Mouse/touch input handling, drop control
|
||||||
|
fruits.js # Fruit definitions (sizes, colors, labels, progression)
|
||||||
|
merger.js # Collision detection callback, merge logic, scoring
|
||||||
|
constants.js # Game dimensions, physics tuning, timing constants
|
||||||
|
index.html # Single HTML page with canvas element
|
||||||
|
```
|
||||||
|
|
||||||
|
### Fruit Progression Chain
|
||||||
|
|
||||||
|
11 fruits from smallest to largest. Only the 5 smallest can be randomly selected for dropping.
|
||||||
|
|
||||||
|
| Tier | Fruit | Radius (px) | Color | Merge Points |
|
||||||
|
|------|-------|-------------|-------|-------------|
|
||||||
|
| 0 | Cherry | 12 | #E74C3C | 1 |
|
||||||
|
| 1 | Strawberry | 16 | #FF6B6B | 3 |
|
||||||
|
| 2 | Grapes | 20 | #9B59B6 | 6 |
|
||||||
|
| 3 | Dekopon | 26 | #F39C12 | 10 |
|
||||||
|
| 4 | Persimmon | 32 | #E67E22 | 15 |
|
||||||
|
| 5 | Apple | 36 | #E74C3C | 21 |
|
||||||
|
| 6 | Pear | 42 | #A8D648 | 28 |
|
||||||
|
| 7 | Peach | 48 | #FDCB6E | 36 |
|
||||||
|
| 8 | Pineapple | 57 | #F1C40F | 45 |
|
||||||
|
| 9 | Melon | 64 | #2ECC71 | 55 |
|
||||||
|
| 10 | Watermelon | 77 | #27AE60 | 66 |
|
||||||
|
|
||||||
|
When two tier-10 watermelons merge, they disappear (bonus points, no new fruit spawned).
|
||||||
|
|
||||||
|
### Container / World Setup
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────┐ ← danger line (Y threshold)
|
||||||
|
│ │
|
||||||
|
│ drop zone │
|
||||||
|
│ │
|
||||||
|
├─────────────────────────┤ ← top of container walls
|
||||||
|
│ │
|
||||||
|
│ │
|
||||||
|
│ ● ● │
|
||||||
|
│ ●●● ●● │
|
||||||
|
│ ●●●●●●●●● │
|
||||||
|
└─────────────────────────┘ ← floor
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Container**: U-shaped static body (left wall, right wall, floor)
|
||||||
|
- **Dimensions**: 400px wide x 600px tall container, centered on canvas
|
||||||
|
- **Canvas size**: 500px x 700px (with padding for UI)
|
||||||
|
- **Danger line**: ~50px below the top of the container walls
|
||||||
|
|
||||||
|
### Physics Configuration (Matter.js)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const engine = Engine.create({
|
||||||
|
gravity: { x: 0, y: 1.5 },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fruit body defaults
|
||||||
|
const fruitBodyOptions = {
|
||||||
|
restitution: 0.2, // low bounce
|
||||||
|
friction: 0.5, // moderate friction
|
||||||
|
frictionAir: 0.01, // slight air drag
|
||||||
|
density: 0.001, // consistent density
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
### Core Game Loop
|
||||||
|
|
||||||
|
1. **Idle state**: Next fruit shown at top, follows cursor/touch X position
|
||||||
|
2. **Drop**: On click/tap, fruit is released with gravity enabled
|
||||||
|
3. **Cooldown**: 500ms before the next fruit can be dropped
|
||||||
|
4. **Collision check**: On every `collisionStart` event from Matter.js:
|
||||||
|
- If two bodies share the same fruit tier label and neither is flagged for removal:
|
||||||
|
- Flag both bodies for removal
|
||||||
|
- Calculate midpoint of the two bodies
|
||||||
|
- If tier < 10: spawn new fruit at midpoint with tier + 1
|
||||||
|
- If tier == 10: no spawn (watermelons vanish)
|
||||||
|
- Add merge points to score
|
||||||
|
5. **Game-over check**: Each frame, check if any settled fruit (not newly dropped within the last 1s) has its top edge above the danger line
|
||||||
|
|
||||||
|
### Merge Logic (Critical Path)
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Collision handler
|
||||||
|
Events.on(engine, "collisionStart", (event) => {
|
||||||
|
for (const pair of event.pairs) {
|
||||||
|
const { bodyA, bodyB } = pair;
|
||||||
|
if (bodyA.label === bodyB.label && !bodyA.removing && !bodyB.removing) {
|
||||||
|
bodyA.removing = true;
|
||||||
|
bodyB.removing = true;
|
||||||
|
|
||||||
|
const tier = bodyA.fruitTier;
|
||||||
|
const midX = (bodyA.position.x + bodyB.position.x) / 2;
|
||||||
|
const midY = (bodyA.position.y + bodyB.position.y) / 2;
|
||||||
|
|
||||||
|
World.remove(engine.world, [bodyA, bodyB]);
|
||||||
|
|
||||||
|
if (tier < 10) {
|
||||||
|
const newFruit = createFruitBody(tier + 1, midX, midY);
|
||||||
|
World.add(engine.world, newFruit);
|
||||||
|
}
|
||||||
|
|
||||||
|
score += FRUITS[tier + 1]?.points ?? 66;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
### Input Handling
|
||||||
|
|
||||||
|
- **Desktop**: `mousemove` on canvas for positioning, `click` to drop
|
||||||
|
- **Mobile**: `touchmove` for positioning, `touchend` to drop
|
||||||
|
- Clamp X position to stay within container walls minus fruit radius
|
||||||
|
- During cooldown, input is accepted for positioning but drop is disabled
|
||||||
|
|
||||||
|
### Rendering (Canvas2D)
|
||||||
|
|
||||||
|
Each frame via `requestAnimationFrame`:
|
||||||
|
|
||||||
|
1. Clear canvas
|
||||||
|
2. Draw container walls (gray rectangles)
|
||||||
|
3. Draw danger line (dashed red line)
|
||||||
|
4. For each fruit body in the world:
|
||||||
|
- Draw filled circle at body position with fruit color
|
||||||
|
- Draw fruit label text (emoji or name) centered
|
||||||
|
5. Draw "next fruit" preview at cursor position (semi-transparent during cooldown)
|
||||||
|
6. Draw score in top corner
|
||||||
|
7. If game over, draw overlay with final score and "Click to restart" prompt
|
||||||
|
|
||||||
|
### State Management
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Game state object
|
||||||
|
const state = {
|
||||||
|
score: 0,
|
||||||
|
nextFruitTier: 0, // 0-4 (random from droppable set)
|
||||||
|
isDropCooldown: false,
|
||||||
|
isGameOver: false,
|
||||||
|
cursorX: 0, // current drop position
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
No external state library needed. The `Game` class owns the state and passes it to the renderer each frame.
|
||||||
|
|
||||||
|
## User Experience
|
||||||
|
|
||||||
|
1. Page loads -> canvas renders with empty container and a fruit preview following cursor
|
||||||
|
2. Player clicks/taps to drop the fruit
|
||||||
|
3. Fruit falls, settles, and may merge with matching neighbors
|
||||||
|
4. Merged fruit creates satisfying chain reactions as larger fruits collide
|
||||||
|
5. Score updates in real-time at the top
|
||||||
|
6. When a fruit settles above the danger line, "Game Over" overlay appears with final score
|
||||||
|
7. Clicking "Restart" resets the game
|
||||||
|
|
||||||
|
## Testing Strategy
|
||||||
|
|
||||||
|
### Unit Tests (Vitest)
|
||||||
|
|
||||||
|
- **`fruits.test.js`**: Verify fruit definitions are consistent (radii increase monotonically, all 11 tiers defined, points are correct)
|
||||||
|
- **`merger.test.js`**: Test merge logic in isolation -- given two same-tier fruits, verify correct tier+1 output, midpoint calculation, and score delta. Test watermelon+watermelon produces no new fruit.
|
||||||
|
- **`input.test.js`**: Test X-clamping logic keeps fruit within container bounds
|
||||||
|
- **`game.test.js`**: Test state transitions: drop sets cooldown, game-over triggers on threshold breach, restart resets state
|
||||||
|
|
||||||
|
### Integration Tests (Vitest)
|
||||||
|
|
||||||
|
- **Physics + merge**: Create a Matter.js engine, drop two same-tier fruits so they collide, verify the collision handler produces the correct merged fruit
|
||||||
|
- **Game-over detection**: Simulate a stack that crosses the danger line, verify game-over flag is set
|
||||||
|
|
||||||
|
### Manual / E2E Testing
|
||||||
|
|
||||||
|
- Play through a full game in the browser to verify feel, responsiveness, and chain reactions
|
||||||
|
- Test on mobile viewport (Chrome DevTools device mode)
|
||||||
|
- Verify no fruit escapes the container walls
|
||||||
|
|
||||||
|
### Test Documentation
|
||||||
|
|
||||||
|
Each test file should include a top-level comment explaining what module it validates and why. Each test case should have a descriptive name that explains the scenario being tested (e.g., `"merging two tier-3 dekopon produces a tier-4 persimmon"`).
|
||||||
|
|
||||||
|
## Performance Considerations
|
||||||
|
|
||||||
|
- **Physics step rate**: Matter.js default (60Hz) is sufficient; no need for sub-stepping
|
||||||
|
- **Rendering**: Canvas2D with simple circle drawing is very lightweight
|
||||||
|
- **Body count**: Maximum ~40-50 fruits in container at once. Matter.js handles this easily
|
||||||
|
- **Memory**: Remove merged bodies immediately from the world to prevent leaks
|
||||||
|
- **Mobile**: Touch events have no performance concern; canvas size is small enough for any device
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
- No user input beyond mouse/touch coordinates -- no injection vectors
|
||||||
|
- No network requests, no backend, no data storage
|
||||||
|
- No third-party scripts loaded at runtime (Matter.js bundled via Vite)
|
||||||
|
- Score is client-side only -- no need to prevent tampering
|
||||||
|
|
||||||
|
## Documentation
|
||||||
|
|
||||||
|
- **README.md**: Update with project description, setup instructions (`npm install`, `npm run dev`), how to play, and screenshot
|
||||||
|
- No API docs needed (no backend)
|
||||||
|
|
||||||
|
## Implementation Phases
|
||||||
|
|
||||||
|
### Phase 1: Core Game (MVP)
|
||||||
|
|
||||||
|
1. Initialize Vite project (`npm create vite@latest . -- --template vanilla`)
|
||||||
|
2. Install Matter.js
|
||||||
|
3. Implement `constants.js` and `fruits.js` (data definitions)
|
||||||
|
4. Implement `physics.js` (engine, walls, container)
|
||||||
|
5. Implement `renderer.js` (canvas drawing for fruits, walls, score)
|
||||||
|
6. Implement `input.js` (mouse/touch positioning and drop trigger)
|
||||||
|
7. Implement `merger.js` (collision-based merge logic and scoring)
|
||||||
|
8. Implement `game.js` (game loop, state management, game-over detection)
|
||||||
|
9. Wire everything together in `main.js`
|
||||||
|
10. Verify playability in browser
|
||||||
|
|
||||||
|
### Phase 2: Polish
|
||||||
|
|
||||||
|
1. Add "next fruit" preview indicator
|
||||||
|
2. Add drop cooldown visual feedback (e.g., dimmed preview)
|
||||||
|
3. Add game-over overlay with score and restart button
|
||||||
|
4. Responsive canvas scaling for different screen sizes
|
||||||
|
5. Add fruit emoji or simple sprite rendering instead of plain circles
|
||||||
|
|
||||||
|
### Phase 3: Testing and Quality
|
||||||
|
|
||||||
|
1. Set up Vitest
|
||||||
|
2. Write unit tests for fruit definitions, merge logic, clamping
|
||||||
|
3. Write integration tests for physics + merge flow
|
||||||
|
4. Manual playtesting and tuning (gravity, restitution, fruit sizes)
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
1. **Fruit visuals**: Use colored circles with text labels (simpler) or emoji/sprites (prettier)? Recommend starting with colored circles, upgrading in Phase 2.
|
||||||
|
2. **Difficulty tuning**: The physics constants (gravity, restitution, fruit sizes) may need adjustment after playtesting. These should be easy to tweak in `constants.js`.
|
||||||
|
3. **Canvas scaling**: Fixed pixel size or scale to viewport? Recommend fixed size with CSS centering for Phase 1, responsive scaling in Phase 2.
|
||||||
|
|
||||||
|
## References
|
||||||
|
|
||||||
|
- [Suika Game - Wikipedia](https://en.wikipedia.org/wiki/Suika_Game)
|
||||||
|
- [Matter.js Documentation](https://brm.io/matter-js/docs/)
|
||||||
|
- [Matter.js GitHub](https://github.com/liabru/matter-js)
|
||||||
|
- [Vite Getting Started](https://vitejs.dev/guide/)
|
||||||
|
- [Suika Game Fruits & Points](https://suikagame.com/games/suika-game-fruits-points-list/)
|
||||||
|
- [Example clone: sgbj/suika-clone (Phaser + Matter.js + TS)](https://github.com/sgbj/suika-clone)
|
||||||
|
- [Example clone: TomboFry/suika-game (plain JS + Matter.js)](https://github.com/TomboFry/suika-game)
|
||||||
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