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

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