|
@@ -1,5 +1,13 @@
|
|
|
import { Circle, World, type Body, type Contact, type ContactImpulse, type Fixture } from 'planck';
|
|
import { Circle, World, type Body, type Contact, type ContactImpulse, type Fixture } from 'planck';
|
|
|
-import { Anim, createCharacter, type Character, type CharacterContext, type CharacterStats } from '../shared/characters';
|
|
|
|
|
|
|
+import {
|
|
|
|
|
+ Anim,
|
|
|
|
|
+ createCharacter,
|
|
|
|
|
+ type Blast,
|
|
|
|
|
+ type Character,
|
|
|
|
|
+ type CharacterContext,
|
|
|
|
|
+ type CharacterStats,
|
|
|
|
|
+ type ProjectileSpec,
|
|
|
|
|
+} from '../shared/characters';
|
|
|
import {
|
|
import {
|
|
|
AIR_CONTROL,
|
|
AIR_CONTROL,
|
|
|
CANDLE_ARM_S,
|
|
CANDLE_ARM_S,
|
|
@@ -29,9 +37,11 @@ import {
|
|
|
ITEM_RADIUS,
|
|
ITEM_RADIUS,
|
|
|
JUMP_BUFFER_TIME,
|
|
JUMP_BUFFER_TIME,
|
|
|
JUMP_CUT,
|
|
JUMP_CUT,
|
|
|
|
|
+ MATERIAL_WOOL,
|
|
|
MIN_CHUNK_PIXELS,
|
|
MIN_CHUNK_PIXELS,
|
|
|
OUT_OF_MAP_MARGIN,
|
|
OUT_OF_MAP_MARGIN,
|
|
|
PIXELS_PER_METER,
|
|
PIXELS_PER_METER,
|
|
|
|
|
+ PROJECTILE_ARM_S,
|
|
|
RESIZE_THRESHOLD_PX,
|
|
RESIZE_THRESHOLD_PX,
|
|
|
RESPAWN_DELAY_S,
|
|
RESPAWN_DELAY_S,
|
|
|
SMARTIES_DURATION_S,
|
|
SMARTIES_DURATION_S,
|
|
@@ -43,11 +53,12 @@ import {
|
|
|
WHISK_CRACKS,
|
|
WHISK_CRACKS,
|
|
|
WHISK_JOLT,
|
|
WHISK_JOLT,
|
|
|
WHISK_STUN_S,
|
|
WHISK_STUN_S,
|
|
|
|
|
+ WOOL_BURN_RADIUS_FACTOR,
|
|
|
} 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, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState } from '../shared/protocol';
|
|
|
|
|
|
|
+import type { ChunkInfo, ChunkState, EntityKind, 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';
|
|
@@ -104,15 +115,33 @@ interface PendingOp {
|
|
|
onApplied?: (removed: number) => void;
|
|
onApplied?: (removed: number) => void;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+/** A lit candle, or a character's projectile. */
|
|
|
|
|
+interface Thrown {
|
|
|
|
|
+ owner: number;
|
|
|
|
|
+ age: number;
|
|
|
|
|
+ /** Candle and firecracker: seconds before it blows up on its own. */
|
|
|
|
|
+ fuse: number;
|
|
|
|
|
+ arm: number;
|
|
|
|
|
+ /** Null for a thrown candle. */
|
|
|
|
|
+ spec: ProjectileSpec | null;
|
|
|
|
|
+ /** Yarn: where the wool thread was last laid. */
|
|
|
|
|
+ trailX: number;
|
|
|
|
|
+ trailY: number;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
interface Item {
|
|
interface Item {
|
|
|
id: number;
|
|
id: number;
|
|
|
- kind: ItemKind;
|
|
|
|
|
|
|
+ kind: EntityKind;
|
|
|
body: Body;
|
|
body: Body;
|
|
|
age: number;
|
|
age: number;
|
|
|
- /** Set once a candle is thrown. */
|
|
|
|
|
- thrown: { owner: number; fuse: number } | null;
|
|
|
|
|
|
|
+ /** Set once thrown: then it is a projectile, no longer something to pick up. */
|
|
|
|
|
+ thrown: Thrown | null;
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+const CANDLE_BLAST: Blast = { r: CANDLE_BLAST_RADIUS, power: CANDLE_BLAST_POWER, knockback: CANDLE_KNOCKBACK, stun: CANDLE_STUN_S };
|
|
|
|
|
+/** Spacing of the wool discs along a yarn thread (px). */
|
|
|
|
|
+const YARN_STEP = 4;
|
|
|
|
|
+
|
|
|
interface Cherry {
|
|
interface Cherry {
|
|
|
body: Body;
|
|
body: Body;
|
|
|
holder: number;
|
|
holder: number;
|
|
@@ -300,7 +329,7 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Spawns an item now (tests and debug). */
|
|
/** Spawns an item now (tests and debug). */
|
|
|
- spawnItem(kind: ItemKind, x: number, y: number): number {
|
|
|
|
|
|
|
+ spawnItem(kind: EntityKind, x: number, y: number): number {
|
|
|
const id = this.nextItemId++;
|
|
const id = this.nextItemId++;
|
|
|
const body = this.world.createBody({ type: 'dynamic', position: { x: x / PPM, y: y / PPM }, bullet: true, userData: { item: id } });
|
|
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 });
|
|
body.createFixture(new Circle(ITEM_RADIUS / PPM), { density: 0.6, friction: 0.6, restitution: 0.25, userData: { item: id } satisfies FixtureTag });
|
|
@@ -695,6 +724,8 @@ export class Game {
|
|
|
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.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),
|
|
|
|
|
+ explode: (x, y, blast, spareSelf) => this.explodeAt(x, y, blast, p.info.id, spareSelf ? p : null),
|
|
|
};
|
|
};
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -799,9 +830,7 @@ export class Game {
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
if (item.thrown) {
|
|
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);
|
|
|
|
|
|
|
+ this.updateThrown(item, item.thrown, x, y);
|
|
|
continue;
|
|
continue;
|
|
|
}
|
|
}
|
|
|
item.age += TICK_DT;
|
|
item.age += TICK_DT;
|
|
@@ -820,11 +849,12 @@ export class Game {
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
|
|
private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
|
|
|
|
|
+ if (item.kind === 'firecracker' || item.kind === 'yarn') return; // projectiles are not pickups
|
|
|
this.removeItem(item);
|
|
this.removeItem(item);
|
|
|
this.fx.push({ kind: 'pickup', id: p.info.id, item: item.kind, x, y });
|
|
this.fx.push({ kind: 'pickup', id: p.info.id, item: item.kind, x, y });
|
|
|
if (item.kind === 'candle') p.item = 'candle';
|
|
if (item.kind === 'candle') p.item = 'candle';
|
|
|
else if (item.kind === 'smarties') p.boost = SMARTIES_DURATION_S;
|
|
else if (item.kind === 'smarties') p.boost = SMARTIES_DURATION_S;
|
|
|
- else this.whisk(p);
|
|
|
|
|
|
|
+ else if (item.kind === 'whisk') this.whisk(p);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
private removeItem(item: Item): void {
|
|
private removeItem(item: Item): void {
|
|
@@ -848,24 +878,70 @@ export class Game {
|
|
|
dx /= len;
|
|
dx /= len;
|
|
|
dy /= len;
|
|
dy /= len;
|
|
|
const start = p.bodyRadius + ITEM_RADIUS + 2;
|
|
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();
|
|
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.throwEntity('candle', null, p.info.id, px + dx * start, py + dy * start, v.x * PPM + dx * CANDLE_THROW_SPEED, v.y * PPM + dy * CANDLE_THROW_SPEED);
|
|
|
this.stats.add(p.info.id, 'candles');
|
|
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 });
|
|
|
|
|
|
|
+ /** Spawns a projectile entity already in flight. */
|
|
|
|
|
+ private throwEntity(kind: EntityKind, spec: ProjectileSpec | null, owner: number, x: number, y: number, vx: number, vy: number): void {
|
|
|
|
|
+ const id = this.spawnItem(kind, x, y);
|
|
|
|
|
+ const item = this.items.get(id)!;
|
|
|
|
|
+ const fuse = spec?.kind === 'firecracker' ? spec.fuse : spec?.kind === 'yarn' ? Infinity : CANDLE_FUSE_S;
|
|
|
|
|
+ item.thrown = { owner, age: 0, fuse, arm: spec ? PROJECTILE_ARM_S : CANDLE_ARM_S, spec, trailX: x, trailY: y };
|
|
|
|
|
+ item.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
|
|
|
|
|
+ item.body.setAngularVelocity(vx >= 0 ? 12 : -12);
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ private updateThrown(item: Item, t: Thrown, x: number, y: number): void {
|
|
|
|
|
+ t.age += TICK_DT;
|
|
|
|
|
+ t.fuse -= TICK_DT;
|
|
|
|
|
+ const hit = t.age >= t.arm && this.touchesAnything(item.body);
|
|
|
|
|
+ const spec = t.spec;
|
|
|
|
|
+ if (spec?.kind === 'yarn') {
|
|
|
|
|
+ this.layThread(item, t, spec, x, y);
|
|
|
|
|
+ if (hit || t.age >= spec.life) {
|
|
|
|
|
+ // The ball sticks where it lands (or gives up mid-air): a blob of wool.
|
|
|
|
|
+ this.removeItem(item);
|
|
|
|
|
+ this.queueOp({ kind: 'fill', x: Math.round(x), y: Math.round(y), r: spec.blobRadius, material: spec.material }, undefined, t.owner);
|
|
|
|
|
+ }
|
|
|
|
|
+ return;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (t.fuse <= 0 || hit) {
|
|
|
|
|
+ this.removeItem(item);
|
|
|
|
|
+ this.explodeAt(x, y, spec?.kind === 'firecracker' ? spec.blast : CANDLE_BLAST, t.owner, null);
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /** Lays wool discs from the last thread point up to just behind the ball, so the ball never sits in its own wool. */
|
|
|
|
|
+ private layThread(item: Item, t: Thrown, spec: Extract<ProjectileSpec, { kind: 'yarn' }>, x: number, y: number): void {
|
|
|
|
|
+ const v = item.body.getLinearVelocity();
|
|
|
|
|
+ const speed = Math.hypot(v.x, v.y);
|
|
|
|
|
+ if (speed === 0) return;
|
|
|
|
|
+ const back = ITEM_RADIUS + spec.trailRadius + 3;
|
|
|
|
|
+ const tx = x - (v.x / speed) * back;
|
|
|
|
|
+ const ty = y - (v.y / speed) * back;
|
|
|
|
|
+ let dist = Math.hypot(tx - t.trailX, ty - t.trailY);
|
|
|
|
|
+ while (dist >= YARN_STEP) {
|
|
|
|
|
+ t.trailX += ((tx - t.trailX) / dist) * YARN_STEP;
|
|
|
|
|
+ t.trailY += ((ty - t.trailY) / dist) * YARN_STEP;
|
|
|
|
|
+ this.queueOp({ kind: 'fill', x: Math.round(t.trailX), y: Math.round(t.trailY), r: spec.trailRadius, material: spec.material }, undefined, t.owner);
|
|
|
|
|
+ dist -= YARN_STEP;
|
|
|
|
|
+ }
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ /**
|
|
|
|
|
+ * Every explosion: carves the cake (chocolate resists), burns the wool around it
|
|
|
|
|
+ * (Mamie's constructions burn, readme §4) and blows players away, `spare` excepted.
|
|
|
|
|
+ */
|
|
|
|
|
+ private explodeAt(x: number, y: number, blast: Blast, owner: number, spare: ServerPlayer | null): void {
|
|
|
|
|
+ const cx = Math.round(x);
|
|
|
|
|
+ const cy = Math.round(y);
|
|
|
|
|
+ this.queueOp({ kind: 'carve', x: cx, y: cy, r: Math.round(blast.r), power: Math.round(blast.power) }, undefined, owner);
|
|
|
|
|
+ this.queueOp({ kind: 'convert', x: cx, y: cy, r: Math.round(blast.r * WOOL_BURN_RADIUS_FACTOR), from: MATERIAL_WOOL, to: 0 }, undefined, owner);
|
|
|
|
|
+ const hits = this.hitArea(spare, x, y, blast.r + 10, blast.knockback, blast.stun);
|
|
|
|
|
+ this.stats.add(owner, 'hits', hits);
|
|
|
|
|
+ this.fx.push({ kind: 'explosion', x, y, r: blast.r });
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
/** Electric whisk: jolts everyone else and shakes jelly loose, so weakened blocks come off (readme §4). */
|
|
/** Electric whisk: jolts everyone else and shakes jelly loose, so weakened blocks come off (readme §4). */
|