|
|
@@ -17,6 +17,7 @@ import {
|
|
|
CANDLE_KNOCKBACK,
|
|
|
CANDLE_STUN_S,
|
|
|
CANDLE_THROW_SPEED,
|
|
|
+ CAKE_CHECK_INTERVAL_S,
|
|
|
CHERRY_DROP_SPEED,
|
|
|
CHERRY_POINTS_PER_S,
|
|
|
CHERRY_RADIUS,
|
|
|
@@ -42,7 +43,9 @@ import {
|
|
|
JUMP_BUFFER_TIME,
|
|
|
JUMP_CUT,
|
|
|
MATERIAL_SOAKED,
|
|
|
+ MATERIAL_PLATE,
|
|
|
MATERIAL_WOOL,
|
|
|
+ MIN_CAKE_PIXELS,
|
|
|
MIN_CHUNK_PIXELS,
|
|
|
OUT_OF_MAP_MARGIN,
|
|
|
PIXELS_PER_METER,
|
|
|
@@ -53,11 +56,20 @@ import {
|
|
|
RESPAWN_DELAY_S,
|
|
|
SMARTIES_DURATION_S,
|
|
|
SMARTIES_SPEED_MULT,
|
|
|
+ SOAKED_JUMP_MULT,
|
|
|
+ SOAKED_S,
|
|
|
+ SOAKED_SPEED_MULT,
|
|
|
SOAK_SAG_INTERVAL_S,
|
|
|
SOAK_SAG_POWER,
|
|
|
SOAK_SAG_RADIUS,
|
|
|
SOAK_SAG_TIME_S,
|
|
|
+ SPOON_APEX_PX,
|
|
|
SPOON_LAUNCH_SPEED,
|
|
|
+ SPOON_LAUNCH_TRIES,
|
|
|
+ SPOON_MAX_VX,
|
|
|
+ SPOON_TARGET_TRIES,
|
|
|
+ STUN_GUARD_KNOCKBACK,
|
|
|
+ STUN_GUARD_S,
|
|
|
TICK_DT,
|
|
|
WHISK_CRACK_POWER,
|
|
|
WHISK_CRACK_RADIUS,
|
|
|
@@ -89,8 +101,12 @@ const JELLY = 4;
|
|
|
const CHERRY_HOLD_OFFSET = 16;
|
|
|
/** Columns scanned for a free spoon launch (px between candidates). */
|
|
|
const SPOON_COLUMN_STEP = 32;
|
|
|
-/** Cap on the spoon's sideways throw (px/s). */
|
|
|
-const SPOON_MAX_VX = 500;
|
|
|
+/** Cap on the spoon's sideways throw when it throws blind (px/s). */
|
|
|
+const SPOON_BLIND_MAX_VX = 500;
|
|
|
+/** Columns scanned for landing spots (px between candidates). */
|
|
|
+const SPOON_SPOT_STEP = 12;
|
|
|
+/** A landing spot is flat enough when the ground under the body stays within this height (px). */
|
|
|
+const SPOON_SPOT_FLATNESS = 10;
|
|
|
/** Free space required on each side of the body along the launch column (px). */
|
|
|
const SPOON_CLEARANCE = 8;
|
|
|
|
|
|
@@ -110,6 +126,8 @@ interface ServerPlayer {
|
|
|
attackCooldown: number;
|
|
|
specialCooldown: number;
|
|
|
stun: number;
|
|
|
+ /** Seconds before a new stun can land (a stun, then STUN_GUARD_S of immunity). */
|
|
|
+ stunLock: number;
|
|
|
/** Seconds before the spoon sends the player back; 0 = alive. */
|
|
|
dead: number;
|
|
|
/** Smarties boost time left. */
|
|
|
@@ -119,6 +137,10 @@ interface ServerPlayer {
|
|
|
/** Toxic cloud: seconds of poisoning left, and time to the next cough. */
|
|
|
poison: number;
|
|
|
cough: number;
|
|
|
+ /** Drenched by Bébé Pompier: seconds left. */
|
|
|
+ soaked: number;
|
|
|
+ /** Raw buttons of the previous tick (the suicide key works even when stunned). */
|
|
|
+ prevRaw: number;
|
|
|
item: ItemKind | null;
|
|
|
/** Seconds holding the cherry this round. */
|
|
|
score: number;
|
|
|
@@ -244,6 +266,10 @@ export class Game {
|
|
|
private clouds: { x: number; y: number; r: number; life: number; maxLife: number; owner: number }[] = [];
|
|
|
/** Recently soaked areas, which sag for a while (Bébé Pompier). */
|
|
|
private soakZones: { x: number; y: number; r: number; time: number; next: number }[] = [];
|
|
|
+ /** Seconds before the next count of the cake left. */
|
|
|
+ private cakeCheck = CAKE_CHECK_INTERVAL_S;
|
|
|
+ /** Set when so little cake is left that the round ends early. */
|
|
|
+ private cakeGone = false;
|
|
|
|
|
|
constructor(opts: GameOptions) {
|
|
|
this.map = opts.map;
|
|
|
@@ -285,7 +311,12 @@ export class Game {
|
|
|
|
|
|
/** True once the round timer has run out. */
|
|
|
get roundOver(): boolean {
|
|
|
- return this.rules !== null && this.timeLeft <= 0;
|
|
|
+ return this.rules !== null && (this.timeLeft <= 0 || this.cakeGone);
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Why the round is over: the timer, or there is (almost) no cake left to stand on. */
|
|
|
+ get endReason(): 'time' | 'noCake' {
|
|
|
+ return this.cakeGone ? 'noCake' : 'time';
|
|
|
}
|
|
|
|
|
|
get custardLevel(): number {
|
|
|
@@ -314,7 +345,11 @@ export class Game {
|
|
|
const stats = this.loadCharacter(characterId);
|
|
|
this.nextPlayerId = Math.max(this.nextPlayerId, id + 1);
|
|
|
const body = this.world.createBody({ type: 'dynamic', fixedRotation: true, bullet: true });
|
|
|
- const info: PlayerInfo = { id, name, characterId, radius: stats.size };
|
|
|
+ // Players on the same character get different colors: the lowest variant not taken yet.
|
|
|
+ const taken = new Set([...this.players.values()].filter((o) => o.info.characterId === characterId).map((o) => o.info.variant));
|
|
|
+ let variant = 0;
|
|
|
+ while (taken.has(variant)) variant++;
|
|
|
+ const info: PlayerInfo = { id, name, characterId, radius: stats.size, variant };
|
|
|
const player: ServerPlayer = {
|
|
|
info,
|
|
|
character: createCharacter(stats),
|
|
|
@@ -330,11 +365,14 @@ export class Game {
|
|
|
attackCooldown: 0,
|
|
|
specialCooldown: 0,
|
|
|
stun: 0,
|
|
|
+ stunLock: 0,
|
|
|
dead: 0,
|
|
|
boost: 0,
|
|
|
flight: 0,
|
|
|
poison: 0,
|
|
|
cough: 0,
|
|
|
+ soaked: 0,
|
|
|
+ prevRaw: 0,
|
|
|
item: null,
|
|
|
score: 0,
|
|
|
};
|
|
|
@@ -454,7 +492,8 @@ export class Game {
|
|
|
(p.stun > 0 ? Anim.Stunned : 0) |
|
|
|
(p.dead > 0 ? Anim.Dead : 0) |
|
|
|
(p.boost > 0 ? Anim.Boosted : 0) |
|
|
|
- (p.poison > 0 ? Anim.Poisoned : 0),
|
|
|
+ (p.poison > 0 ? Anim.Poisoned : 0) |
|
|
|
+ (p.soaked > 0 ? Anim.Soaked : 0),
|
|
|
score: Math.floor(p.score),
|
|
|
item: p.item,
|
|
|
};
|
|
|
@@ -640,7 +679,18 @@ export class Game {
|
|
|
return;
|
|
|
}
|
|
|
const { stats } = p.character;
|
|
|
+ // Giving up when stuck: works even when stunned or busy.
|
|
|
+ const rawPressed = p.input.buttons & ~p.prevRaw;
|
|
|
+ p.prevRaw = p.input.buttons;
|
|
|
+ if (rawPressed & Button.Suicide) {
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ this.fx.push({ kind: 'splash', id: p.info.id, x: pos.x * PPM, y: pos.y * PPM });
|
|
|
+ this.kill(p, 'custard');
|
|
|
+ return;
|
|
|
+ }
|
|
|
p.stun = Math.max(0, p.stun - TICK_DT);
|
|
|
+ p.stunLock = Math.max(0, p.stunLock - TICK_DT);
|
|
|
+ p.soaked = Math.max(0, p.soaked - TICK_DT);
|
|
|
p.boost = Math.max(0, p.boost - TICK_DT);
|
|
|
p.flight = Math.max(0, p.flight - TICK_DT);
|
|
|
p.poison = Math.max(0, p.poison - TICK_DT);
|
|
|
@@ -655,7 +705,7 @@ export class Game {
|
|
|
let vx = vel.x * PPM;
|
|
|
let vy = vel.y * PPM;
|
|
|
const mobility = p.character.mobility();
|
|
|
- const speedMult = (p.boost > 0 ? SMARTIES_SPEED_MULT : 1) * (p.poison > 0 ? POISON_SPEED_MULT : 1);
|
|
|
+ const speedMult = (p.boost > 0 ? SMARTIES_SPEED_MULT : 1) * (p.poison > 0 ? POISON_SPEED_MULT : 1) * (p.soaked > 0 ? SOAKED_SPEED_MULT : 1);
|
|
|
|
|
|
if (!locked) {
|
|
|
const dir = (isDown(input, Button.Right) ? 1 : 0) - (isDown(input, Button.Left) ? 1 : 0);
|
|
|
@@ -667,7 +717,7 @@ export class Game {
|
|
|
|
|
|
p.jumpBuffer = pressed & Button.Jump ? JUMP_BUFFER_TIME : Math.max(0, p.jumpBuffer - TICK_DT);
|
|
|
if (p.jumpBuffer > 0 && p.sinceGrounded <= COYOTE_TIME) {
|
|
|
- vy = -stats.jump * Math.sqrt(mobility);
|
|
|
+ vy = -stats.jump * Math.sqrt(mobility) * (p.soaked > 0 ? SOAKED_JUMP_MULT : 1);
|
|
|
p.jumpBuffer = 0;
|
|
|
p.sinceGrounded = Infinity;
|
|
|
} else if (released & Button.Jump && vy < 0) {
|
|
|
@@ -732,6 +782,9 @@ export class Game {
|
|
|
this.stats.add(p.info.id, cause);
|
|
|
p.dead = RESPAWN_DELAY_S;
|
|
|
p.stun = 0;
|
|
|
+ p.stunLock = 0;
|
|
|
+ p.soaked = 0;
|
|
|
+ p.poison = 0;
|
|
|
p.item = null;
|
|
|
p.body.setLinearVelocity({ x: 0, y: 0 });
|
|
|
p.body.setActive(false);
|
|
|
@@ -745,14 +798,103 @@ export class Game {
|
|
|
}
|
|
|
|
|
|
/**
|
|
|
- * The spoon catapult: from the custard surface, in a column of open air (so the player
|
|
|
- * does not launch into the cake), as close as possible to a random spawn point.
|
|
|
+ * The spoon catapult: from the custard surface, it throws the player on an arc that lands on
|
|
|
+ * a ledge of the cake (so nobody spends the round falling back in). Without any reachable
|
|
|
+ * ledge, it throws blind from a column of open air, toward the closest spawn point.
|
|
|
*/
|
|
|
private spoonLaunch(p: ServerPlayer): void {
|
|
|
p.dead = 0;
|
|
|
p.body.setActive(true);
|
|
|
+ const r = p.bodyRadius;
|
|
|
+ const surface = Math.min(this.custardY, this.terrain.height) - r - 4;
|
|
|
+ const spots = this.landingSpots(r, surface);
|
|
|
+ // Shuffle (seeded): players come back in different places.
|
|
|
+ for (let i = spots.length - 1; i > 0; i--) {
|
|
|
+ const j = this.rng.int(0, i);
|
|
|
+ [spots[i], spots[j]] = [spots[j], spots[i]];
|
|
|
+ }
|
|
|
+ for (const [tx, ty] of spots.slice(0, SPOON_TARGET_TRIES)) {
|
|
|
+ const columns: number[] = [];
|
|
|
+ for (let x = SPOON_COLUMN_STEP; x < this.terrain.width - SPOON_COLUMN_STEP; x += SPOON_COLUMN_STEP) columns.push(x);
|
|
|
+ columns.sort((a, b) => Math.abs(a - tx) - Math.abs(b - tx));
|
|
|
+ let tried = 0;
|
|
|
+ for (const bx of columns) {
|
|
|
+ if (tried >= SPOON_LAUNCH_TRIES) break;
|
|
|
+ if (!this.discIsFree(bx, surface, r)) continue;
|
|
|
+ tried++;
|
|
|
+ const arc = this.spoonArc(bx, surface, tx, ty, r);
|
|
|
+ if (!arc) continue;
|
|
|
+ this.throwFromSpoon(p, bx, surface, arc.vx, arc.vy, arc.time);
|
|
|
+ return;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ this.blindSpoonLaunch(p, surface);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Where a body of radius `r` can land: the top of the cake seen from the sky, where the ground
|
|
|
+ * is flat enough under the whole body and above the custard. Returns body centers.
|
|
|
+ */
|
|
|
+ private landingSpots(r: number, surface: number): [number, number][] {
|
|
|
+ const spots: [number, number][] = [];
|
|
|
+ const top = (x: number): number => {
|
|
|
+ for (let y = 0; y < surface; y++) if (this.terrain.isSolid(x, y)) return y;
|
|
|
+ return Infinity;
|
|
|
+ };
|
|
|
+ for (let x = Math.ceil(r) + 2; x < this.terrain.width - r - 2; x += SPOON_SPOT_STEP) {
|
|
|
+ const y = top(x);
|
|
|
+ if (y === Infinity || y > surface - r) continue;
|
|
|
+ const left = top(Math.round(x - r * 0.7));
|
|
|
+ const right = top(Math.round(x + r * 0.7));
|
|
|
+ if (Math.abs(left - y) > SPOON_SPOT_FLATNESS || Math.abs(right - y) > SPOON_SPOT_FLATNESS) continue;
|
|
|
+ spots.push([x, Math.min(y, left, right) - r - 3]);
|
|
|
+ }
|
|
|
+ return spots;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * A ballistic arc from (bx, by) peaking SPOON_APEX_PX above the higher end and coming down on
|
|
|
+ * (tx, ty), checked against the terrain. Null if it is too far or hits the cake on the way.
|
|
|
+ */
|
|
|
+ private spoonArc(bx: number, by: number, tx: number, ty: number, r: number): { vx: number; vy: number; time: number } | null {
|
|
|
+ const g = GRAVITY_PX;
|
|
|
+ const apex = Math.min(by, ty) - SPOON_APEX_PX;
|
|
|
+ const vy0 = Math.sqrt(2 * g * (by - apex));
|
|
|
+ const disc = vy0 * vy0 - 2 * g * (by - ty);
|
|
|
+ if (disc < 0) return null;
|
|
|
+ const time = (vy0 + Math.sqrt(disc)) / g;
|
|
|
+ const vx = (tx - bx) / time;
|
|
|
+ if (Math.abs(vx) > SPOON_MAX_VX) return null;
|
|
|
+ // The last few px of the descent end on the ledge: check with a slightly smaller body.
|
|
|
+ for (let t = 0; t < time; t += TICK_DT) {
|
|
|
+ const x = bx + vx * t;
|
|
|
+ const y = by - vy0 * t + (g * t * t) / 2;
|
|
|
+ if (!this.discIsFree(x, y, r - 1)) return null;
|
|
|
+ }
|
|
|
+ return { vx, vy: -vy0, time };
|
|
|
+ }
|
|
|
+
|
|
|
+ /** True when no terrain pixel lies on the disc's outline or center (sampled). */
|
|
|
+ private discIsFree(x: number, y: number, r: number): boolean {
|
|
|
+ if (this.terrain.isSolid(Math.round(x), Math.round(y))) return false;
|
|
|
+ for (let k = 0; k < 12; k++) {
|
|
|
+ const a = (k / 12) * Math.PI * 2;
|
|
|
+ if (this.terrain.isSolid(Math.round(x + Math.cos(a) * r), Math.round(y + Math.sin(a) * r))) return false;
|
|
|
+ }
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ private throwFromSpoon(p: ServerPlayer, x: number, y: number, vx: number, vy: number, flight: number): void {
|
|
|
+ p.body.setPosition({ x: x / PPM, y: y / PPM });
|
|
|
+ p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
|
|
|
+ p.body.setAwake(true);
|
|
|
+ p.flight = flight;
|
|
|
+ this.fx.push({ kind: 'spoon', id: p.info.id, x, y: this.custardY });
|
|
|
+ }
|
|
|
+
|
|
|
+ /** No reachable ledge: a column of open air as close as possible to a random spawn point. */
|
|
|
+ private blindSpoonLaunch(p: ServerPlayer, surface: number): void {
|
|
|
const [sx, sy] = this.map.spawns[this.rng.int(0, this.map.spawns.length - 1)];
|
|
|
- const surface = Math.min(this.custardY, this.terrain.height) - p.bodyRadius - 4;
|
|
|
const top = Math.max(0, Math.min(sy, surface - 200));
|
|
|
let bestX = -1;
|
|
|
for (let x = SPOON_COLUMN_STEP; x < this.terrain.width - SPOON_COLUMN_STEP; x += SPOON_COLUMN_STEP) {
|
|
|
@@ -763,17 +905,12 @@ export class Game {
|
|
|
this.placeAtSpawn(p);
|
|
|
return;
|
|
|
}
|
|
|
- p.body.setPosition({ x: bestX / PPM, y: surface / PPM });
|
|
|
- // Aim the arc at the spawn closest to the launch column (the random one may be across the map,
|
|
|
- // out of reach): flight time ≈ 2·v/g.
|
|
|
+ // Aim the arc at the spawn closest to the launch column: flight time ≈ 2·v/g.
|
|
|
let tx = sx;
|
|
|
for (const [x] of this.map.spawns) if (Math.abs(x - bestX) < Math.abs(tx - bestX)) tx = x;
|
|
|
const flight = (2 * SPOON_LAUNCH_SPEED) / GRAVITY_PX;
|
|
|
- const vx = Math.max(-SPOON_MAX_VX, Math.min(SPOON_MAX_VX, (tx - bestX) / flight));
|
|
|
- p.body.setLinearVelocity({ x: vx / PPM, y: -SPOON_LAUNCH_SPEED / PPM });
|
|
|
- p.body.setAwake(true);
|
|
|
- p.flight = flight;
|
|
|
- this.fx.push({ kind: 'spoon', id: p.info.id, x: bestX, y: this.custardY });
|
|
|
+ const vx = Math.max(-SPOON_BLIND_MAX_VX, Math.min(SPOON_BLIND_MAX_VX, (tx - bestX) / flight));
|
|
|
+ this.throwFromSpoon(p, bestX, surface, vx, -SPOON_LAUNCH_SPEED, flight);
|
|
|
}
|
|
|
|
|
|
private columnIsFree(x: number, radius: number, top: number, bottom: number): boolean {
|
|
|
@@ -800,9 +937,10 @@ export class Game {
|
|
|
const nx = dist > 0 ? dx / dist : (attacker?.facing ?? 1);
|
|
|
const ny = Math.min(dist > 0 ? dy / dist : 0, -0.4);
|
|
|
const len = Math.hypot(nx, ny);
|
|
|
- const kb = knockback * target.character.knockbackTaken();
|
|
|
+ const guarded = target.stunLock > 0 && target.stun === 0;
|
|
|
+ const kb = knockback * target.character.knockbackTaken() * (guarded ? STUN_GUARD_KNOCKBACK : 1);
|
|
|
target.body.setLinearVelocity({ x: ((nx / len) * kb) / PPM, y: ((ny / len) * kb) / PPM });
|
|
|
- target.stun = Math.max(target.stun, stun);
|
|
|
+ this.stunPlayer(target, stun);
|
|
|
target.character.onHit(this.contextFor(target, target.input));
|
|
|
// A hit holder lets the cherry go (readme §4).
|
|
|
if (this.cherry?.holder === target.info.id) this.dropCherry(target);
|
|
|
@@ -813,6 +951,29 @@ export class Game {
|
|
|
return count;
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Stuns a player, unless they were stunned a moment ago: a stun is followed by STUN_GUARD_S
|
|
|
+ * of immunity, and hits during a stun do not extend it, so nobody can be stun-locked.
|
|
|
+ */
|
|
|
+ private stunPlayer(p: ServerPlayer, seconds: number): void {
|
|
|
+ if (seconds <= 0 || p.stunLock > 0) return;
|
|
|
+ p.stun = seconds;
|
|
|
+ p.stunLock = seconds + STUN_GUARD_S;
|
|
|
+ }
|
|
|
+
|
|
|
+ /** Bébé Pompier's spray: drenched players run and jump less for a while. */
|
|
|
+ private drench(by: ServerPlayer, x: number, y: number, r: number): number {
|
|
|
+ let count = 0;
|
|
|
+ for (const t of this.alivePlayers()) {
|
|
|
+ if (t === by) continue;
|
|
|
+ const pos = t.body.getPosition();
|
|
|
+ if (Math.hypot(pos.x * PPM - x, pos.y * PPM - y) > r + t.bodyRadius) continue;
|
|
|
+ t.soaked = SOAKED_S;
|
|
|
+ count++;
|
|
|
+ }
|
|
|
+ return count;
|
|
|
+ }
|
|
|
+
|
|
|
private contextFor(p: ServerPlayer, input: InputState): CharacterContext {
|
|
|
const pos = p.body.getPosition();
|
|
|
const vel = p.body.getLinearVelocity();
|
|
|
@@ -834,6 +995,7 @@ export class Game {
|
|
|
isSolid: (x, y) => this.terrain.isSolid(Math.round(x), Math.round(y)),
|
|
|
standing: this.standing(p),
|
|
|
shove: (x, y, r, vx, vy, fraction) => this.shove(p, x, y, r, vx, vy, fraction),
|
|
|
+ drench: (x, y, r) => this.drench(p, x, y, r),
|
|
|
cloud: (x, y, r, life) => this.clouds.push({ x, y, r, life, maxLife: life, owner: p.info.id }),
|
|
|
swapWithRandomPlayer: () => this.swapWithRandom(p),
|
|
|
cue: (cue) => {
|
|
|
@@ -913,7 +1075,10 @@ export class Game {
|
|
|
this.clouds = this.clouds.filter((c) => c.life > 0);
|
|
|
}
|
|
|
|
|
|
- /** Soaked sponge slowly sags: a few soaked pixels in recently soaked areas crumble (readme §4). */
|
|
|
+ /**
|
|
|
+ * Soaked sponge sags: a few soaked pixels in recently soaked areas crumble (readme §4), and
|
|
|
+ * it gives way under whoever stands on it, a trap Bébé Pompier sets for the others.
|
|
|
+ */
|
|
|
private updateSoakZones(): void {
|
|
|
if (this.soakZones.length === 0) return;
|
|
|
const { width, height, data } = this.terrain;
|
|
|
@@ -922,6 +1087,15 @@ export class Game {
|
|
|
z.next -= TICK_DT;
|
|
|
if (z.next > 0) continue;
|
|
|
z.next = SOAK_SAG_INTERVAL_S;
|
|
|
+ for (const p of this.alivePlayers()) {
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ const fx = Math.round(pos.x * PPM);
|
|
|
+ const fy = Math.round(pos.y * PPM + p.bodyRadius + 2);
|
|
|
+ if (Math.hypot(fx - z.x, fy - z.y) > z.r || !p.grounded) continue;
|
|
|
+ if (fx >= 0 && fy >= 0 && fx < width && fy < height && data[fy * width + fx] === MATERIAL_SOAKED) {
|
|
|
+ this.queueOp({ kind: 'carve', x: fx, y: fy, r: SOAK_SAG_RADIUS + 2, power: SOAK_SAG_POWER });
|
|
|
+ }
|
|
|
+ }
|
|
|
// Pick a soaked pixel near the bottom of the zone, where the weight rests.
|
|
|
for (let tries = 0; tries < 12; tries++) {
|
|
|
const x = Math.round(z.x + this.rng.range(-z.r, z.r));
|
|
|
@@ -953,11 +1127,29 @@ export class Game {
|
|
|
if (this.cherry && this.cherry.holder === 0) push(this.cherry.body);
|
|
|
}
|
|
|
this.timeLeft -= TICK_DT;
|
|
|
+ this.cakeCheck -= TICK_DT;
|
|
|
+ if (this.cakeCheck <= 0) {
|
|
|
+ this.cakeCheck = CAKE_CHECK_INTERVAL_S;
|
|
|
+ if (this.cakeLeft() < MIN_CAKE_PIXELS) this.cakeGone = true;
|
|
|
+ }
|
|
|
const c = this.map.custard;
|
|
|
const speed = this.timeLeft <= FINAL_RUSH_S ? c.finalRushSpeed : c.speed;
|
|
|
this.custardY = Math.max(0, this.custardY - speed * TICK_DT);
|
|
|
}
|
|
|
|
|
|
+ /** Solid pixels above the custard, plate excluded: what is left to stand on and fight over. */
|
|
|
+ private cakeLeft(): number {
|
|
|
+ const { width, data } = this.terrain;
|
|
|
+ const PLATE = MATERIAL_PLATE;
|
|
|
+ const end = Math.max(0, Math.min(this.terrain.height, Math.floor(this.custardY))) * width;
|
|
|
+ let n = 0;
|
|
|
+ for (let i = 0; i < end; i++) {
|
|
|
+ const m = data[i];
|
|
|
+ if (m !== 0 && m !== PLATE) n++;
|
|
|
+ }
|
|
|
+ return n;
|
|
|
+ }
|
|
|
+
|
|
|
private spawnCherry(): void {
|
|
|
const [x, y] = this.map.cherrySpawn;
|
|
|
const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true });
|
|
|
@@ -1189,7 +1381,7 @@ export class Game {
|
|
|
if (other === p) continue;
|
|
|
const v = other.body.getLinearVelocity();
|
|
|
other.body.setLinearVelocity({ x: v.x + this.rng.range(-0.5, 0.5) * (WHISK_JOLT / PPM), y: v.y - WHISK_JOLT / PPM });
|
|
|
- other.stun = Math.max(other.stun, WHISK_STUN_S);
|
|
|
+ this.stunPlayer(other, WHISK_STUN_S);
|
|
|
if (this.cherry?.holder === other.info.id) this.dropCherry(other);
|
|
|
}
|
|
|
const { width, height, data } = this.terrain;
|