diff --git a/docs/gameplay.md b/docs/gameplay.md index b3bd94d..c83679e 100644 --- a/docs/gameplay.md +++ b/docs/gameplay.md @@ -7,20 +7,22 @@ ## Fruit Chain -Cherry → Strawberry → Grapes → Dekopon → Persimmon → Apple → Pear → Peach → Pineapple → Melon → Watermelon +Cherry → Strawberry → Grape → Dekopon → Persimmon → Apple → Pear → Peach → Pineapple → Melon → Watermelon | Tier | Fruit | Radius | Color | Merge Points | |------|-------|--------|-------|-------------| | 0 | Cherry | 12px | Red | 1 | -| 1 | Strawberry | 16px | Light Red | 3 | -| 2 | Grapes | 20px | Purple | 6 | -| 3 | Dekopon | 26px | Orange | 10 | -| 4 | Persimmon | 32px | Dark Orange | 15 | -| 5 | Apple | 36px | Red | 21 | -| 6 | Pear | 42px | Green-Yellow | 28 | -| 7 | Peach | 48px | Peach | 36 | -| 8 | Pineapple | 57px | Yellow | 45 | -| 9 | Melon | 64px | Green | 55 | -| 10 | Watermelon | 77px | Dark Green | 66 | +| 1 | Strawberry | 17px | Pink-Red | 3 | +| 2 | Grape | 21px | Purple | 6 | +| 3 | Dekopon | 29px | Orange | 10 | +| 4 | Persimmon | 33px | Orange-Red | 15 | +| 5 | Apple | 37px | Red | 21 | +| 6 | Pear | 44px | Yellow-Green | 28 | +| 7 | Peach | 50px | Peach | 36 | +| 8 | Pineapple | 67px | Yellow | 45 | +| 9 | Melon | 83px | Green | 55 | +| 10 | Watermelon | 100px | Dark Green | 66 | + +Radii are scaled from the [community-standard values](https://github.com/TomboFry/suika-game) (24–192px) to fit the 400px container. Points match the Nintendo Switch scoring. Only the 5 smallest fruits (Cherry through Persimmon) appear as drop candidates. When two Watermelons merge, they disappear and award 66 bonus points. diff --git a/src/constants.js b/src/constants.js index e929791..073dc0c 100644 --- a/src/constants.js +++ b/src/constants.js @@ -15,12 +15,17 @@ 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, + restitution: 0.3, + friction: 0.3, + frictionAir: 0.02, + frictionStatic: 0.5, + density: 0.003, }; +// Fixed physics step: run sub-steps at this interval to prevent tunneling +export const PHYSICS_STEP_MS = 1000 / 60; // ~16.67ms +export const MAX_SUB_STEPS = 5; // cap sub-steps to avoid spiral of death + // Timing export const DROP_COOLDOWN_MS = 500; export const NEW_FRUIT_GRACE_MS = 1000; diff --git a/src/fruits.js b/src/fruits.js index c55e6ca..b67e9b8 100644 --- a/src/fruits.js +++ b/src/fruits.js @@ -1,15 +1,18 @@ +// Community-standard fruit stats (TomboFry/moonfloof clones, Suika Game Wiki). +// Radii scaled from the standard (24..192) by 0.52 to fit our 400px container. +// Points match the Nintendo Switch scoring: 1, 3, 6, 10, 15, 21, 28, 36, 45, 55, 66. 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 }, + { tier: 0, name: 'Cherry', radius: 12, color: '#E8373B', points: 1 }, + { tier: 1, name: 'Strawberry', radius: 17, color: '#FF6B6B', points: 3 }, + { tier: 2, name: 'Grape', radius: 21, color: '#7B52AB', points: 6 }, + { tier: 3, name: 'Dekopon', radius: 29, color: '#FF8C00', points: 10 }, + { tier: 4, name: 'Persimmon', radius: 33, color: '#E2611C', points: 15 }, + { tier: 5, name: 'Apple', radius: 37, color: '#C0392B', points: 21 }, + { tier: 6, name: 'Pear', radius: 44, color: '#C8D44E', points: 28 }, + { tier: 7, name: 'Peach', radius: 50, color: '#FFBF8C', points: 36 }, + { tier: 8, name: 'Pineapple', radius: 67, color: '#F5C518', points: 45 }, + { tier: 9, name: 'Melon', radius: 83, color: '#90C040', points: 55 }, + { tier: 10, name: 'Watermelon', radius: 100, color: '#3A7D44', points: 66 }, ]; // Only the 5 smallest fruits can be randomly selected for dropping diff --git a/src/game.js b/src/game.js index aa3a313..c79786d 100644 --- a/src/game.js +++ b/src/game.js @@ -3,6 +3,7 @@ import { createWalls, createFruitBody, addToWorld, + removeFromWorld, getAllBodies, stepEngine, } from './physics.js'; @@ -12,10 +13,15 @@ import { clampX } from './input.js'; import { FRUITS, getRandomDroppableTier } from './fruits.js'; import { CANVAS_WIDTH, + CONTAINER_X, + CONTAINER_WIDTH, CONTAINER_Y, + CONTAINER_HEIGHT, DANGER_LINE_Y, DROP_COOLDOWN_MS, NEW_FRUIT_GRACE_MS, + PHYSICS_STEP_MS, + MAX_SUB_STEPS, } from './constants.js'; export class Game { @@ -25,6 +31,7 @@ export class Game { this.mergeHandler = null; this.state = null; this.lastTime = 0; + this.accumulator = 0; this.animFrameId = null; this.cooldownTimer = null; this.init(); @@ -60,8 +67,21 @@ export class Game { this.lastTime = time; if (!this.state.isGameOver) { - stepEngine(this.engine, delta); - this.mergeHandler.flushMerges(); + // Fixed-step sub-stepping: accumulate time and run physics in + // consistent small steps to prevent tunneling through walls/floor. + this.accumulator += delta; + const maxAccumulated = PHYSICS_STEP_MS * MAX_SUB_STEPS; + if (this.accumulator > maxAccumulated) { + this.accumulator = maxAccumulated; + } + + while (this.accumulator >= PHYSICS_STEP_MS) { + stepEngine(this.engine, PHYSICS_STEP_MS); + this.mergeHandler.flushMerges(); + this.accumulator -= PHYSICS_STEP_MS; + } + + this.removeEscapedBodies(); this.checkGameOver(); } @@ -93,6 +113,22 @@ export class Game { }, DROP_COOLDOWN_MS); } + removeEscapedBodies() { + const bodies = getAllBodies(this.engine); + const margin = 100; + const minX = CONTAINER_X - margin; + const maxX = CONTAINER_X + CONTAINER_WIDTH + margin; + const maxY = CONTAINER_Y + CONTAINER_HEIGHT + margin; + + for (const body of bodies) { + if (body.fruitTier === undefined || body.isStatic) continue; + const { x, y } = body.position; + if (x < minX || x > maxX || y > maxY) { + removeFromWorld(this.engine, body); + } + } + } + checkGameOver() { const now = Date.now(); const bodies = getAllBodies(this.engine);