|
|
@@ -20,6 +20,22 @@ import {
|
|
|
CANDLE_THROW_SPEED,
|
|
|
CAKE_CHECK_INTERVAL_S,
|
|
|
CHERRY_DROP_SPEED,
|
|
|
+ CHERRY_FLEE_COOLDOWN_S,
|
|
|
+ CHERRY_FLEE_HOP,
|
|
|
+ CHERRY_FLEE_RADIUS,
|
|
|
+ CHERRY_FLEE_SPEED,
|
|
|
+ CHERRY_HOT_BLAST,
|
|
|
+ CHERRY_HOT_S,
|
|
|
+ CHERRY_STEAL_BONUS,
|
|
|
+ CHERRY_STEAL_WINDOW_S,
|
|
|
+ CRUSH_KILL_PIXELS,
|
|
|
+ FLAT_S,
|
|
|
+ SMALL_PROJECTILE_RADIUS,
|
|
|
+ SPLAT_SPEED,
|
|
|
+ SPLAT_STUN_S,
|
|
|
+ SURPRISE_WEIGHTS,
|
|
|
+ SURPRISE_WINDOW,
|
|
|
+ SURPRISES_PER_ROUND,
|
|
|
CHERRY_HOLDER_JUMP_MULT,
|
|
|
CHERRY_POINTS_PER_S,
|
|
|
CHERRY_RADIUS,
|
|
|
@@ -88,7 +104,7 @@ import {
|
|
|
import { Button, EMPTY_INPUT, isDown, type InputState } from '../shared/input';
|
|
|
import type { MapDefinition } from '../shared/maps/loader';
|
|
|
import type { MaterialRegistry } from '../shared/materials';
|
|
|
-import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState, TurretState } from '../shared/protocol';
|
|
|
+import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState, SurpriseId, TurretState } from '../shared/protocol';
|
|
|
import { Rng } from '../shared/rng';
|
|
|
import type { Rect, TerrainBitmap } from '../shared/terrain/bitmap';
|
|
|
import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
|
|
|
@@ -96,6 +112,7 @@ import { applyCells, applyTerrainEvent, rasterizeChunk, type SequencedEvent, typ
|
|
|
import type { TerrainOp } from '../shared/terrain/ops';
|
|
|
import { rleEncode } from '../shared/terrain/rle';
|
|
|
import { MODIFIERS, type Modifier, type ModifierContext } from './modifiers';
|
|
|
+import { SURPRISES, type Surprise, type SurpriseContext } from './surprises';
|
|
|
import { FallingChunk, type FixtureTag } from './physics/fallingChunk';
|
|
|
import { TerrainColliders } from './physics/terrainColliders';
|
|
|
import { MatchStats } from './stats';
|
|
|
@@ -148,6 +165,11 @@ interface ServerPlayer {
|
|
|
cough: number;
|
|
|
/** Drenched by Bébé Pompier: seconds left. */
|
|
|
soaked: number;
|
|
|
+ /** Flattened into a pancake: seconds left. */
|
|
|
+ flat: number;
|
|
|
+ /** Speed (px/s) and velocity at the end of the previous step, to notice a slam into a wall. */
|
|
|
+ lastVx: number;
|
|
|
+ lastVy: number;
|
|
|
/** Raw buttons of the previous tick (the suicide key works even when stunned). */
|
|
|
prevRaw: number;
|
|
|
item: ItemKind | null;
|
|
|
@@ -227,12 +249,21 @@ interface Cherry {
|
|
|
/** Player who cannot grab it back yet, and for how long. */
|
|
|
blocked: number;
|
|
|
blockedFor: number;
|
|
|
+ /** Seconds in its current holder's hands (hot potato). */
|
|
|
+ heldFor: number;
|
|
|
+ /** Seconds before it may hop away again. */
|
|
|
+ fleeIn: number;
|
|
|
+ /** Who knocked it out of whose hands, and how long the steal bonus stays open. */
|
|
|
+ thief: number;
|
|
|
+ victim: number;
|
|
|
+ stealFor: number;
|
|
|
}
|
|
|
|
|
|
export interface Crush {
|
|
|
id: number;
|
|
|
x: number;
|
|
|
y: number;
|
|
|
+ flat: boolean;
|
|
|
}
|
|
|
|
|
|
/** What happened during one tick, for the transport to broadcast. */
|
|
|
@@ -282,7 +313,12 @@ export class Game {
|
|
|
private readonly chunks = new Map<number, FallingChunk>();
|
|
|
private nextChunkId = 1;
|
|
|
/** Players hit hard enough by a chunk during the current world step. */
|
|
|
- private readonly crushedThisStep = new Set<number>();
|
|
|
+ /** Players hit hard enough by a chunk during the current world step, with the size of the biggest one (px). */
|
|
|
+ private readonly crushedThisStep = new Map<number, number>();
|
|
|
+ /** Speed of each falling chunk before the current world step (px/s). */
|
|
|
+ private readonly chunkSpeedBefore = new Map<number, number>();
|
|
|
+ /** Pixel count of each falling chunk (small ones only flatten). */
|
|
|
+ private readonly chunkPixels = new Map<number, number>();
|
|
|
private nextPlayerId = 1;
|
|
|
private nextSpawn = 0;
|
|
|
|
|
|
@@ -295,6 +331,10 @@ export class Game {
|
|
|
/** FX produced outside step() bookkeeping (hits, pickups), flushed into the step result. */
|
|
|
private fx: FxEvent[] = [];
|
|
|
private readonly modifiers: Modifier[] = [];
|
|
|
+ /** Round times (s since the start) of the surprises still to come, and the one going on. */
|
|
|
+ private surpriseAt: number[] = [];
|
|
|
+ private surprise: { s: Surprise; t: number } | null = null;
|
|
|
+ private readonly surpriseCtx: SurpriseContext;
|
|
|
private readonly modifierCtx: ModifierContext;
|
|
|
/** Side wind this tick (px/s²). */
|
|
|
private wind = 0;
|
|
|
@@ -333,7 +373,39 @@ export class Game {
|
|
|
const make = MODIFIERS[id];
|
|
|
if (make) this.modifiers.push(make());
|
|
|
}
|
|
|
+ // Surprises at random times, away from the very start and the final rush.
|
|
|
+ const [from, beforeEnd] = SURPRISE_WINDOW;
|
|
|
+ const to = this.rules.duration - beforeEnd;
|
|
|
+ if (to > from) for (let i = 0; i < SURPRISES_PER_ROUND; i++) this.surpriseAt.push(this.rng.range(from, to));
|
|
|
+ this.surpriseAt.sort((a, b) => a - b);
|
|
|
}
|
|
|
+ this.surpriseCtx = {
|
|
|
+ rng: this.rng,
|
|
|
+ terrain: this.terrain,
|
|
|
+ queueOp: (op) => this.queueOp(op),
|
|
|
+ players: () =>
|
|
|
+ this.alivePlayers().map((p) => {
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ return { id: p.info.id, x: pos.x * PPM, y: pos.y * PPM, r: p.bodyRadius, grounded: p.grounded };
|
|
|
+ }),
|
|
|
+ fling: (id, vx, vy, stun) => {
|
|
|
+ const p = this.players.get(id);
|
|
|
+ if (!p || p.dead > 0) return;
|
|
|
+ p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
|
|
|
+ p.body.setAwake(true);
|
|
|
+ if (stun > 0) this.stunPlayer(p, stun);
|
|
|
+ if (stun > 0 && this.cherry?.holder === id) this.dropCherry(p);
|
|
|
+ },
|
|
|
+ launch: (spec, x, y, vx, vy) => this.throwEntity(spec.kind, spec, 0, x, y, vx, vy),
|
|
|
+ top: (x) => {
|
|
|
+ const cx = Math.round(x);
|
|
|
+ // The plate is not cake: a column whose top is the plate has nothing to grab.
|
|
|
+ for (let y = 0; y < Math.min(this.terrain.height, this.custardY); y++) {
|
|
|
+ if (this.terrain.isSolid(cx, y)) return this.terrain.get(cx, y) === MATERIAL_PLATE ? null : y;
|
|
|
+ }
|
|
|
+ return null;
|
|
|
+ },
|
|
|
+ };
|
|
|
this.modifierCtx = {
|
|
|
rng: this.rng,
|
|
|
terrain: this.terrain,
|
|
|
@@ -411,6 +483,9 @@ export class Game {
|
|
|
poison: 0,
|
|
|
cough: 0,
|
|
|
soaked: 0,
|
|
|
+ flat: 0,
|
|
|
+ lastVx: 0,
|
|
|
+ lastVy: 0,
|
|
|
prevRaw: 0,
|
|
|
item: null,
|
|
|
score: 0,
|
|
|
@@ -452,13 +527,17 @@ export class Game {
|
|
|
spawnItem(kind: EntityKind, x: number, y: number): number {
|
|
|
const id = this.nextItemId++;
|
|
|
const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true, userData: { item: id } });
|
|
|
- body.createFixture(new Circle(ITEM_RADIUS / PPM), {
|
|
|
+ // Dragées and sprinkles are small: their little blast must reach the cake they land on.
|
|
|
+ const radius = kind === 'sprinkle' || kind === 'dragee' ? SMALL_PROJECTILE_RADIUS : ITEM_RADIUS;
|
|
|
+ body.createFixture(new Circle(radius / PPM), {
|
|
|
density: 0.6,
|
|
|
friction: 0.6,
|
|
|
restitution: 0.25,
|
|
|
userData: { item: id } satisfies FixtureTag,
|
|
|
// A ball of yarn flies through players: it must not stick to its thrower or wrap anyone.
|
|
|
filterMaskBits: kind === 'yarn' ? ~CATEGORY_PLAYER & 0xffff : 0xffff,
|
|
|
+ // Sprinkles rain down together: they never hit one another.
|
|
|
+ filterGroupIndex: kind === 'sprinkle' ? -1 : 0,
|
|
|
});
|
|
|
this.items.set(id, { id, kind, body, age: 0, thrown: null });
|
|
|
return id;
|
|
|
@@ -492,13 +571,19 @@ export class Game {
|
|
|
this.syncTerrain(result);
|
|
|
|
|
|
this.crushedThisStep.clear();
|
|
|
+ this.chunkSpeedBefore.clear();
|
|
|
+ for (const c of this.chunks.values()) this.chunkSpeedBefore.set(c.id, c.speed());
|
|
|
this.world.step(TICK_DT, 8, 3);
|
|
|
- for (const id of this.crushedThisStep) {
|
|
|
+ for (const [id, pixels] of this.crushedThisStep) {
|
|
|
const p = this.players.get(id);
|
|
|
if (!p || p.dead > 0) continue;
|
|
|
const pos = p.body.getPosition();
|
|
|
- result.crushed.push({ id, x: pos.x * PPM, y: pos.y * PPM });
|
|
|
- this.kill(p, 'crushed');
|
|
|
+ // A small chunk only flattens its victim into a pancake for a while.
|
|
|
+ const flat = pixels < CRUSH_KILL_PIXELS;
|
|
|
+ if (flat && p.flat > 0) continue;
|
|
|
+ result.crushed.push({ id, x: pos.x * PPM, y: pos.y * PPM, flat });
|
|
|
+ if (flat) this.flatten(p);
|
|
|
+ else this.kill(p, 'crushed');
|
|
|
}
|
|
|
for (const p of this.players.values()) this.postStep(p);
|
|
|
this.updateRound();
|
|
|
@@ -534,7 +619,8 @@ export class Game {
|
|
|
(p.dead > 0 ? Anim.Dead : 0) |
|
|
|
(p.boost > 0 ? Anim.Boosted : 0) |
|
|
|
(p.poison > 0 ? Anim.Poisoned : 0) |
|
|
|
- (p.soaked > 0 ? Anim.Soaked : 0),
|
|
|
+ (p.soaked > 0 ? Anim.Soaked : 0) |
|
|
|
+ (p.flat > 0 ? Anim.Flat : 0),
|
|
|
score: Math.floor(p.score),
|
|
|
item: p.item,
|
|
|
};
|
|
|
@@ -555,7 +641,7 @@ export class Game {
|
|
|
let cherry: RoundState['cherry'] = null;
|
|
|
if (this.cherry) {
|
|
|
const pos = this.cherry.body.getPosition();
|
|
|
- cherry = { x: pos.x * PPM, y: pos.y * PPM, holder: this.cherry.holder };
|
|
|
+ cherry = { x: pos.x * PPM, y: pos.y * PPM, holder: this.cherry.holder, heat: this.cherry.holder ? Math.min(1, this.cherry.heldFor / CHERRY_HOT_S) : 0 };
|
|
|
}
|
|
|
return {
|
|
|
custardY: this.custardY,
|
|
|
@@ -604,6 +690,7 @@ export class Game {
|
|
|
if (applyCells(this.terrain, 'detach', x0, y0, w, h, c.mask).changed) this.log({ kind: 'detach', id, x: x0, y: y0, w, h, mask, debris }, result);
|
|
|
if (debris) return;
|
|
|
this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials, mask));
|
|
|
+ this.chunkPixels.set(id, c.pixelCount);
|
|
|
this.stats.add(owner, 'detached');
|
|
|
}
|
|
|
|
|
|
@@ -685,6 +772,7 @@ export class Game {
|
|
|
private removeChunk(chunk: FallingChunk): void {
|
|
|
this.world.destroyBody(chunk.body);
|
|
|
this.chunks.delete(chunk.id);
|
|
|
+ this.chunkPixels.delete(chunk.id);
|
|
|
}
|
|
|
|
|
|
private onPostSolve(contact: Contact, impulse: ContactImpulse): void {
|
|
|
@@ -694,10 +782,11 @@ export class Game {
|
|
|
const chunkId = a?.chunk ?? b?.chunk;
|
|
|
if (playerId === undefined || chunkId === undefined) return;
|
|
|
const chunk = this.chunks.get(chunkId);
|
|
|
- if (!chunk || !chunk.canCrush || chunk.speed() < CRUSH_MIN_SPEED) return;
|
|
|
+ // Speed before the impact: a small chunk stops dead on the player it lands on.
|
|
|
+ if (!chunk || !chunk.canCrush || Math.max(chunk.speed(), this.chunkSpeedBefore.get(chunkId) ?? 0) < CRUSH_MIN_SPEED) return;
|
|
|
let total = 0;
|
|
|
for (const n of impulse.normalImpulses) total += n;
|
|
|
- if (total > CRUSH_IMPULSE) this.crushedThisStep.add(playerId);
|
|
|
+ if (total > CRUSH_IMPULSE) this.crushedThisStep.set(playerId, Math.max(this.crushedThisStep.get(playerId) ?? 0, this.chunkPixels.get(chunkId) ?? Infinity));
|
|
|
}
|
|
|
|
|
|
// ---------------------------------------------------------------- players
|
|
|
@@ -731,6 +820,7 @@ export class Game {
|
|
|
return;
|
|
|
}
|
|
|
p.stun = Math.max(0, p.stun - TICK_DT);
|
|
|
+ p.flat = Math.max(0, p.flat - 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);
|
|
|
@@ -809,6 +899,10 @@ export class Game {
|
|
|
if (p.dead > 0) return;
|
|
|
p.grounded = false;
|
|
|
let wall = 0;
|
|
|
+ // The contact facing the previous velocity the most: a slam into it is a cartoon splat.
|
|
|
+ const lastSpeed = Math.hypot(p.lastVx, p.lastVy);
|
|
|
+ let slam: { nx: number; ny: number } | null = null;
|
|
|
+ let slamDot = 0;
|
|
|
for (let edge = p.body.getContactList(); edge; edge = edge.next) {
|
|
|
const contact = edge.contact;
|
|
|
if (!contact.isTouching()) continue;
|
|
|
@@ -818,11 +912,26 @@ export class Game {
|
|
|
const flip = contact.getFixtureA().getBody() === p.body ? -1 : 1;
|
|
|
const nx = normal.x * flip;
|
|
|
const ny = normal.y * flip;
|
|
|
+ const dot = (p.lastVx * nx + p.lastVy * ny) / (lastSpeed || 1);
|
|
|
+ if (dot < slamDot) {
|
|
|
+ slamDot = dot;
|
|
|
+ slam = { nx, ny };
|
|
|
+ }
|
|
|
if (ny < GROUND_NORMAL_Y) p.grounded = true;
|
|
|
// A wall on the left pushes the player right (+x), and the other way round.
|
|
|
else if (Math.abs(nx) > WALL_NORMAL_X) wall = nx > 0 ? -1 : 1;
|
|
|
}
|
|
|
p.sinceGrounded = p.grounded ? 0 : p.sinceGrounded + TICK_DT;
|
|
|
+ const vel = p.body.getLinearVelocity();
|
|
|
+ // Only someone sent flying by a hit (stunned) splats: a long fall onto the floor is just a landing.
|
|
|
+ if (slam && p.stun > 0 && lastSpeed > SPLAT_SPEED && slamDot < -0.6 && Math.hypot(vel.x, vel.y) * PPM < lastSpeed * 0.5) {
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ this.fx.push({ kind: 'splat', id: p.info.id, x: pos.x * PPM, y: pos.y * PPM, nx: slam.nx, ny: slam.ny });
|
|
|
+ // Stuck to the wall a moment longer.
|
|
|
+ p.stun = Math.max(p.stun, SPLAT_STUN_S);
|
|
|
+ }
|
|
|
+ p.lastVx = vel.x * PPM;
|
|
|
+ p.lastVy = vel.y * PPM;
|
|
|
if (!p.grounded && wall !== 0) {
|
|
|
p.wallSide = wall;
|
|
|
p.sinceWall = 0;
|
|
|
@@ -1005,8 +1114,15 @@ export class Game {
|
|
|
target.body.setLinearVelocity({ x: ((nx / len) * kb) / PPM, y: ((ny / len) * kb) / PPM });
|
|
|
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);
|
|
|
+ // A hit holder lets the cherry go (readme §4); the attacker may steal it.
|
|
|
+ if (this.cherry?.holder === target.info.id) {
|
|
|
+ this.dropCherry(target);
|
|
|
+ if (attacker) {
|
|
|
+ this.cherry.thief = attacker.info.id;
|
|
|
+ this.cherry.victim = target.info.id;
|
|
|
+ this.cherry.stealFor = CHERRY_STEAL_WINDOW_S;
|
|
|
+ }
|
|
|
+ }
|
|
|
this.stats.add(target.info.id, 'hitTaken');
|
|
|
if (attacker) this.stats.add(attacker.info.id, 'hits');
|
|
|
count++;
|
|
|
@@ -1024,6 +1140,15 @@ export class Game {
|
|
|
p.stunLock = seconds + STUN_GUARD_S;
|
|
|
}
|
|
|
|
|
|
+ /** Flattened into a pancake by a small chunk: stuck for FLAT_S (whatever the stun guard says). */
|
|
|
+ private flatten(p: ServerPlayer): void {
|
|
|
+ if (this.cherry?.holder === p.info.id) this.dropCherry(p);
|
|
|
+ this.stats.add(p.info.id, 'hitTaken');
|
|
|
+ p.flat = FLAT_S;
|
|
|
+ p.stun = FLAT_S;
|
|
|
+ p.stunLock = FLAT_S + 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;
|
|
|
@@ -1293,6 +1418,7 @@ export class Game {
|
|
|
private updateRound(): void {
|
|
|
if (!this.rules) return;
|
|
|
for (const m of this.modifiers) m.tick(this.modifierCtx, TICK_DT);
|
|
|
+ this.updateSurprise();
|
|
|
if (this.wind !== 0) {
|
|
|
// Loose things drift too.
|
|
|
const push = (body: Body): void => {
|
|
|
@@ -1313,6 +1439,26 @@ export class Game {
|
|
|
this.custardY = Math.max(0, this.custardY - speed * TICK_DT);
|
|
|
}
|
|
|
|
|
|
+ /** Starts a surprise now (also used by tests and debug tools). */
|
|
|
+ startSurprise(id: SurpriseId): void {
|
|
|
+ const s = SURPRISES[id](this.surpriseCtx);
|
|
|
+ this.surprise = { s, t: 0 };
|
|
|
+ this.fx.push({ kind: 'surprise', surprise: id, x: s.x, y: s.y, duration: s.duration });
|
|
|
+ }
|
|
|
+
|
|
|
+ private updateSurprise(): void {
|
|
|
+ const elapsed = (this.rules?.duration ?? 0) - this.timeLeft;
|
|
|
+ if (!this.surprise && this.surpriseAt.length > 0 && elapsed >= this.surpriseAt[0]) {
|
|
|
+ this.surpriseAt.shift();
|
|
|
+ this.startSurprise(this.rng.weighted(SURPRISE_WEIGHTS as Record<SurpriseId, number>));
|
|
|
+ }
|
|
|
+ const active = this.surprise;
|
|
|
+ if (!active) return;
|
|
|
+ active.s.tick(this.surpriseCtx, active.t, TICK_DT);
|
|
|
+ active.t += TICK_DT;
|
|
|
+ if (active.t >= active.s.duration) this.surprise = null;
|
|
|
+ }
|
|
|
+
|
|
|
/** Solid pixels above the custard, plate excluded: what is left to stand on and fight over. */
|
|
|
private cakeLeft(): number {
|
|
|
const { width, data } = this.terrain;
|
|
|
@@ -1330,13 +1476,15 @@ export class Game {
|
|
|
const [x, y] = this.map.cherrySpawn;
|
|
|
const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true });
|
|
|
body.createFixture(new Circle(CHERRY_RADIUS / PPM), { density: 0.6, friction: 0.5, restitution: 0.35, userData: { cherry: true } satisfies FixtureTag });
|
|
|
- this.cherry = { body, holder: 0, blocked: 0, blockedFor: 0 };
|
|
|
+ this.cherry = { body, holder: 0, blocked: 0, blockedFor: 0, heldFor: 0, fleeIn: 0, thief: 0, victim: 0, stealFor: 0 };
|
|
|
}
|
|
|
|
|
|
private updateCherry(): void {
|
|
|
const cherry = this.cherry;
|
|
|
if (!cherry) return;
|
|
|
cherry.blockedFor = Math.max(0, cherry.blockedFor - TICK_DT);
|
|
|
+ cherry.stealFor = Math.max(0, cherry.stealFor - TICK_DT);
|
|
|
+ cherry.fleeIn = Math.max(0, cherry.fleeIn - TICK_DT);
|
|
|
const holder = cherry.holder ? this.players.get(cherry.holder) : undefined;
|
|
|
if (holder) {
|
|
|
// Held: it rides above the holder's head and scores 1 point per second.
|
|
|
@@ -1344,6 +1492,14 @@ export class Game {
|
|
|
cherry.body.setPosition({ x: pos.x, y: pos.y - (holder.character.size + CHERRY_HOLD_OFFSET) / PPM });
|
|
|
holder.score += CHERRY_POINTS_PER_S * TICK_DT;
|
|
|
this.stats.add(holder.info.id, 'cherry', TICK_DT);
|
|
|
+ // Hot potato: held too long, it blows up in their hands.
|
|
|
+ cherry.heldFor += TICK_DT;
|
|
|
+ if (cherry.heldFor >= CHERRY_HOT_S) {
|
|
|
+ const at = cherry.body.getPosition();
|
|
|
+ this.fx.push({ kind: 'cherryBoom', id: holder.info.id, x: at.x * PPM, y: at.y * PPM });
|
|
|
+ this.dropCherry(holder);
|
|
|
+ this.explodeAt(pos.x * PPM, pos.y * PPM, CHERRY_HOT_BLAST, 0, null);
|
|
|
+ }
|
|
|
return;
|
|
|
}
|
|
|
cherry.holder = 0;
|
|
|
@@ -1358,15 +1514,38 @@ export class Game {
|
|
|
cherry.body.setLinearVelocity({ x: 0, y: 0 });
|
|
|
return;
|
|
|
}
|
|
|
+ let nearest: ServerPlayer | null = null;
|
|
|
+ let nearestDist = Infinity;
|
|
|
for (const p of this.alivePlayers()) {
|
|
|
if (p.stun > 0 || (cherry.blocked === p.info.id && cherry.blockedFor > 0)) continue;
|
|
|
const pp = p.body.getPosition();
|
|
|
- if (Math.hypot(pp.x - pos.x, pp.y - pos.y) * PPM > p.bodyRadius + CHERRY_RADIUS + 4) continue;
|
|
|
+ const dist = Math.hypot(pp.x - pos.x, pp.y - pos.y) * PPM;
|
|
|
+ if (dist < nearestDist) {
|
|
|
+ nearest = p;
|
|
|
+ nearestDist = dist;
|
|
|
+ }
|
|
|
+ if (dist > p.bodyRadius + CHERRY_RADIUS + 4) continue;
|
|
|
cherry.holder = p.info.id;
|
|
|
+ cherry.heldFor = 0;
|
|
|
cherry.body.setActive(false);
|
|
|
this.fx.push({ kind: 'pickup', id: p.info.id, item: 'cherry', x: pos.x * PPM, y: pos.y * PPM });
|
|
|
+ // Knocked out of someone's hands and grabbed right away: stolen!
|
|
|
+ if (cherry.stealFor > 0 && cherry.thief === p.info.id) {
|
|
|
+ p.score += CHERRY_STEAL_BONUS;
|
|
|
+ this.fx.push({ kind: 'stolen', id: p.info.id, from: cherry.victim, bonus: CHERRY_STEAL_BONUS, x: pos.x * PPM, y: pos.y * PPM });
|
|
|
+ }
|
|
|
+ cherry.stealFor = 0;
|
|
|
return;
|
|
|
}
|
|
|
+ // Alive: resting, it hops away from whoever comes close (it can still be cornered).
|
|
|
+ const v = cherry.body.getLinearVelocity();
|
|
|
+ if (nearest && nearestDist < CHERRY_FLEE_RADIUS && cherry.fleeIn === 0 && Math.hypot(v.x, v.y) * PPM < 40) {
|
|
|
+ const away = pos.x >= nearest.body.getPosition().x ? 1 : -1;
|
|
|
+ cherry.body.setLinearVelocity({ x: (away * CHERRY_FLEE_SPEED) / PPM, y: -CHERRY_FLEE_HOP / PPM });
|
|
|
+ cherry.body.setAwake(true);
|
|
|
+ cherry.fleeIn = CHERRY_FLEE_COOLDOWN_S;
|
|
|
+ this.fx.push({ kind: 'cherryHop', x: pos.x * PPM, y: pos.y * PPM });
|
|
|
+ }
|
|
|
}
|
|
|
|
|
|
/** The holder lets the cherry go: it pops out and rolls (readme §4). */
|
|
|
@@ -1435,7 +1614,7 @@ export class Game {
|
|
|
}
|
|
|
|
|
|
private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
|
|
|
- if (item.kind === 'firecracker' || item.kind === 'yarn' || item.kind === 'dragee') return; // projectiles are not pickups
|
|
|
+ if (item.kind === 'firecracker' || item.kind === 'yarn' || item.kind === 'dragee' || item.kind === 'sprinkle') return; // projectiles are not pickups
|
|
|
this.removeItem(item);
|
|
|
this.fx.push({ kind: 'pickup', id: p.info.id, item: item.kind, x, y });
|
|
|
if (item.kind === 'candle') p.item = 'candle';
|