|
@@ -26,6 +26,8 @@ import {
|
|
|
CHUNK_REST_ANGULAR,
|
|
CHUNK_REST_ANGULAR,
|
|
|
CHUNK_REST_SPEED,
|
|
CHUNK_REST_SPEED,
|
|
|
CHUNK_REST_TIME,
|
|
CHUNK_REST_TIME,
|
|
|
|
|
+ COUGH_INTERVAL_S,
|
|
|
|
|
+ COUGH_STUMBLE,
|
|
|
COYOTE_TIME,
|
|
COYOTE_TIME,
|
|
|
CRUSH_IMPULSE,
|
|
CRUSH_IMPULSE,
|
|
|
CRUSH_MIN_SPEED,
|
|
CRUSH_MIN_SPEED,
|
|
@@ -37,15 +39,22 @@ import {
|
|
|
ITEM_RADIUS,
|
|
ITEM_RADIUS,
|
|
|
JUMP_BUFFER_TIME,
|
|
JUMP_BUFFER_TIME,
|
|
|
JUMP_CUT,
|
|
JUMP_CUT,
|
|
|
|
|
+ MATERIAL_SOAKED,
|
|
|
MATERIAL_WOOL,
|
|
MATERIAL_WOOL,
|
|
|
MIN_CHUNK_PIXELS,
|
|
MIN_CHUNK_PIXELS,
|
|
|
OUT_OF_MAP_MARGIN,
|
|
OUT_OF_MAP_MARGIN,
|
|
|
PIXELS_PER_METER,
|
|
PIXELS_PER_METER,
|
|
|
|
|
+ POISON_S,
|
|
|
|
|
+ POISON_SPEED_MULT,
|
|
|
PROJECTILE_ARM_S,
|
|
PROJECTILE_ARM_S,
|
|
|
RESIZE_THRESHOLD_PX,
|
|
RESIZE_THRESHOLD_PX,
|
|
|
RESPAWN_DELAY_S,
|
|
RESPAWN_DELAY_S,
|
|
|
SMARTIES_DURATION_S,
|
|
SMARTIES_DURATION_S,
|
|
|
SMARTIES_SPEED_MULT,
|
|
SMARTIES_SPEED_MULT,
|
|
|
|
|
+ SOAK_SAG_INTERVAL_S,
|
|
|
|
|
+ SOAK_SAG_POWER,
|
|
|
|
|
+ SOAK_SAG_RADIUS,
|
|
|
|
|
+ SOAK_SAG_TIME_S,
|
|
|
SPOON_LAUNCH_SPEED,
|
|
SPOON_LAUNCH_SPEED,
|
|
|
TICK_DT,
|
|
TICK_DT,
|
|
|
WHISK_CRACK_POWER,
|
|
WHISK_CRACK_POWER,
|
|
@@ -105,6 +114,9 @@ interface ServerPlayer {
|
|
|
boost: number;
|
|
boost: number;
|
|
|
/** Spoon flight time left: horizontal momentum is kept unless the player steers. */
|
|
/** Spoon flight time left: horizontal momentum is kept unless the player steers. */
|
|
|
flight: number;
|
|
flight: number;
|
|
|
|
|
+ /** Toxic cloud: seconds of poisoning left, and time to the next cough. */
|
|
|
|
|
+ poison: number;
|
|
|
|
|
+ cough: number;
|
|
|
item: ItemKind | null;
|
|
item: ItemKind | null;
|
|
|
/** Seconds holding the cherry this round. */
|
|
/** Seconds holding the cherry this round. */
|
|
|
score: number;
|
|
score: number;
|
|
@@ -227,6 +239,9 @@ export class Game {
|
|
|
private readonly modifierCtx: ModifierContext;
|
|
private readonly modifierCtx: ModifierContext;
|
|
|
/** Side wind this tick (px/s²). */
|
|
/** Side wind this tick (px/s²). */
|
|
|
private wind = 0;
|
|
private wind = 0;
|
|
|
|
|
+ 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 }[] = [];
|
|
|
|
|
|
|
|
constructor(opts: GameOptions) {
|
|
constructor(opts: GameOptions) {
|
|
|
this.map = opts.map;
|
|
this.map = opts.map;
|
|
@@ -316,6 +331,8 @@ export class Game {
|
|
|
dead: 0,
|
|
dead: 0,
|
|
|
boost: 0,
|
|
boost: 0,
|
|
|
flight: 0,
|
|
flight: 0,
|
|
|
|
|
+ poison: 0,
|
|
|
|
|
+ cough: 0,
|
|
|
item: null,
|
|
item: null,
|
|
|
score: 0,
|
|
score: 0,
|
|
|
};
|
|
};
|
|
@@ -380,6 +397,7 @@ export class Game {
|
|
|
const changedBy: number[] = [];
|
|
const changedBy: number[] = [];
|
|
|
for (const { op, owner, onApplied } of pending) {
|
|
for (const { op, owner, onApplied } of pending) {
|
|
|
const { changed, removedByMaterial } = this.emit({ kind: 'op', op }, result);
|
|
const { changed, removedByMaterial } = this.emit({ kind: 'op', op }, result);
|
|
|
|
|
+ if (changed && op.kind === 'convert' && op.to === MATERIAL_SOAKED) this.soakZones.push({ x: op.x, y: op.y, r: op.r, time: SOAK_SAG_TIME_S, next: 0 });
|
|
|
let removed = 0;
|
|
let removed = 0;
|
|
|
for (const n of removedByMaterial.values()) removed += n;
|
|
for (const n of removedByMaterial.values()) removed += n;
|
|
|
this.stats.add(owner, 'eaten', removed);
|
|
this.stats.add(owner, 'eaten', removed);
|
|
@@ -405,6 +423,8 @@ export class Game {
|
|
|
this.updateRound();
|
|
this.updateRound();
|
|
|
this.updateCherry();
|
|
this.updateCherry();
|
|
|
this.updateItems();
|
|
this.updateItems();
|
|
|
|
|
+ this.updateClouds();
|
|
|
|
|
+ this.updateSoakZones();
|
|
|
this.updateChunks(result);
|
|
this.updateChunks(result);
|
|
|
this.syncTerrain(result);
|
|
this.syncTerrain(result);
|
|
|
result.fx = this.fx;
|
|
result.fx = this.fx;
|
|
@@ -427,7 +447,11 @@ export class Game {
|
|
|
grounded: p.grounded,
|
|
grounded: p.grounded,
|
|
|
size: p.character.size,
|
|
size: p.character.size,
|
|
|
anim:
|
|
anim:
|
|
|
- p.character.anim() | (p.stun > 0 ? Anim.Stunned : 0) | (p.dead > 0 ? Anim.Dead : 0) | (p.boost > 0 ? Anim.Boosted : 0),
|
|
|
|
|
|
|
+ p.character.anim() |
|
|
|
|
|
+ (p.stun > 0 ? Anim.Stunned : 0) |
|
|
|
|
|
+ (p.dead > 0 ? Anim.Dead : 0) |
|
|
|
|
|
+ (p.boost > 0 ? Anim.Boosted : 0) |
|
|
|
|
|
+ (p.poison > 0 ? Anim.Poisoned : 0),
|
|
|
score: Math.floor(p.score),
|
|
score: Math.floor(p.score),
|
|
|
item: p.item,
|
|
item: p.item,
|
|
|
};
|
|
};
|
|
@@ -455,6 +479,7 @@ export class Game {
|
|
|
timeLeft: this.rules ? Math.max(0, this.timeLeft) : null,
|
|
timeLeft: this.rules ? Math.max(0, this.timeLeft) : null,
|
|
|
finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
|
|
finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
|
|
|
wind: this.wind,
|
|
wind: this.wind,
|
|
|
|
|
+ clouds: this.clouds.map((c) => [c.x, c.y, c.r, c.life / c.maxLife]),
|
|
|
cherry,
|
|
cherry,
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
@@ -596,8 +621,10 @@ export class Game {
|
|
|
p.stun = Math.max(0, p.stun - TICK_DT);
|
|
p.stun = Math.max(0, p.stun - TICK_DT);
|
|
|
p.boost = Math.max(0, p.boost - TICK_DT);
|
|
p.boost = Math.max(0, p.boost - TICK_DT);
|
|
|
p.flight = Math.max(0, p.flight - TICK_DT);
|
|
p.flight = Math.max(0, p.flight - TICK_DT);
|
|
|
- // Stunned players keep their momentum but ignore input.
|
|
|
|
|
- const input = p.stun > 0 ? { ...p.input, buttons: 0 } : p.input;
|
|
|
|
|
|
|
+ p.poison = Math.max(0, p.poison - TICK_DT);
|
|
|
|
|
+ // Stunned (or busy) players keep their momentum but ignore input.
|
|
|
|
|
+ const locked = p.stun > 0 || p.character.busy();
|
|
|
|
|
+ const input = locked ? { ...p.input, buttons: 0 } : p.input;
|
|
|
const pressed = input.buttons & ~p.prevButtons;
|
|
const pressed = input.buttons & ~p.prevButtons;
|
|
|
const released = p.prevButtons & ~input.buttons;
|
|
const released = p.prevButtons & ~input.buttons;
|
|
|
p.prevButtons = input.buttons;
|
|
p.prevButtons = input.buttons;
|
|
@@ -606,9 +633,9 @@ export class Game {
|
|
|
let vx = vel.x * PPM;
|
|
let vx = vel.x * PPM;
|
|
|
let vy = vel.y * PPM;
|
|
let vy = vel.y * PPM;
|
|
|
const mobility = p.character.mobility();
|
|
const mobility = p.character.mobility();
|
|
|
- const speedMult = p.boost > 0 ? SMARTIES_SPEED_MULT : 1;
|
|
|
|
|
|
|
+ const speedMult = (p.boost > 0 ? SMARTIES_SPEED_MULT : 1) * (p.poison > 0 ? POISON_SPEED_MULT : 1);
|
|
|
|
|
|
|
|
- if (p.stun === 0) {
|
|
|
|
|
|
|
+ if (!locked) {
|
|
|
const dir = (isDown(input, Button.Right) ? 1 : 0) - (isDown(input, Button.Left) ? 1 : 0);
|
|
const dir = (isDown(input, Button.Right) ? 1 : 0) - (isDown(input, Button.Left) ? 1 : 0);
|
|
|
const accel = stats.acceleration * speedMult * (p.grounded ? 1 : AIR_CONTROL) * TICK_DT;
|
|
const accel = stats.acceleration * speedMult * (p.grounded ? 1 : AIR_CONTROL) * TICK_DT;
|
|
|
const target = dir * stats.speed * mobility * speedMult;
|
|
const target = dir * stats.speed * mobility * speedMult;
|
|
@@ -626,6 +653,14 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
// Wind: full push in the air, partial on the ground.
|
|
// Wind: full push in the air, partial on the ground.
|
|
|
vx += this.wind * (p.grounded ? WIND_GROUND_FACTOR : 1) * TICK_DT;
|
|
vx += this.wind * (p.grounded ? WIND_GROUND_FACTOR : 1) * TICK_DT;
|
|
|
|
|
+ // Coughing in a toxic cloud: the run stumbles now and then.
|
|
|
|
|
+ if (p.poison > 0) {
|
|
|
|
|
+ p.cough -= TICK_DT;
|
|
|
|
|
+ if (p.cough <= 0) {
|
|
|
|
|
+ p.cough = COUGH_INTERVAL_S * (0.6 + this.rng.next() * 0.8);
|
|
|
|
|
+ vx *= COUGH_STUMBLE;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
|
|
p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
|
|
|
|
|
|
|
|
if (pressed & Button.Use && p.item === 'candle') this.throwCandle(p, input);
|
|
if (pressed & Button.Use && p.item === 'candle') this.throwCandle(p, input);
|
|
@@ -743,7 +778,8 @@ export class Game {
|
|
|
const nx = dist > 0 ? dx / dist : (attacker?.facing ?? 1);
|
|
const nx = dist > 0 ? dx / dist : (attacker?.facing ?? 1);
|
|
|
const ny = Math.min(dist > 0 ? dy / dist : 0, -0.4);
|
|
const ny = Math.min(dist > 0 ? dy / dist : 0, -0.4);
|
|
|
const len = Math.hypot(nx, ny);
|
|
const len = Math.hypot(nx, ny);
|
|
|
- target.body.setLinearVelocity({ x: ((nx / len) * knockback) / PPM, y: ((ny / len) * knockback) / PPM });
|
|
|
|
|
|
|
+ const kb = knockback * target.character.knockbackTaken();
|
|
|
|
|
+ target.body.setLinearVelocity({ x: ((nx / len) * kb) / PPM, y: ((ny / len) * kb) / PPM });
|
|
|
target.stun = Math.max(target.stun, stun);
|
|
target.stun = Math.max(target.stun, stun);
|
|
|
target.character.onHit(this.contextFor(target, target.input));
|
|
target.character.onHit(this.contextFor(target, target.input));
|
|
|
// A hit holder lets the cherry go (readme §4).
|
|
// A hit holder lets the cherry go (readme §4).
|
|
@@ -772,7 +808,108 @@ export class Game {
|
|
|
hit: (x, y, r, knockback, stun) => this.hitArea(p, x, y, r, knockback, stun),
|
|
hit: (x, y, r, knockback, stun) => this.hitArea(p, x, y, r, knockback, stun),
|
|
|
launch: (spec, x, y, vx, vy) => this.throwEntity(spec.kind, spec, p.info.id, x, y, vx, vy),
|
|
launch: (spec, x, y, vx, vy) => this.throwEntity(spec.kind, spec, p.info.id, x, y, vx, vy),
|
|
|
explode: (x, y, blast, spareSelf) => this.explodeAt(x, y, blast, p.info.id, spareSelf ? p : null),
|
|
explode: (x, y, blast, spareSelf) => this.explodeAt(x, y, blast, p.info.id, spareSelf ? p : null),
|
|
|
|
|
+ random: () => this.rng.next(),
|
|
|
|
|
+ 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),
|
|
|
|
|
+ cloud: (x, y, r, life) => this.clouds.push({ x, y, r, life, maxLife: life, owner: p.info.id }),
|
|
|
|
|
+ swapWithRandomPlayer: () => this.swapWithRandom(p),
|
|
|
|
|
+ cue: (cue) => {
|
|
|
|
|
+ const at = p.body.getPosition();
|
|
|
|
|
+ this.fx.push({ kind: 'cue', id: p.info.id, cue, x: at.x * PPM, y: at.y * PPM });
|
|
|
|
|
+ },
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Rank by this round's score: 0 = last, 1 = leader, 0.5 when alone or everyone is tied. */
|
|
|
|
|
+ private standing(p: ServerPlayer): number {
|
|
|
|
|
+ const others = [...this.players.values()].filter((o) => o !== p);
|
|
|
|
|
+ if (others.length === 0) return 0.5;
|
|
|
|
|
+ let below = 0;
|
|
|
|
|
+ let tied = 0;
|
|
|
|
|
+ for (const o of others) {
|
|
|
|
|
+ if (o.score < p.score) below++;
|
|
|
|
|
+ else if (o.score === p.score) tied++;
|
|
|
|
|
+ }
|
|
|
|
|
+ return (below + tied / 2) / others.length;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Pushes falling chunks, loose items and stunned players around (x, y); others get `fraction` of it. */
|
|
|
|
|
+ private shove(by: ServerPlayer, x: number, y: number, r: number, vx: number, vy: number, fraction: number): number {
|
|
|
|
|
+ let moved = 0;
|
|
|
|
|
+ const near = (bx: number, by2: number, extra: number): boolean => Math.hypot(bx * PPM - x, by2 * PPM - y) <= r + extra;
|
|
|
|
|
+ const push = (body: Body, k: number): void => {
|
|
|
|
|
+ const v = body.getLinearVelocity();
|
|
|
|
|
+ body.setLinearVelocity({ x: v.x + (vx * k) / PPM, y: v.y + (vy * k) / PPM });
|
|
|
|
|
+ body.setAwake(true);
|
|
|
|
|
+ moved++;
|
|
|
};
|
|
};
|
|
|
|
|
+ for (const c of this.chunks.values()) {
|
|
|
|
|
+ const center = c.body.getWorldCenter();
|
|
|
|
|
+ if (near(center.x, center.y, Math.max(c.w, c.h) / 2)) push(c.body, 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const it of this.items.values()) {
|
|
|
|
|
+ const pos = it.body.getPosition();
|
|
|
|
|
+ if (near(pos.x, pos.y, ITEM_RADIUS)) push(it.body, 1);
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const t of this.alivePlayers()) {
|
|
|
|
|
+ if (t === by) continue;
|
|
|
|
|
+ const pos = t.body.getPosition();
|
|
|
|
|
+ if (!near(pos.x, pos.y, t.bodyRadius)) continue;
|
|
|
|
|
+ push(t.body, t.stun > 0 ? 1 : fraction);
|
|
|
|
|
+ if (this.cherry?.holder === t.info.id && t.stun > 0) this.dropCherry(t);
|
|
|
|
|
+ }
|
|
|
|
|
+ return moved;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Swaps places (and momentum) with another living player chosen at random. */
|
|
|
|
|
+ private swapWithRandom(p: ServerPlayer): boolean {
|
|
|
|
|
+ const others = this.alivePlayers().filter((o) => o !== p);
|
|
|
|
|
+ if (others.length === 0) return false;
|
|
|
|
|
+ const o = others[this.rng.int(0, others.length - 1)];
|
|
|
|
|
+ const a = p.body.getPosition().clone();
|
|
|
|
|
+ const b = o.body.getPosition().clone();
|
|
|
|
|
+ p.body.setPosition(b);
|
|
|
|
|
+ o.body.setPosition(a);
|
|
|
|
|
+ p.body.setLinearVelocity({ x: 0, y: 0 });
|
|
|
|
|
+ o.body.setLinearVelocity({ x: 0, y: 0 });
|
|
|
|
|
+ p.body.setAwake(true);
|
|
|
|
|
+ o.body.setAwake(true);
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private updateClouds(): void {
|
|
|
|
|
+ if (this.clouds.length === 0) return;
|
|
|
|
|
+ for (const c of this.clouds) {
|
|
|
|
|
+ c.life -= TICK_DT;
|
|
|
|
|
+ for (const p of this.alivePlayers()) {
|
|
|
|
|
+ if (p.info.id === c.owner) continue; // he is used to it
|
|
|
|
|
+ const pos = p.body.getPosition();
|
|
|
|
|
+ if (Math.hypot(pos.x * PPM - c.x, pos.y * PPM - c.y) <= c.r + p.bodyRadius) p.poison = POISON_S;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.clouds = this.clouds.filter((c) => c.life > 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Soaked sponge slowly sags: a few soaked pixels in recently soaked areas crumble (readme §4). */
|
|
|
|
|
+ private updateSoakZones(): void {
|
|
|
|
|
+ if (this.soakZones.length === 0) return;
|
|
|
|
|
+ const { width, height, data } = this.terrain;
|
|
|
|
|
+ for (const z of this.soakZones) {
|
|
|
|
|
+ z.time -= TICK_DT;
|
|
|
|
|
+ z.next -= TICK_DT;
|
|
|
|
|
+ if (z.next > 0) continue;
|
|
|
|
|
+ z.next = SOAK_SAG_INTERVAL_S;
|
|
|
|
|
+ // 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));
|
|
|
|
|
+ const y = Math.round(z.y + this.rng.range(0, z.r));
|
|
|
|
|
+ if (x < 0 || y < 0 || x >= width || y >= height || data[y * width + x] !== MATERIAL_SOAKED) continue;
|
|
|
|
|
+ this.queueOp({ kind: 'carve', x, y, r: SOAK_SAG_RADIUS, power: SOAK_SAG_POWER });
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ this.soakZones = this.soakZones.filter((z) => z.time > 0);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private alivePlayers(): ServerPlayer[] {
|
|
private alivePlayers(): ServerPlayer[] {
|