|
@@ -2,6 +2,17 @@ import { Circle, World, type Body, type Contact, type ContactImpulse, type Fixtu
|
|
|
import { Anim, createCharacter, type Character, type CharacterContext, type CharacterStats } from '../shared/characters';
|
|
import { Anim, createCharacter, type Character, type CharacterContext, type CharacterStats } from '../shared/characters';
|
|
|
import {
|
|
import {
|
|
|
AIR_CONTROL,
|
|
AIR_CONTROL,
|
|
|
|
|
+ CANDLE_ARM_S,
|
|
|
|
|
+ CANDLE_BLAST_POWER,
|
|
|
|
|
+ CANDLE_BLAST_RADIUS,
|
|
|
|
|
+ CANDLE_FUSE_S,
|
|
|
|
|
+ CANDLE_KNOCKBACK,
|
|
|
|
|
+ CANDLE_STUN_S,
|
|
|
|
|
+ CANDLE_THROW_SPEED,
|
|
|
|
|
+ CHERRY_DROP_SPEED,
|
|
|
|
|
+ CHERRY_POINTS_PER_S,
|
|
|
|
|
+ CHERRY_RADIUS,
|
|
|
|
|
+ CHERRY_REGRAB_DELAY,
|
|
|
CHUNK_GLUE_PX,
|
|
CHUNK_GLUE_PX,
|
|
|
CHUNK_MERGE_RETRY,
|
|
CHUNK_MERGE_RETRY,
|
|
|
CHUNK_REST_ANGULAR,
|
|
CHUNK_REST_ANGULAR,
|
|
@@ -10,20 +21,33 @@ import {
|
|
|
COYOTE_TIME,
|
|
COYOTE_TIME,
|
|
|
CRUSH_IMPULSE,
|
|
CRUSH_IMPULSE,
|
|
|
CRUSH_MIN_SPEED,
|
|
CRUSH_MIN_SPEED,
|
|
|
|
|
+ CUSTARD_SINK_PX,
|
|
|
|
|
+ FINAL_RUSH_S,
|
|
|
GRAVITY_PX,
|
|
GRAVITY_PX,
|
|
|
GROUND_NORMAL_Y,
|
|
GROUND_NORMAL_Y,
|
|
|
|
|
+ ITEM_LIFETIME_S,
|
|
|
|
|
+ ITEM_RADIUS,
|
|
|
JUMP_BUFFER_TIME,
|
|
JUMP_BUFFER_TIME,
|
|
|
JUMP_CUT,
|
|
JUMP_CUT,
|
|
|
MIN_CHUNK_PIXELS,
|
|
MIN_CHUNK_PIXELS,
|
|
|
OUT_OF_MAP_MARGIN,
|
|
OUT_OF_MAP_MARGIN,
|
|
|
PIXELS_PER_METER,
|
|
PIXELS_PER_METER,
|
|
|
RESIZE_THRESHOLD_PX,
|
|
RESIZE_THRESHOLD_PX,
|
|
|
|
|
+ RESPAWN_DELAY_S,
|
|
|
|
|
+ SMARTIES_DURATION_S,
|
|
|
|
|
+ SMARTIES_SPEED_MULT,
|
|
|
|
|
+ SPOON_LAUNCH_SPEED,
|
|
|
TICK_DT,
|
|
TICK_DT,
|
|
|
|
|
+ WHISK_CRACK_POWER,
|
|
|
|
|
+ WHISK_CRACK_RADIUS,
|
|
|
|
|
+ WHISK_CRACKS,
|
|
|
|
|
+ WHISK_JOLT,
|
|
|
|
|
+ WHISK_STUN_S,
|
|
|
} from '../shared/constants';
|
|
} from '../shared/constants';
|
|
|
import { Button, EMPTY_INPUT, isDown, type InputState } from '../shared/input';
|
|
import { Button, EMPTY_INPUT, isDown, type InputState } from '../shared/input';
|
|
|
import type { MapDefinition } from '../shared/maps/loader';
|
|
import type { MapDefinition } from '../shared/maps/loader';
|
|
|
import type { MaterialRegistry } from '../shared/materials';
|
|
import type { MaterialRegistry } from '../shared/materials';
|
|
|
-import type { ChunkInfo, ChunkState, PlayerInfo, PlayerState } from '../shared/protocol';
|
|
|
|
|
|
|
+import type { ChunkInfo, ChunkState, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState } from '../shared/protocol';
|
|
|
import { Rng } from '../shared/rng';
|
|
import { Rng } from '../shared/rng';
|
|
|
import type { TerrainBitmap } from '../shared/terrain/bitmap';
|
|
import type { TerrainBitmap } from '../shared/terrain/bitmap';
|
|
|
import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
|
|
import { DetachFinder, type DetachedComponent } from '../shared/terrain/detach';
|
|
@@ -32,8 +56,19 @@ import type { TerrainOp } from '../shared/terrain/ops';
|
|
|
import { rleEncode } from '../shared/terrain/rle';
|
|
import { rleEncode } from '../shared/terrain/rle';
|
|
|
import { FallingChunk, type FixtureTag } from './physics/fallingChunk';
|
|
import { FallingChunk, type FixtureTag } from './physics/fallingChunk';
|
|
|
import { TerrainColliders } from './physics/terrainColliders';
|
|
import { TerrainColliders } from './physics/terrainColliders';
|
|
|
|
|
+import { MatchStats } from './stats';
|
|
|
|
|
|
|
|
const PPM = PIXELS_PER_METER;
|
|
const PPM = PIXELS_PER_METER;
|
|
|
|
|
+const ITEM_KINDS: readonly ItemKind[] = ['candle', 'smarties', 'whisk'];
|
|
|
|
|
+const JELLY = 4;
|
|
|
|
|
+/** Height above a player's head where a held cherry sits (px). */
|
|
|
|
|
+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;
|
|
|
|
|
+/** Free space required on each side of the body along the launch column (px). */
|
|
|
|
|
+const SPOON_CLEARANCE = 8;
|
|
|
|
|
|
|
|
interface ServerPlayer {
|
|
interface ServerPlayer {
|
|
|
info: PlayerInfo;
|
|
info: PlayerInfo;
|
|
@@ -51,13 +86,41 @@ interface ServerPlayer {
|
|
|
attackCooldown: number;
|
|
attackCooldown: number;
|
|
|
specialCooldown: number;
|
|
specialCooldown: number;
|
|
|
stun: number;
|
|
stun: number;
|
|
|
|
|
+ /** Seconds before the spoon sends the player back; 0 = alive. */
|
|
|
|
|
+ dead: number;
|
|
|
|
|
+ /** Smarties boost time left. */
|
|
|
|
|
+ boost: number;
|
|
|
|
|
+ /** Spoon flight time left: horizontal momentum is kept unless the player steers. */
|
|
|
|
|
+ flight: number;
|
|
|
|
|
+ item: ItemKind | null;
|
|
|
|
|
+ /** Seconds holding the cherry this round. */
|
|
|
|
|
+ score: number;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
interface PendingOp {
|
|
interface PendingOp {
|
|
|
op: TerrainOp;
|
|
op: TerrainOp;
|
|
|
|
|
+ /** Player who caused it (0 = the game itself), for stats. */
|
|
|
|
|
+ owner: number;
|
|
|
onApplied?: (removed: number) => void;
|
|
onApplied?: (removed: number) => void;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+interface Item {
|
|
|
|
|
+ id: number;
|
|
|
|
|
+ kind: ItemKind;
|
|
|
|
|
+ body: Body;
|
|
|
|
|
+ age: number;
|
|
|
|
|
+ /** Set once a candle is thrown. */
|
|
|
|
|
+ thrown: { owner: number; fuse: number } | null;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+interface Cherry {
|
|
|
|
|
+ body: Body;
|
|
|
|
|
+ holder: number;
|
|
|
|
|
+ /** Player who cannot grab it back yet, and for how long. */
|
|
|
|
|
+ blocked: number;
|
|
|
|
|
+ blockedFor: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
export interface Crush {
|
|
export interface Crush {
|
|
|
id: number;
|
|
id: number;
|
|
|
x: number;
|
|
x: number;
|
|
@@ -67,9 +130,15 @@ export interface Crush {
|
|
|
/** What happened during one tick, for the transport to broadcast. */
|
|
/** What happened during one tick, for the transport to broadcast. */
|
|
|
export interface StepResult {
|
|
export interface StepResult {
|
|
|
terrain: SequencedEvent[];
|
|
terrain: SequencedEvent[];
|
|
|
- /** Chunks that left without merging (fell off the map). */
|
|
|
|
|
|
|
+ /** Chunks that left without merging (fell off the map or into the custard). */
|
|
|
removedChunks: number[];
|
|
removedChunks: number[];
|
|
|
crushed: Crush[];
|
|
crushed: Crush[];
|
|
|
|
|
+ fx: FxEvent[];
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+export interface RoundRules {
|
|
|
|
|
+ /** Round length (s). */
|
|
|
|
|
+ duration: number;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
export interface GameOptions {
|
|
export interface GameOptions {
|
|
@@ -78,20 +147,26 @@ export interface GameOptions {
|
|
|
materials: MaterialRegistry;
|
|
materials: MaterialRegistry;
|
|
|
seed: number;
|
|
seed: number;
|
|
|
loadCharacter: (id: string) => CharacterStats;
|
|
loadCharacter: (id: string) => CharacterStats;
|
|
|
|
|
+ /** Null during the warm-up: no timer, no cherry, no items, the custard stays put. */
|
|
|
|
|
+ rules?: RoundRules | null;
|
|
|
|
|
+ /** Match-wide stats sink (trophies). */
|
|
|
|
|
+ stats?: MatchStats;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
-/** Authoritative simulation. Plain Node, no networking: see net.ts for the transport. */
|
|
|
|
|
|
|
+/** Authoritative simulation of one round. Plain Node, no networking: see net.ts for the transport. */
|
|
|
export class Game {
|
|
export class Game {
|
|
|
readonly map: MapDefinition;
|
|
readonly map: MapDefinition;
|
|
|
readonly terrain: TerrainBitmap;
|
|
readonly terrain: TerrainBitmap;
|
|
|
readonly seed: number;
|
|
readonly seed: number;
|
|
|
readonly rng: Rng;
|
|
readonly rng: Rng;
|
|
|
|
|
+ readonly stats: MatchStats;
|
|
|
tick = 0;
|
|
tick = 0;
|
|
|
|
|
|
|
|
private readonly world: World;
|
|
private readonly world: World;
|
|
|
private readonly colliders: TerrainColliders;
|
|
private readonly colliders: TerrainColliders;
|
|
|
private readonly materials: MaterialRegistry;
|
|
private readonly materials: MaterialRegistry;
|
|
|
private readonly loadCharacter: (id: string) => CharacterStats;
|
|
private readonly loadCharacter: (id: string) => CharacterStats;
|
|
|
|
|
+ private readonly rules: RoundRules | null;
|
|
|
private readonly players = new Map<number, ServerPlayer>();
|
|
private readonly players = new Map<number, ServerPlayer>();
|
|
|
private readonly eventLog: SequencedEvent[] = [];
|
|
private readonly eventLog: SequencedEvent[] = [];
|
|
|
private pendingOps: PendingOp[] = [];
|
|
private pendingOps: PendingOp[] = [];
|
|
@@ -103,6 +178,15 @@ export class Game {
|
|
|
private nextPlayerId = 1;
|
|
private nextPlayerId = 1;
|
|
|
private nextSpawn = 0;
|
|
private nextSpawn = 0;
|
|
|
|
|
|
|
|
|
|
+ private custardY: number;
|
|
|
|
|
+ private timeLeft: number;
|
|
|
|
|
+ private cherry: Cherry | null = null;
|
|
|
|
|
+ private readonly items = new Map<number, Item>();
|
|
|
|
|
+ private nextItemId = 1;
|
|
|
|
|
+ private itemTimer = 0;
|
|
|
|
|
+ /** FX produced outside step() bookkeeping (hits, pickups), flushed into the step result. */
|
|
|
|
|
+ private fx: FxEvent[] = [];
|
|
|
|
|
+
|
|
|
constructor(opts: GameOptions) {
|
|
constructor(opts: GameOptions) {
|
|
|
this.map = opts.map;
|
|
this.map = opts.map;
|
|
|
this.terrain = opts.terrain;
|
|
this.terrain = opts.terrain;
|
|
@@ -110,17 +194,35 @@ export class Game {
|
|
|
this.seed = opts.seed;
|
|
this.seed = opts.seed;
|
|
|
this.rng = new Rng(opts.seed);
|
|
this.rng = new Rng(opts.seed);
|
|
|
this.loadCharacter = opts.loadCharacter;
|
|
this.loadCharacter = opts.loadCharacter;
|
|
|
|
|
+ this.rules = opts.rules ?? null;
|
|
|
|
|
+ this.stats = opts.stats ?? new MatchStats();
|
|
|
this.world = new World({ gravity: { x: 0, y: GRAVITY_PX / PPM } });
|
|
this.world = new World({ gravity: { x: 0, y: GRAVITY_PX / PPM } });
|
|
|
this.colliders = new TerrainColliders(this.world, this.terrain);
|
|
this.colliders = new TerrainColliders(this.world, this.terrain);
|
|
|
this.colliders.rebuildDirty();
|
|
this.colliders.rebuildDirty();
|
|
|
this.finder = new DetachFinder(this.terrain);
|
|
this.finder = new DetachFinder(this.terrain);
|
|
|
this.world.on('post-solve', (contact, impulse) => this.onPostSolve(contact, impulse));
|
|
this.world.on('post-solve', (contact, impulse) => this.onPostSolve(contact, impulse));
|
|
|
|
|
+
|
|
|
|
|
+ this.custardY = this.map.custard.startY;
|
|
|
|
|
+ this.timeLeft = this.rules?.duration ?? Infinity;
|
|
|
|
|
+ if (this.rules) {
|
|
|
|
|
+ this.spawnCherry();
|
|
|
|
|
+ this.itemTimer = this.nextItemDelay();
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
get events(): readonly SequencedEvent[] {
|
|
get events(): readonly SequencedEvent[] {
|
|
|
return this.eventLog;
|
|
return this.eventLog;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ /** True once the round timer has run out. */
|
|
|
|
|
+ get roundOver(): boolean {
|
|
|
|
|
+ return this.rules !== null && this.timeLeft <= 0;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ get custardLevel(): number {
|
|
|
|
|
+ return this.custardY;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
chunkInfos(): ChunkInfo[] {
|
|
chunkInfos(): ChunkInfo[] {
|
|
|
return [...this.chunks.values()].map((c) => c.info());
|
|
return [...this.chunks.values()].map((c) => c.info());
|
|
|
}
|
|
}
|
|
@@ -133,7 +235,12 @@ export class Game {
|
|
|
return [...this.players.values()].map((p) => p.info);
|
|
return [...this.players.values()].map((p) => p.info);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- /** Adds a player. The transport passes its own `id` so players keep it across matches. */
|
|
|
|
|
|
|
+ /** Whole points scored this round, by player id. */
|
|
|
|
|
+ scores(): Map<number, number> {
|
|
|
|
|
+ return new Map([...this.players.values()].map((p) => [p.info.id, Math.floor(p.score)]));
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Adds a player. The transport passes its own `id` so players keep it across rounds. */
|
|
|
addPlayer(name: string, characterId: string, id = this.nextPlayerId++): PlayerInfo {
|
|
addPlayer(name: string, characterId: string, id = this.nextPlayerId++): PlayerInfo {
|
|
|
const stats = this.loadCharacter(characterId);
|
|
const stats = this.loadCharacter(characterId);
|
|
|
this.nextPlayerId = Math.max(this.nextPlayerId, id + 1);
|
|
this.nextPlayerId = Math.max(this.nextPlayerId, id + 1);
|
|
@@ -154,15 +261,21 @@ export class Game {
|
|
|
attackCooldown: 0,
|
|
attackCooldown: 0,
|
|
|
specialCooldown: 0,
|
|
specialCooldown: 0,
|
|
|
stun: 0,
|
|
stun: 0,
|
|
|
|
|
+ dead: 0,
|
|
|
|
|
+ boost: 0,
|
|
|
|
|
+ flight: 0,
|
|
|
|
|
+ item: null,
|
|
|
|
|
+ score: 0,
|
|
|
};
|
|
};
|
|
|
this.players.set(id, player);
|
|
this.players.set(id, player);
|
|
|
- this.respawn(player);
|
|
|
|
|
|
|
+ this.placeAtSpawn(player);
|
|
|
return info;
|
|
return info;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
removePlayer(id: number): void {
|
|
removePlayer(id: number): void {
|
|
|
const p = this.players.get(id);
|
|
const p = this.players.get(id);
|
|
|
if (!p) return;
|
|
if (!p) return;
|
|
|
|
|
+ if (this.cherry?.holder === id) this.dropCherry(p);
|
|
|
this.world.destroyBody(p.body);
|
|
this.world.destroyBody(p.body);
|
|
|
this.players.delete(id);
|
|
this.players.delete(id);
|
|
|
}
|
|
}
|
|
@@ -182,25 +295,33 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Queues an op, applied later in this step (in queue order). */
|
|
/** Queues an op, applied later in this step (in queue order). */
|
|
|
- queueOp(op: TerrainOp, onApplied?: (removed: number) => void): void {
|
|
|
|
|
- this.pendingOps.push({ op, onApplied });
|
|
|
|
|
|
|
+ queueOp(op: TerrainOp, onApplied?: (removed: number) => void, owner = 0): void {
|
|
|
|
|
+ this.pendingOps.push({ op, owner, onApplied });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Spawns an item now (tests and debug). */
|
|
|
|
|
+ spawnItem(kind: ItemKind, 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), { density: 0.6, friction: 0.6, restitution: 0.25, userData: { item: id } satisfies FixtureTag });
|
|
|
|
|
+ this.items.set(id, { id, kind, body, age: 0, thrown: null });
|
|
|
|
|
+ return id;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Advances one fixed tick. Terrain events in the result must be broadcast as-is, in order. */
|
|
/** Advances one fixed tick. Terrain events in the result must be broadcast as-is, in order. */
|
|
|
step(): StepResult {
|
|
step(): StepResult {
|
|
|
- const result: StepResult = { terrain: [], removedChunks: [], crushed: [] };
|
|
|
|
|
|
|
+ const result: StepResult = { terrain: [], removedChunks: [], crushed: [], fx: [] };
|
|
|
for (const p of this.players.values()) this.updatePlayer(p);
|
|
for (const p of this.players.values()) this.updatePlayer(p);
|
|
|
|
|
|
|
|
const pending = this.pendingOps;
|
|
const pending = this.pendingOps;
|
|
|
this.pendingOps = [];
|
|
this.pendingOps = [];
|
|
|
- for (const { op, 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 (onApplied) {
|
|
|
|
|
- let removed = 0;
|
|
|
|
|
- for (const n of removedByMaterial.values()) removed += n;
|
|
|
|
|
- onApplied(removed);
|
|
|
|
|
- }
|
|
|
|
|
- if (changed) for (const component of this.finder.find(changed)) this.detach(component, result);
|
|
|
|
|
|
|
+ let removed = 0;
|
|
|
|
|
+ for (const n of removedByMaterial.values()) removed += n;
|
|
|
|
|
+ this.stats.add(owner, 'eaten', removed);
|
|
|
|
|
+ onApplied?.(removed);
|
|
|
|
|
+ if (changed) for (const component of this.finder.find(changed)) this.detach(component, owner, result);
|
|
|
}
|
|
}
|
|
|
this.syncTerrain(result);
|
|
this.syncTerrain(result);
|
|
|
|
|
|
|
@@ -208,23 +329,74 @@ export class Game {
|
|
|
this.world.step(TICK_DT, 8, 3);
|
|
this.world.step(TICK_DT, 8, 3);
|
|
|
for (const id of this.crushedThisStep) {
|
|
for (const id of this.crushedThisStep) {
|
|
|
const p = this.players.get(id);
|
|
const p = this.players.get(id);
|
|
|
- if (!p) continue;
|
|
|
|
|
|
|
+ if (!p || p.dead > 0) continue;
|
|
|
const pos = p.body.getPosition();
|
|
const pos = p.body.getPosition();
|
|
|
result.crushed.push({ id, x: pos.x * PPM, y: pos.y * PPM });
|
|
result.crushed.push({ id, x: pos.x * PPM, y: pos.y * PPM });
|
|
|
- this.respawn(p);
|
|
|
|
|
|
|
+ this.kill(p, 'crushed');
|
|
|
}
|
|
}
|
|
|
for (const p of this.players.values()) this.postStep(p);
|
|
for (const p of this.players.values()) this.postStep(p);
|
|
|
|
|
+ this.updateRound();
|
|
|
|
|
+ this.updateCherry();
|
|
|
|
|
+ this.updateItems();
|
|
|
this.updateChunks(result);
|
|
this.updateChunks(result);
|
|
|
this.syncTerrain(result);
|
|
this.syncTerrain(result);
|
|
|
|
|
+ result.fx = this.fx;
|
|
|
|
|
+ this.fx = [];
|
|
|
this.tick++;
|
|
this.tick++;
|
|
|
return result;
|
|
return result;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+ snapshot(): PlayerState[] {
|
|
|
|
|
+ return [...this.players.values()].map((p) => {
|
|
|
|
|
+ const pos = p.body.getPosition();
|
|
|
|
|
+ const vel = p.body.getLinearVelocity();
|
|
|
|
|
+ return {
|
|
|
|
|
+ id: p.info.id,
|
|
|
|
|
+ x: pos.x * PPM,
|
|
|
|
|
+ y: pos.y * PPM,
|
|
|
|
|
+ vx: vel.x * PPM,
|
|
|
|
|
+ vy: vel.y * PPM,
|
|
|
|
|
+ facing: p.facing,
|
|
|
|
|
+ grounded: p.grounded,
|
|
|
|
|
+ size: p.character.size,
|
|
|
|
|
+ anim:
|
|
|
|
|
+ p.character.anim() | (p.stun > 0 ? Anim.Stunned : 0) | (p.dead > 0 ? Anim.Dead : 0) | (p.boost > 0 ? Anim.Boosted : 0),
|
|
|
|
|
+ score: Math.floor(p.score),
|
|
|
|
|
+ item: p.item,
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ itemStates(): ItemState[] {
|
|
|
|
|
+ return [...this.items.values()].map((it) => {
|
|
|
|
|
+ const pos = it.body.getPosition();
|
|
|
|
|
+ return { id: it.id, kind: it.kind, x: pos.x * PPM, y: pos.y * PPM, angle: it.body.getAngle(), lit: it.thrown !== null };
|
|
|
|
|
+ });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ roundState(): RoundState {
|
|
|
|
|
+ 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 };
|
|
|
|
|
+ }
|
|
|
|
|
+ return {
|
|
|
|
|
+ custardY: this.custardY,
|
|
|
|
|
+ timeLeft: this.rules ? Math.max(0, this.timeLeft) : null,
|
|
|
|
|
+ finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
|
|
|
|
|
+ cherry,
|
|
|
|
|
+ };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------------------------------------------------------------- terrain
|
|
|
|
|
+
|
|
|
/** Rebuilds colliders after terrain changes and wakes every body that may have lost its support. */
|
|
/** Rebuilds colliders after terrain changes and wakes every body that may have lost its support. */
|
|
|
private syncTerrain(result: StepResult): void {
|
|
private syncTerrain(result: StepResult): void {
|
|
|
if (this.colliders.rebuildDirty() === 0 && result.terrain.length === 0) return;
|
|
if (this.colliders.rebuildDirty() === 0 && result.terrain.length === 0) return;
|
|
|
for (const p of this.players.values()) p.body.setAwake(true);
|
|
for (const p of this.players.values()) p.body.setAwake(true);
|
|
|
for (const c of this.chunks.values()) c.body.setAwake(true);
|
|
for (const c of this.chunks.values()) c.body.setAwake(true);
|
|
|
|
|
+ for (const it of this.items.values()) it.body.setAwake(true);
|
|
|
|
|
+ this.cherry?.body.setAwake(true);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Applies a terrain event and logs it if it changed anything. */
|
|
/** Applies a terrain event and logs it if it changed anything. */
|
|
@@ -241,20 +413,24 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Removes an unanchored component from the bitmap; big ones become falling chunks, small ones crumble. */
|
|
/** Removes an unanchored component from the bitmap; big ones become falling chunks, small ones crumble. */
|
|
|
- private detach(c: DetachedComponent, result: StepResult): void {
|
|
|
|
|
|
|
+ private detach(c: DetachedComponent, owner: number, result: StepResult): void {
|
|
|
const { x0, y0, x1, y1 } = c.bounds;
|
|
const { x0, y0, x1, y1 } = c.bounds;
|
|
|
const w = x1 - x0;
|
|
const w = x1 - x0;
|
|
|
const h = y1 - y0;
|
|
const h = y1 - y0;
|
|
|
const debris = c.pixelCount < MIN_CHUNK_PIXELS;
|
|
const debris = c.pixelCount < MIN_CHUNK_PIXELS;
|
|
|
const id = this.nextChunkId++;
|
|
const id = this.nextChunkId++;
|
|
|
this.emit({ kind: 'detach', id, x: x0, y: y0, w, h, mask: rleEncode(c.mask), debris }, result);
|
|
this.emit({ kind: 'detach', id, x: x0, y: y0, w, h, mask: rleEncode(c.mask), debris }, result);
|
|
|
- if (!debris) this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials));
|
|
|
|
|
|
|
+ if (debris) return;
|
|
|
|
|
+ this.chunks.set(id, new FallingChunk(this.world, id, c.mask, w, h, x0, y0, this.materials));
|
|
|
|
|
+ this.stats.add(owner, 'detached');
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private updateChunks(result: StepResult): void {
|
|
private updateChunks(result: StepResult): void {
|
|
|
for (const chunk of [...this.chunks.values()]) {
|
|
for (const chunk of [...this.chunks.values()]) {
|
|
|
- const pos = chunk.body.getPosition();
|
|
|
|
|
- if (pos.y * PPM > this.terrain.height + OUT_OF_MAP_MARGIN) {
|
|
|
|
|
|
|
+ const center = chunk.body.getWorldCenter();
|
|
|
|
|
+ if (center.y * PPM > Math.min(this.custardY + CUSTARD_SINK_PX * 4, this.terrain.height + OUT_OF_MAP_MARGIN)) {
|
|
|
|
|
+ // Swallowed by the custard (readme §5).
|
|
|
|
|
+ if (center.y * PPM <= this.terrain.height + OUT_OF_MAP_MARGIN) this.fx.push({ kind: 'splash', id: 0, x: center.x * PPM, y: this.custardY });
|
|
|
this.removeChunk(chunk);
|
|
this.removeChunk(chunk);
|
|
|
result.removedChunks.push(chunk.id);
|
|
result.removedChunks.push(chunk.id);
|
|
|
continue;
|
|
continue;
|
|
@@ -323,23 +499,7 @@ export class Game {
|
|
|
if (total > CRUSH_IMPULSE) this.crushedThisStep.add(playerId);
|
|
if (total > CRUSH_IMPULSE) this.crushedThisStep.add(playerId);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- snapshot(): PlayerState[] {
|
|
|
|
|
- return [...this.players.values()].map((p) => {
|
|
|
|
|
- const pos = p.body.getPosition();
|
|
|
|
|
- const vel = p.body.getLinearVelocity();
|
|
|
|
|
- return {
|
|
|
|
|
- id: p.info.id,
|
|
|
|
|
- x: pos.x * PPM,
|
|
|
|
|
- y: pos.y * PPM,
|
|
|
|
|
- vx: vel.x * PPM,
|
|
|
|
|
- vy: vel.y * PPM,
|
|
|
|
|
- facing: p.facing,
|
|
|
|
|
- grounded: p.grounded,
|
|
|
|
|
- size: p.character.size,
|
|
|
|
|
- anim: p.character.anim() | (p.stun > 0 ? Anim.Stunned : 0),
|
|
|
|
|
- };
|
|
|
|
|
- });
|
|
|
|
|
- }
|
|
|
|
|
|
|
+ // ---------------------------------------------------------------- players
|
|
|
|
|
|
|
|
private createBodyFixture(body: Body, radius: number, mass: number, id: number): Fixture {
|
|
private createBodyFixture(body: Body, radius: number, mass: number, id: number): Fixture {
|
|
|
const tag: FixtureTag = { player: id };
|
|
const tag: FixtureTag = { player: id };
|
|
@@ -354,8 +514,15 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private updatePlayer(p: ServerPlayer): void {
|
|
private updatePlayer(p: ServerPlayer): void {
|
|
|
|
|
+ if (p.dead > 0) {
|
|
|
|
|
+ p.dead -= TICK_DT;
|
|
|
|
|
+ if (p.dead <= 0) this.spoonLaunch(p);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
const { stats } = p.character;
|
|
const { stats } = p.character;
|
|
|
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.flight = Math.max(0, p.flight - TICK_DT);
|
|
|
// Stunned players keep their momentum but ignore input.
|
|
// Stunned players keep their momentum but ignore input.
|
|
|
const input = p.stun > 0 ? { ...p.input, buttons: 0 } : p.input;
|
|
const input = p.stun > 0 ? { ...p.input, buttons: 0 } : p.input;
|
|
|
const pressed = input.buttons & ~p.prevButtons;
|
|
const pressed = input.buttons & ~p.prevButtons;
|
|
@@ -366,12 +533,13 @@ 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;
|
|
|
|
|
|
|
|
if (p.stun === 0) {
|
|
if (p.stun === 0) {
|
|
|
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 * (p.grounded ? 1 : AIR_CONTROL) * TICK_DT;
|
|
|
|
|
- const target = dir * stats.speed * mobility;
|
|
|
|
|
- vx = vx < target ? Math.min(target, vx + accel) : Math.max(target, vx - accel);
|
|
|
|
|
|
|
+ const accel = stats.acceleration * speedMult * (p.grounded ? 1 : AIR_CONTROL) * TICK_DT;
|
|
|
|
|
+ const target = dir * stats.speed * mobility * speedMult;
|
|
|
|
|
+ if (dir !== 0 || p.flight === 0) vx = vx < target ? Math.min(target, vx + accel) : Math.max(target, vx - accel);
|
|
|
p.facing = dir !== 0 ? dir : input.aimX >= p.body.getPosition().x * PPM ? 1 : -1;
|
|
p.facing = dir !== 0 ? dir : input.aimX >= p.body.getPosition().x * PPM ? 1 : -1;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -385,6 +553,8 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
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);
|
|
|
|
|
+
|
|
|
p.attackCooldown = Math.max(0, p.attackCooldown - TICK_DT);
|
|
p.attackCooldown = Math.max(0, p.attackCooldown - TICK_DT);
|
|
|
p.specialCooldown = Math.max(0, p.specialCooldown - TICK_DT);
|
|
p.specialCooldown = Math.max(0, p.specialCooldown - TICK_DT);
|
|
|
const ctx = this.contextFor(p, input);
|
|
const ctx = this.contextFor(p, input);
|
|
@@ -400,6 +570,7 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private postStep(p: ServerPlayer): void {
|
|
private postStep(p: ServerPlayer): void {
|
|
|
|
|
+ if (p.dead > 0) return;
|
|
|
p.grounded = false;
|
|
p.grounded = false;
|
|
|
for (let edge = p.body.getContactList(); edge; edge = edge.next) {
|
|
for (let edge = p.body.getContactList(); edge; edge = edge.next) {
|
|
|
const contact = edge.contact;
|
|
const contact = edge.contact;
|
|
@@ -414,32 +585,96 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|
|
|
p.sinceGrounded = p.grounded ? 0 : p.sinceGrounded + TICK_DT;
|
|
p.sinceGrounded = p.grounded ? 0 : p.sinceGrounded + TICK_DT;
|
|
|
- if (p.body.getPosition().y * PPM > this.terrain.height + OUT_OF_MAP_MARGIN) this.respawn(p);
|
|
|
|
|
|
|
+ // Landing ends the spoon flight; brushing an edge on the way up does not.
|
|
|
|
|
+ if (p.grounded && p.body.getLinearVelocity().y >= 0) p.flight = 0;
|
|
|
|
|
+ const y = p.body.getPosition().y * PPM;
|
|
|
|
|
+ if (y > this.custardY + CUSTARD_SINK_PX || y > this.terrain.height + OUT_OF_MAP_MARGIN) {
|
|
|
|
|
+ this.fx.push({ kind: 'splash', id: p.info.id, x: p.body.getPosition().x * PPM, y: this.custardY });
|
|
|
|
|
+ this.kill(p, 'custard');
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private respawn(p: ServerPlayer): void {
|
|
|
|
|
|
|
+ /** Out of the round for RESPAWN_DELAY_S: falls in the custard or crushed (readme §4). */
|
|
|
|
|
+ private kill(p: ServerPlayer, cause: 'custard' | 'crushed'): void {
|
|
|
|
|
+ if (this.cherry?.holder === p.info.id) this.dropCherry(p);
|
|
|
|
|
+ this.stats.add(p.info.id, cause);
|
|
|
|
|
+ p.dead = RESPAWN_DELAY_S;
|
|
|
|
|
+ p.stun = 0;
|
|
|
|
|
+ p.item = null;
|
|
|
|
|
+ p.body.setLinearVelocity({ x: 0, y: 0 });
|
|
|
|
|
+ p.body.setActive(false);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private placeAtSpawn(p: ServerPlayer): void {
|
|
|
const [x, y] = this.map.spawns[this.nextSpawn++ % this.map.spawns.length];
|
|
const [x, y] = this.map.spawns[this.nextSpawn++ % this.map.spawns.length];
|
|
|
p.body.setPosition({ x: x / PPM, y: y / PPM });
|
|
p.body.setPosition({ x: x / PPM, y: y / PPM });
|
|
|
p.body.setLinearVelocity({ x: 0, y: 0 });
|
|
p.body.setLinearVelocity({ x: 0, y: 0 });
|
|
|
p.stun = 0;
|
|
p.stun = 0;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- private hit(attacker: ServerPlayer, x: number, y: number, r: number, knockback: number, stun: number): number {
|
|
|
|
|
|
|
+ /**
|
|
|
|
|
+ * 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.
|
|
|
|
|
+ */
|
|
|
|
|
+ private spoonLaunch(p: ServerPlayer): void {
|
|
|
|
|
+ p.dead = 0;
|
|
|
|
|
+ p.body.setActive(true);
|
|
|
|
|
+ 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) {
|
|
|
|
|
+ if (bestX >= 0 && Math.abs(x - sx) >= Math.abs(bestX - sx)) continue;
|
|
|
|
|
+ if (this.columnIsFree(x, p.bodyRadius, top, surface)) bestX = x;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (bestX < 0) {
|
|
|
|
|
+ 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.
|
|
|
|
|
+ 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 });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private columnIsFree(x: number, radius: number, top: number, bottom: number): boolean {
|
|
|
|
|
+ for (let y = Math.floor(top); y <= bottom; y += 2) {
|
|
|
|
|
+ // Keep a margin around the body so the launch does not scrape a ledge.
|
|
|
|
|
+ for (const dx of [-radius - SPOON_CLEARANCE, -radius / 2, 0, radius / 2, radius + SPOON_CLEARANCE]) {
|
|
|
|
|
+ if (this.terrain.isSolid(Math.round(x + dx), y)) return false;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ return true;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Knocks back and stuns players touching the disc. `attacker` is spared (null = everyone is hit, e.g. explosions). */
|
|
|
|
|
+ private hitArea(attacker: ServerPlayer | null, x: number, y: number, r: number, knockback: number, stun: number): number {
|
|
|
let count = 0;
|
|
let count = 0;
|
|
|
for (const target of this.players.values()) {
|
|
for (const target of this.players.values()) {
|
|
|
- if (target === attacker) continue;
|
|
|
|
|
|
|
+ if (target === attacker || target.dead > 0) continue;
|
|
|
const pos = target.body.getPosition();
|
|
const pos = target.body.getPosition();
|
|
|
const dx = pos.x * PPM - x;
|
|
const dx = pos.x * PPM - x;
|
|
|
const dy = pos.y * PPM - y;
|
|
const dy = pos.y * PPM - y;
|
|
|
const dist = Math.hypot(dx, dy);
|
|
const dist = Math.hypot(dx, dy);
|
|
|
if (dist > r + target.bodyRadius) continue;
|
|
if (dist > r + target.bodyRadius) continue;
|
|
|
// Push away from the hit point, always with some lift so the target pops up.
|
|
// Push away from the hit point, always with some lift so the target pops up.
|
|
|
- const nx = dist > 0 ? dx / dist : attacker.facing;
|
|
|
|
|
|
|
+ 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 });
|
|
target.body.setLinearVelocity({ x: ((nx / len) * knockback) / PPM, y: ((ny / len) * knockback) / 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).
|
|
|
|
|
+ if (this.cherry?.holder === target.info.id) this.dropCherry(target);
|
|
|
|
|
+ this.stats.add(target.info.id, 'hitTaken');
|
|
|
|
|
+ if (attacker) this.stats.add(attacker.info.id, 'hits');
|
|
|
count++;
|
|
count++;
|
|
|
}
|
|
}
|
|
|
return count;
|
|
return count;
|
|
@@ -456,10 +691,202 @@ export class Game {
|
|
|
grounded: p.grounded,
|
|
grounded: p.grounded,
|
|
|
facing: p.facing,
|
|
facing: p.facing,
|
|
|
input,
|
|
input,
|
|
|
- emitOp: (op, onApplied) => this.queueOp(op, onApplied),
|
|
|
|
|
|
|
+ emitOp: (op, onApplied) => this.queueOp(op, onApplied, p.info.id),
|
|
|
setVelocity: (vx, vy) => p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM }),
|
|
setVelocity: (vx, vy) => p.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM }),
|
|
|
setSize: (radius) => this.resize(p, radius),
|
|
setSize: (radius) => this.resize(p, radius),
|
|
|
- hit: (x, y, r, knockback, stun) => this.hit(p, x, y, r, knockback, stun),
|
|
|
|
|
|
|
+ hit: (x, y, r, knockback, stun) => this.hitArea(p, x, y, r, knockback, stun),
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+ private alivePlayers(): ServerPlayer[] {
|
|
|
|
|
+ return [...this.players.values()].filter((p) => p.dead === 0);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------------------------------------------------------------- round
|
|
|
|
|
+
|
|
|
|
|
+ private updateRound(): void {
|
|
|
|
|
+ if (!this.rules) return;
|
|
|
|
|
+ this.timeLeft -= TICK_DT;
|
|
|
|
|
+ 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);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ 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 });
|
|
|
|
|
+ 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 };
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private updateCherry(): void {
|
|
|
|
|
+ const cherry = this.cherry;
|
|
|
|
|
+ if (!cherry) return;
|
|
|
|
|
+ cherry.blockedFor = Math.max(0, cherry.blockedFor - 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.
|
|
|
|
|
+ const pos = holder.body.getPosition();
|
|
|
|
|
+ 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);
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ cherry.holder = 0;
|
|
|
|
|
+ const pos = cherry.body.getPosition();
|
|
|
|
|
+ if (pos.y * PPM > this.custardY + CUSTARD_SINK_PX || pos.y * PPM > this.terrain.height + OUT_OF_MAP_MARGIN) {
|
|
|
|
|
+ this.fx.push({ kind: 'splash', id: 0, x: pos.x * PPM, y: this.custardY });
|
|
|
|
|
+ const [x, y] = this.map.cherrySpawn;
|
|
|
|
|
+ cherry.body.setPosition({ x: x / PPM, y: y / PPM });
|
|
|
|
|
+ cherry.body.setLinearVelocity({ x: 0, y: 0 });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ 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;
|
|
|
|
|
+ cherry.holder = p.info.id;
|
|
|
|
|
+ cherry.body.setActive(false);
|
|
|
|
|
+ this.fx.push({ kind: 'pickup', id: p.info.id, item: 'cherry', x: pos.x * PPM, y: pos.y * PPM });
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** The holder lets the cherry go: it pops out and rolls (readme §4). */
|
|
|
|
|
+ private dropCherry(p: ServerPlayer): void {
|
|
|
|
|
+ const cherry = this.cherry;
|
|
|
|
|
+ if (!cherry || cherry.holder !== p.info.id) return;
|
|
|
|
|
+ const pos = p.body.getPosition();
|
|
|
|
|
+ cherry.holder = 0;
|
|
|
|
|
+ cherry.blocked = p.info.id;
|
|
|
|
|
+ cherry.blockedFor = CHERRY_REGRAB_DELAY;
|
|
|
|
|
+ cherry.body.setActive(true);
|
|
|
|
|
+ cherry.body.setPosition({ x: pos.x, y: pos.y - (p.character.size + CHERRY_HOLD_OFFSET) / PPM });
|
|
|
|
|
+ const angle = -Math.PI / 2 + this.rng.range(-0.8, 0.8);
|
|
|
|
|
+ cherry.body.setLinearVelocity({ x: (Math.cos(angle) * CHERRY_DROP_SPEED) / PPM, y: (Math.sin(angle) * CHERRY_DROP_SPEED) / PPM });
|
|
|
|
|
+ cherry.body.setAwake(true);
|
|
|
|
|
+ this.fx.push({ kind: 'cherryDrop', id: p.info.id, x: pos.x * PPM, y: pos.y * PPM });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---------------------------------------------------------------- items
|
|
|
|
|
+
|
|
|
|
|
+ private nextItemDelay(): number {
|
|
|
|
|
+ const [a, b] = this.map.items.interval;
|
|
|
|
|
+ return this.rng.range(a, b);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private updateItems(): void {
|
|
|
|
|
+ if (this.rules) {
|
|
|
|
|
+ this.itemTimer -= TICK_DT;
|
|
|
|
|
+ if (this.itemTimer <= 0) {
|
|
|
|
|
+ this.itemTimer = this.nextItemDelay();
|
|
|
|
|
+ const weights: Partial<Record<ItemKind, number>> = {};
|
|
|
|
|
+ for (const k of ITEM_KINDS) if ((this.map.items.weights[k] ?? 0) > 0) weights[k] = this.map.items.weights[k];
|
|
|
|
|
+ if (Object.keys(weights).length > 0) {
|
|
|
|
|
+ const kind = this.rng.weighted(weights as Record<ItemKind, number>);
|
|
|
|
|
+ this.spawnItem(kind, this.rng.int(80, this.terrain.width - 80), -ITEM_RADIUS * 2);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ for (const item of [...this.items.values()]) {
|
|
|
|
|
+ const pos = item.body.getPosition();
|
|
|
|
|
+ const x = pos.x * PPM;
|
|
|
|
|
+ const y = pos.y * PPM;
|
|
|
|
|
+ if (y > this.custardY + CUSTARD_SINK_PX || y > this.terrain.height + OUT_OF_MAP_MARGIN) {
|
|
|
|
|
+ this.fx.push({ kind: 'splash', id: 0, x, y: this.custardY });
|
|
|
|
|
+ this.removeItem(item);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (item.thrown) {
|
|
|
|
|
+ item.thrown.fuse -= TICK_DT;
|
|
|
|
|
+ const armed = CANDLE_FUSE_S - item.thrown.fuse >= CANDLE_ARM_S;
|
|
|
|
|
+ if (item.thrown.fuse <= 0 || (armed && this.touchesAnything(item.body))) this.explode(item, x, y);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ item.age += TICK_DT;
|
|
|
|
|
+ if (item.age > ITEM_LIFETIME_S) {
|
|
|
|
|
+ this.removeItem(item);
|
|
|
|
|
+ continue;
|
|
|
|
|
+ }
|
|
|
|
|
+ for (const p of this.alivePlayers()) {
|
|
|
|
|
+ const pp = p.body.getPosition();
|
|
|
|
|
+ if (Math.hypot(pp.x * PPM - x, pp.y * PPM - y) > p.bodyRadius + ITEM_RADIUS + 4) continue;
|
|
|
|
|
+ if (item.kind === 'candle' && p.item !== null) continue; // one candle at a time
|
|
|
|
|
+ this.pickUp(p, item, x, y);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
|
|
|
|
|
+ this.removeItem(item);
|
|
|
|
|
+ this.fx.push({ kind: 'pickup', id: p.info.id, item: item.kind, x, y });
|
|
|
|
|
+ if (item.kind === 'candle') p.item = 'candle';
|
|
|
|
|
+ else if (item.kind === 'smarties') p.boost = SMARTIES_DURATION_S;
|
|
|
|
|
+ else this.whisk(p);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private removeItem(item: Item): void {
|
|
|
|
|
+ this.world.destroyBody(item.body);
|
|
|
|
|
+ this.items.delete(item.id);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private touchesAnything(body: Body): boolean {
|
|
|
|
|
+ for (let edge = body.getContactList(); edge; edge = edge.next) if (edge.contact.isTouching()) return true;
|
|
|
|
|
+ return false;
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private throwCandle(p: ServerPlayer, input: InputState): void {
|
|
|
|
|
+ p.item = null;
|
|
|
|
|
+ const pos = p.body.getPosition();
|
|
|
|
|
+ const px = pos.x * PPM;
|
|
|
|
|
+ const py = pos.y * PPM;
|
|
|
|
|
+ let dx = input.aimX - px;
|
|
|
|
|
+ let dy = input.aimY - py;
|
|
|
|
|
+ const len = Math.hypot(dx, dy) || 1;
|
|
|
|
|
+ dx /= len;
|
|
|
|
|
+ dy /= len;
|
|
|
|
|
+ const start = p.bodyRadius + ITEM_RADIUS + 2;
|
|
|
|
|
+ const id = this.spawnItem('candle', px + dx * start, py + dy * start);
|
|
|
|
|
+ const item = this.items.get(id)!;
|
|
|
|
|
+ item.thrown = { owner: p.info.id, fuse: CANDLE_FUSE_S };
|
|
|
|
|
+ const v = p.body.getLinearVelocity();
|
|
|
|
|
+ item.body.setLinearVelocity({ x: v.x + (dx * CANDLE_THROW_SPEED) / PPM, y: v.y + (dy * CANDLE_THROW_SPEED) / PPM });
|
|
|
|
|
+ item.body.setAngularVelocity(dx >= 0 ? 12 : -12);
|
|
|
|
|
+ this.stats.add(p.info.id, 'candles');
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Candle explosion: carves the cake (chocolate resists) and blows everyone away, thrower included. */
|
|
|
|
|
+ private explode(item: Item, x: number, y: number): void {
|
|
|
|
|
+ const owner = item.thrown?.owner ?? 0;
|
|
|
|
|
+ this.removeItem(item);
|
|
|
|
|
+ this.queueOp({ kind: 'carve', x: Math.round(x), y: Math.round(y), r: CANDLE_BLAST_RADIUS, power: CANDLE_BLAST_POWER }, undefined, owner);
|
|
|
|
|
+ const attacker = this.players.get(owner);
|
|
|
|
|
+ const hits = this.hitArea(null, x, y, CANDLE_BLAST_RADIUS + 10, CANDLE_KNOCKBACK, CANDLE_STUN_S);
|
|
|
|
|
+ if (attacker) this.stats.add(owner, 'hits', hits);
|
|
|
|
|
+ this.fx.push({ kind: 'explosion', x, y, r: CANDLE_BLAST_RADIUS });
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Electric whisk: jolts everyone else and shakes jelly loose, so weakened blocks come off (readme §4). */
|
|
|
|
|
+ private whisk(p: ServerPlayer): void {
|
|
|
|
|
+ this.fx.push({ kind: 'whisk', id: p.info.id });
|
|
|
|
|
+ for (const other of this.alivePlayers()) {
|
|
|
|
|
+ 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);
|
|
|
|
|
+ if (this.cherry?.holder === other.info.id) this.dropCherry(other);
|
|
|
|
|
+ }
|
|
|
|
|
+ const { width, height, data } = this.terrain;
|
|
|
|
|
+ for (let n = 0; n < WHISK_CRACKS; n++) {
|
|
|
|
|
+ for (let tries = 0; tries < 40; tries++) {
|
|
|
|
|
+ const x = this.rng.int(0, width - 1);
|
|
|
|
|
+ const y = this.rng.int(0, height - 1);
|
|
|
|
|
+ if (data[y * width + x] !== JELLY) continue;
|
|
|
|
|
+ this.queueOp({ kind: 'carve', x, y, r: WHISK_CRACK_RADIUS, power: WHISK_CRACK_POWER }, undefined, p.info.id);
|
|
|
|
|
+ break;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|