Split the monolith into ES modules; hoist scattered magic numbers
game.js (830 lines) -> 9 modules, native ES modules so still zero build step: dom canvas handles (imports nothing; keeps cycles harmless) config TUNE (live-tunable) + structural constants state all mutable game state (G) + setupPoint/sendBoy audio sfx synthesis, recorded umpire voice, reverb, speech fallback input keyboard + touch, exports axes and takeFlickAim() gameplay ballistics, swings, serve, rules, AI, update loop render camera, viewport, sprites, crowd, all drawing panel T tuning UI main boot, loop, intro, window.T test hooks Deduped into named constants: bounce tempo (was in 3 places), contact height band, intercept zone depth, home positions, player bounds, toss params, shot depths, pose hold time, sprite scale, actor heights. Also extracted drawShadow/drawBall/expirePose from repeated code. One intentional import cycle (input <-> gameplay), safe: both sides only call functions at runtime, never during module evaluation. Regression-verified in browser sims: point cycle, CPU returns 11/12, assist contract 8/8, corner-winner gradient intact (0/12 perfect corners returned, 10/12 center balls returned). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
f2bf21f252
commit
c506a631c2
12 files changed
+936
-824
No files matched your search
+354
@@ -0,0 +1,354 @@
|
||||
// Game logic: ballistics, swings, serves, rules/scoring, AI, and the per-frame update.
|
||||
|
||||
import { TUNE, COURT, CONTACT, ZONE_DEPTH, HOME, P_BOUNDS, TOSS, SHOT_DEPTH, POSE_TIME, BOUNCE_IDLE } from './config.js';
|
||||
import { P, O, ball, score, crowd, G, other, deuceSide, setupPoint, sendBoy } from './state.js';
|
||||
import { audio, pock, swish, applause, voice } from './audio.js';
|
||||
import { axisX, axisZ, takeFlickAim } from './input.js';
|
||||
|
||||
// ------------------------------------------------------------- ballistics ----
|
||||
// Launch ball from (x0,y0,z0) so it lands at (tx, tz) with horizontal speed s under gravity g.
|
||||
export function solveShot(x0, y0, z0, tx, tz, s, g = TUNE.gravity) {
|
||||
const dx = tx - x0, dz = tz - z0, dist = Math.hypot(dx, dz), T = dist / s;
|
||||
return { vx: dx / T, vz: dz / T, vy: (0.08 - y0 + 0.5 * g * T * T) / T };
|
||||
}
|
||||
|
||||
export function predictLanding() { // first-bounce point, closed form — exact, so the ring never jitters
|
||||
const g = ball.g || TUNE.gravity;
|
||||
const disc = ball.vy * ball.vy + 2 * g * ball.y;
|
||||
if (disc < 0) return null;
|
||||
const t = (ball.vy + Math.sqrt(disc)) / g; // time until y = 0
|
||||
return { x: ball.x + ball.vx * t, z: ball.z + ball.vz * t };
|
||||
}
|
||||
|
||||
function doHit(ch, quality, aim) {
|
||||
// aim: {ax: -1|0|1, az: -1|0|1} from held keys / flick at contact (az -1 = deep, 1 = short)
|
||||
const toFar = ch.side === 'P';
|
||||
// snap the ball to the racket at the contact frame so the hit visually connects
|
||||
ball.x = ch.x + ch.facing * 0.5;
|
||||
ball.z = ch.z + (toFar ? -0.3 : 0.3);
|
||||
const err = () => (Math.random() * 2 - 1) * TUNE.aimError * (1 - quality);
|
||||
let tx = aim.ax * (toFar ? 3.3 : 2.7) + err(); // your aimed shots go properly wide
|
||||
const tzBase = aim.az < 0 ? SHOT_DEPTH.deep : aim.az > 0 ? SHOT_DEPTH.short : SHOT_DEPTH.mid;
|
||||
const tz = (toFar ? tzBase : COURT.L - tzBase) + err() * 0.8;
|
||||
tx = Math.max(-5, Math.min(5, tx));
|
||||
let s = TUNE.baseShotSpeed + quality * TUNE.qualityShotSpeed;
|
||||
// topspin: quality shots dive under extra effective gravity, which is what lets real
|
||||
// pace arc over the net and still land in — the core tennis trade-off
|
||||
const g = TUNE.gravity * (1 + quality * TUNE.spinFactor);
|
||||
let v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
|
||||
// low contact → trade pace for loft until the net is cleared; good timing gets a
|
||||
// comfortable margin, sloppy timing keeps some tape risk
|
||||
const margin = 0.06 + 0.22 * quality;
|
||||
for (let i = 0; i < 8; i++) {
|
||||
const tN = (COURT.netZ - ball.z) / v.vz;
|
||||
const yN = ball.y + v.vy * tN - 0.5 * g * tN * tN;
|
||||
if (tN <= 0 || yN >= COURT.netH + margin) break;
|
||||
s *= 0.9; v = solveShot(ball.x, ball.y, ball.z, tx, tz, s, g);
|
||||
}
|
||||
ball.g = g;
|
||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||
ball.lastHit = ch.side; ball.bounces = 0; ball.firstLand = null;
|
||||
if (ch.side === 'P') { // shot marker: where this will land — red means it's going out
|
||||
const ok = Math.abs(tx) <= COURT.W / 2 + 0.02 && tz >= -0.02 && tz <= COURT.netZ;
|
||||
G.outMarker = { x: tx, z: tz, out: !ok, t: 1.3 };
|
||||
G.aiReactT = 0; // CPU hasn't read the shot yet
|
||||
}
|
||||
pock(0.9, 950);
|
||||
if (quality > 0.999) { G.hitstop = 0.05; G.flash = { text: 'PERFECT', t: 0.7 }; pock(0.5, 1600); }
|
||||
ch.pose = 'swing'; ch.poseT = 0;
|
||||
}
|
||||
|
||||
// ----------------------------------------------------------------- serve ----
|
||||
function tossBall(sv) {
|
||||
ball.active = true; ball.bounces = 0; ball.firstLand = null; ball.collected = false;
|
||||
ball.x = sv.x + 0.35 * (sv.side === 'P' ? 1 : -1); ball.z = sv.z; ball.y = TOSS.y0;
|
||||
ball.vx = 0; ball.vz = 0; ball.vy = TOSS.vy; ball.g = TUNE.gravity;
|
||||
G.phase = 'toss';
|
||||
}
|
||||
export function serveApexY() { return TOSS.y0 + TOSS.vy * TOSS.vy / (2 * TUNE.gravity); } // ~2.95
|
||||
|
||||
function hitServe(sv, quality) {
|
||||
const toFar = sv.side === 'P';
|
||||
// target: diagonal service box. Serving from deuce side → target box is across.
|
||||
const boxX = (deuceSide() ? -1 : 1) * (toFar ? 1 : -1) * 2.0 + (Math.random() * 2 - 1) * TUNE.aimError * 0.8 * (1 - quality);
|
||||
const boxZ = toFar ? COURT.svcFar + 1.6 : COURT.svcNear - 1.6;
|
||||
const s = TUNE.serveSpeed + quality * TUNE.serveQualitySpeed;
|
||||
const tzs = boxZ + (Math.random() * 2 - 1) * (1 - quality) * 1.5;
|
||||
const g = TUNE.gravity * (1 + quality * 0.35); // kick serves dip a little too
|
||||
const v = solveShot(ball.x, ball.y, ball.z, boxX, tzs, s, g);
|
||||
ball.g = g;
|
||||
ball.vx = v.vx; ball.vy = v.vy; ball.vz = v.vz;
|
||||
ball.lastHit = sv.side; ball.bounces = 0;
|
||||
if (sv.side === 'P') G.outMarker = { x: boxX, z: tzs, out: !inServeBox(boxX, tzs), t: 1.3 };
|
||||
G.phase = 'rally'; pock(1, 1100);
|
||||
sv.pose = 'serve'; sv.poseT = 0;
|
||||
if (quality > 0.999) { G.flash = { text: 'PERFECT', t: 0.7 }; G.hitstop = 0.05; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- scoring ----
|
||||
function scoreKey() {
|
||||
const N = ['love', 'fifteen', 'thirty', 'forty'];
|
||||
const s = score.server === 'P' ? [score.pts[0], score.pts[1]] : [score.pts[1], score.pts[0]];
|
||||
if (s[0] >= 3 && s[1] >= 3) {
|
||||
if (s[0] === s[1]) return 'deuce';
|
||||
return (s[0] > s[1]) === (score.server === 'P') ? 'advantage_player' : 'advantage_receiver';
|
||||
}
|
||||
if (s[0] === s[1]) return N[s[0]] + '_all';
|
||||
return N[s[0]] + '_' + N[s[1]];
|
||||
}
|
||||
|
||||
function cheer(seconds, vol) {
|
||||
applause(seconds, vol);
|
||||
crowd.cheer = Math.max(crowd.cheer, seconds);
|
||||
}
|
||||
|
||||
function pointTo(side, reason) {
|
||||
const i = side === 'P' ? 0 : 1, j = 1 - i;
|
||||
score.pts[i]++;
|
||||
let gameOver = false;
|
||||
if (score.pts[i] >= 4 && score.pts[i] - score.pts[j] >= 2) {
|
||||
score.games[i]++; score.pts = [0, 0]; gameOver = true;
|
||||
}
|
||||
G.phase = 'over'; G.overT = gameOver ? 3.2 : 2.2;
|
||||
sendBoy();
|
||||
if (reason) voice(reason);
|
||||
if (gameOver) {
|
||||
voice(side === 'P' ? 'game_player' : 'game_opponent', 0.7);
|
||||
cheer(2.8, 0.6);
|
||||
score.server = other(score.server);
|
||||
} else {
|
||||
voice(scoreKey(), 0.7);
|
||||
if (side === 'P') cheer(1.2, 0.3);
|
||||
}
|
||||
G.serveNum = 1;
|
||||
}
|
||||
|
||||
function fault() {
|
||||
if (G.serveNum === 1) { G.serveNum = 2; voice('fault'); G.phase = 'over'; G.overT = 1.4; sendBoy(); }
|
||||
else pointTo(other(score.server), 'double_fault');
|
||||
}
|
||||
|
||||
export function inCourt(x, z) { return Math.abs(x) <= COURT.W / 2 + 0.02 && z >= -0.02 && z <= COURT.L + 0.02; }
|
||||
export function inServeBox(x, z) {
|
||||
const toFar = score.server === 'P';
|
||||
const xOK = deuceSide() ? (toFar ? x <= 0.02 : x >= -0.02) : (toFar ? x >= -0.02 : x <= 0.02);
|
||||
const zOK = toFar ? (z >= COURT.svcFar - 0.02 && z <= COURT.netZ) : (z >= COURT.netZ && z <= COURT.svcNear + 0.02);
|
||||
return xOK && Math.abs(x) <= COURT.W / 2 + 0.02 && zOK;
|
||||
}
|
||||
|
||||
function onBounce() {
|
||||
ball.bounces++;
|
||||
pock(0.35, 500);
|
||||
if (ball.bounces === 1) {
|
||||
ball.firstLand = { x: ball.x, z: ball.z };
|
||||
const wasServe = G.wasServe; G.wasServe = false;
|
||||
if (wasServe) {
|
||||
if (!inServeBox(ball.x, ball.z)) { ball.active = false; fault(); }
|
||||
return;
|
||||
}
|
||||
// must land on the side opposite the hitter
|
||||
const okSide = ball.lastHit === 'P' ? ball.z < COURT.netZ : ball.z > COURT.netZ;
|
||||
if (!inCourt(ball.x, ball.z) || !okSide) {
|
||||
ball.active = false; pointTo(other(ball.lastHit), 'out');
|
||||
}
|
||||
} else if (ball.bounces === 2) {
|
||||
ball.active = false; pointTo(ball.lastHit, null);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------- movement ----
|
||||
// Where should a player run to? Simulate the ball forward and take the first point in
|
||||
// their half at hitting height that they can actually REACH IN TIME; otherwise chase the
|
||||
// deepest hittable point. This is the "every ball is catchable, timing is the skill" contract.
|
||||
function intercept(side) {
|
||||
const ch = side === 'O' ? O : P;
|
||||
const spd = side === 'O' ? TUNE.aiSpeed : TUNE.playerSpeed * TUNE.autoAssist;
|
||||
let { x, y, z, vx, vy, vz } = ball;
|
||||
const g = ball.g || TUNE.gravity, dt = 0.01;
|
||||
const inZone = side === 'O' ? zz => zz <= ZONE_DEPTH : zz => zz >= COURT.L - ZONE_DEPTH;
|
||||
const home = HOME[side];
|
||||
let fallback = null, deepest = null;
|
||||
for (let i = 0; i < 300; i++) {
|
||||
vy -= g * dt; x += vx * dt; y += vy * dt; z += vz * dt;
|
||||
if (y <= 0 && vy < 0) { y = 0; vy = -vy * TUNE.bounceY; vx *= TUNE.bounceXZ; vz *= TUNE.bounceXZ; }
|
||||
if (i > 5 && inZone(z) && y < 1.6 && y > 0.3) {
|
||||
const pt = { x, z: side === 'O' ? Math.max(0.8, z) : Math.min(24.5, z) };
|
||||
const reachable = Math.hypot(pt.x - ch.x, pt.z - ch.z) / spd <= i * dt;
|
||||
if (y > 0.7 && reachable) return pt; // waist-high AND arrivable in time
|
||||
if (reachable && !fallback) fallback = pt; // low but arrivable
|
||||
deepest = pt; // last resort: run at the deepest hittable point
|
||||
}
|
||||
}
|
||||
return fallback || deepest || home;
|
||||
}
|
||||
|
||||
function trySwing(ch, isAI) {
|
||||
// Contact happens if the ball is inside reach while the swing window is open.
|
||||
const reach = isAI ? TUNE.aiReach : TUNE.reach;
|
||||
const t = ch.swingT;
|
||||
if (t < TUNE.swingWindup || t > TUNE.swingWindup + TUNE.swingWindow) return;
|
||||
if (!ball.active || G.phase !== 'rally') return;
|
||||
if (ball.lastHit === ch.side) return;
|
||||
const dx = ball.x - ch.x, dz = ball.z - ch.z;
|
||||
if (Math.hypot(dx, dz * 0.8) > reach || ball.y > CONTACT.maxY) return;
|
||||
if (ball.y < CONTACT.minY) return; // below the ankles the ball is past you — no grass-scraping saves
|
||||
// quality: how centered in the window the contact lands
|
||||
const center = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||||
const off = Math.abs(t - center) / (TUNE.swingWindow / 2);
|
||||
let q = Math.max(0.05, 1 - off);
|
||||
if (Math.abs(t - center) * 1000 < TUNE.perfectMs) q = 1;
|
||||
ch.facing = dx >= 0 ? 1 : -1; // forehand right (righty), backhand left → mirrored sprite
|
||||
const flick = isAI ? null : takeFlickAim();
|
||||
const aim = isAI
|
||||
? { ax: [ -1, 0, 1 ][Math.floor(Math.random() * 3)], az: Math.random() < 0.25 ? -1 : 0 }
|
||||
: flick || { ax: axisX(), az: axisZ() * (ch.side === 'P' ? 1 : -1) };
|
||||
if (isAI) q = Math.min(q, TUNE.aiQualityMin + Math.random() * (TUNE.aiQualityMax - TUNE.aiQualityMin));
|
||||
doHit(ch, q, aim);
|
||||
ch.swingT = -1;
|
||||
}
|
||||
|
||||
export function keyPressed(code) {
|
||||
if (code !== 'Space') return;
|
||||
audio(); // unlock on first gesture
|
||||
if (G.phase === 'pretoss' && score.server === 'P') { tossBall(P); return; }
|
||||
if (G.phase === 'toss' && score.server === 'P') {
|
||||
// serve timing: distance from apex = quality
|
||||
const dy = Math.abs(ball.y - serveApexY());
|
||||
if (ball.y < 2.0) return; // too low, let it drop (re-toss)
|
||||
let q = Math.max(0.05, 1 - dy / 0.9);
|
||||
if (dy < 0.12) q = 1;
|
||||
G.wasServe = true;
|
||||
hitServe(P, q);
|
||||
return;
|
||||
}
|
||||
if (G.phase === 'rally' && P.swingT < 0) { P.swingT = 0; P.pose = 'swing'; P.poseT = 0; }
|
||||
}
|
||||
|
||||
function expirePose(ch, dt) {
|
||||
if (ch.pose === 'swing' || ch.pose === 'serve') { ch.poseT += dt; if (ch.poseT > POSE_TIME) ch.pose = 'idle'; }
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------- update ----
|
||||
export function update(dt) {
|
||||
if (G.hitstop > 0) { G.hitstop -= dt; return; }
|
||||
if (G.flash) { G.flash.t -= dt; if (G.flash.t <= 0) G.flash = null; }
|
||||
crowd.cheer = Math.max(0, crowd.cheer - dt);
|
||||
G.idleT += dt;
|
||||
const w = BOUNCE_IDLE.tempo;
|
||||
if (G.phase === 'pretoss' && Math.sin(G.idleT * w) * Math.sin((G.idleT - dt) * w) < 0) pock(0.12, 420); // ball bounce
|
||||
if (G.phase === 'toss') { const sv = score.server === 'P' ? P : O; sv.pose = 'serve'; sv.poseT = 0; }
|
||||
|
||||
// --- player movement (near half only, generous margins)
|
||||
if (G.phase === 'rally') {
|
||||
const slow = P.swingT >= 0 ? 0.35 : 1;
|
||||
const mx = axisX(), mz = axisZ();
|
||||
P.x += mx * TUNE.playerSpeed * slow * dt;
|
||||
P.z += mz * TUNE.playerSpeed * slow * dt;
|
||||
let assist = 0;
|
||||
if (!mx && !mz && TUNE.autoAssist > 0 && ball.active) {
|
||||
const chasing = ball.lastHit === 'O';
|
||||
// no `slow` here: tapping must not freeze your legs, or anticipating a shot loses it
|
||||
const rate = TUNE.playerSpeed * TUNE.autoAssist * dt * (chasing ? 1 : 0.55);
|
||||
const tgt = chasing ? intercept('P') : HOME.P; // run to the ball, then recover home
|
||||
const dx = tgt.x - P.x, dz = tgt.z - P.z, d = Math.hypot(dx, dz);
|
||||
if (d > 0.35) { // deadzone: stand ready near the spot instead of dithering over it
|
||||
P.x += dx / d * Math.min(rate, d);
|
||||
P.z += dz / d * Math.min(rate, d);
|
||||
assist = dx;
|
||||
}
|
||||
}
|
||||
P.x = Math.max(-P_BOUNDS.x, Math.min(P_BOUNDS.x, P.x));
|
||||
P.z = Math.max(P_BOUNDS.zMin, Math.min(P_BOUNDS.zMax, P.z));
|
||||
const moving = mx || mz || assist !== 0;
|
||||
P.runT += moving ? dt : 0;
|
||||
if (P.pose !== 'swing' && P.pose !== 'serve') P.pose = moving ? 'run' : 'idle';
|
||||
if (mx) P.facing = mx > 0 ? 1 : -1;
|
||||
else if (Math.abs(assist) > 0.25) P.facing = assist > 0 ? 1 : -1; // hysteresis: small corrections don't flip facing
|
||||
}
|
||||
if (P.swingT >= 0) {
|
||||
P.swingT += dt; trySwing(P, false); // a contact resets swingT to -1 inside trySwing
|
||||
if (P.swingT > TUNE.swingWindup + TUNE.swingWindow + 0.02) { P.swingT = -1; if (G.phase === 'rally') swish(); }
|
||||
}
|
||||
expirePose(P, dt);
|
||||
|
||||
// --- AI
|
||||
if (score.server === 'O' && G.phase === 'pretoss') {
|
||||
G.aiServeT -= dt;
|
||||
if (G.aiServeT <= 0) tossBall(O);
|
||||
} else if (score.server === 'O' && G.phase === 'toss') {
|
||||
if (ball.vy < 0 && ball.y < serveApexY() - 0.1) { G.wasServe = true; hitServe(O, 0.6 + Math.random() * 0.35); }
|
||||
}
|
||||
if (G.phase === 'rally') {
|
||||
G.aiReactT += dt;
|
||||
const incoming = ball.active && ball.lastHit === 'P' && G.aiReactT >= TUNE.aiReaction;
|
||||
const tgt = incoming ? intercept('O') : HOME.O;
|
||||
const spd = TUNE.aiSpeed * (incoming ? 1 : 0.5); // slow recovery: wrong-footing wins points
|
||||
const dx = tgt.x - O.x, dz = tgt.z - O.z, d = Math.hypot(dx, dz);
|
||||
if (d > 0.1) {
|
||||
O.x += dx / d * Math.min(spd * dt, d);
|
||||
O.z += dz / d * Math.min(spd * dt, d);
|
||||
O.pose = O.pose === 'swing' ? 'swing' : 'run'; O.runT += dt;
|
||||
if (Math.abs(dx) > 0.25) O.facing = dx > 0 ? 1 : -1;
|
||||
} else if (O.pose === 'run') O.pose = 'idle';
|
||||
G.aiSwingCool -= dt;
|
||||
if (incoming && G.aiSwingCool <= 0 && O.swingT < 0) {
|
||||
// same radius trySwing uses for contact, so an armed swing actually connects
|
||||
const near = Math.hypot(ball.x - O.x, (ball.z - O.z) * 0.8) < TUNE.aiReach
|
||||
&& ball.y < CONTACT.maxY && ball.y > CONTACT.minY && ball.vz < 0;
|
||||
if (near) {
|
||||
if (Math.random() < TUNE.aiFlub) G.aiSwingCool = 0.5; // whiff: hesitates and misses
|
||||
else {
|
||||
O.swingT = TUNE.swingWindup + TUNE.swingWindow / 2;
|
||||
trySwing(O, true); O.swingT = -1;
|
||||
G.aiSwingCool = ball.lastHit === 'O' ? 0.4 : 0.12;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
expirePose(O, dt);
|
||||
|
||||
// --- ball physics
|
||||
if (ball.active) {
|
||||
ball.vy -= (ball.g || TUNE.gravity) * dt;
|
||||
const oldZ = ball.z;
|
||||
ball.x += ball.vx * dt; ball.y += ball.vy * dt; ball.z += ball.vz * dt;
|
||||
// net: plane at netZ, only during rally flight
|
||||
if (G.phase === 'rally' && (oldZ - COURT.netZ) * (ball.z - COURT.netZ) < 0 && Math.abs(ball.x) < COURT.netW / 2) {
|
||||
const tCross = (COURT.netZ - oldZ) / (ball.z - oldZ);
|
||||
const yAt = ball.y - ball.vy * dt * (1 - tCross); // approx height at crossing
|
||||
if (yAt < COURT.netH) {
|
||||
ball.z = COURT.netZ + (oldZ < COURT.netZ ? -0.05 : 0.05);
|
||||
ball.vz *= -0.08; ball.vx *= 0.3; ball.vy = Math.min(ball.vy, 0);
|
||||
pock(0.4, 300);
|
||||
}
|
||||
}
|
||||
if (ball.y <= 0 && ball.vy < 0) {
|
||||
ball.y = 0; ball.vy = -ball.vy * TUNE.bounceY;
|
||||
ball.vx *= TUNE.bounceXZ; ball.vz *= TUNE.bounceXZ;
|
||||
onBounce();
|
||||
if (ball.active && Math.abs(ball.vy) < 0.8) { // rolled out — dead ball
|
||||
ball.active = false;
|
||||
if (ball.bounces === 1) pointTo(ball.lastHit, null);
|
||||
}
|
||||
}
|
||||
if (G.phase === 'toss' && ball.y <= 1.4 && ball.vy < 0) { // uncaught toss → retoss
|
||||
ball.active = false; G.phase = 'pretoss';
|
||||
if (score.server === 'O') G.aiServeT = 0.8;
|
||||
}
|
||||
}
|
||||
|
||||
// ball boy: sprint across, scoop the ball in passing, despawn off the far side
|
||||
const boy = G.boy;
|
||||
if (boy) {
|
||||
boy.x += boy.dir * 7.5 * dt; boy.runT += dt;
|
||||
if (!ball.collected && !ball.active) {
|
||||
const dz = ball.z - boy.z;
|
||||
boy.z += Math.sign(dz) * Math.min(Math.abs(dz), 3 * dt); // track the rolling ball a little
|
||||
if (Math.abs(boy.x - ball.x) < 0.4 && Math.abs(dz) < 1.2) ball.collected = true;
|
||||
}
|
||||
if (Math.abs(boy.x) > 13.5) G.boy = null;
|
||||
}
|
||||
if (G.outMarker && (G.outMarker.t -= dt) <= 0) G.outMarker = null;
|
||||
|
||||
if (G.phase === 'over') { G.overT -= dt; if (G.overT <= 0) setupPoint(); }
|
||||
}
|
||||
Reference in new issue
Block a user