fix: physics tunneling and update fruit stats to community standard

Fix fruits falling through the floor by replacing single-step physics
with fixed-timestep sub-stepping (16.67ms steps, max 5 per frame).
Add escaped-body cleanup as safety net. Tune physics constants for
better feel (higher density, more bounce, less float).

Update fruit radii to match the community-standard ratios (TomboFry/
moonfloof clones) scaled to our 400px container. Update colors to
match real fruit appearances. Fix "Grapes" → "Grape" per wiki.
This commit is contained in:
2026-04-12 11:19:04 +07:00
parent 6e1aaff557
commit b91b29753f
4 changed files with 74 additions and 28 deletions

View File

@@ -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) (24192px) 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.

View File

@@ -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;

View File

@@ -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

View File

@@ -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);