|
|
@@ -7,6 +7,7 @@ import {
|
|
|
type CharacterContext,
|
|
|
type CharacterStats,
|
|
|
type ProjectileSpec,
|
|
|
+ type TurretSpec,
|
|
|
} from '../shared/characters';
|
|
|
import {
|
|
|
AIR_CONTROL,
|
|
|
@@ -82,7 +83,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 } from '../shared/protocol';
|
|
|
+import type { ChunkInfo, ChunkState, EntityKind, FxEvent, ItemKind, ItemState, PlayerInfo, PlayerState, RoundState, 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';
|
|
|
@@ -186,6 +187,32 @@ const YARN_PLAYER_MARGIN = 3;
|
|
|
/** Physics collision category of players (projectiles can opt out of hitting them). */
|
|
|
const CATEGORY_PLAYER = 0x0002;
|
|
|
|
|
|
+/** A turret built by Tatie Douille (see TurretSpec). */
|
|
|
+interface Turret {
|
|
|
+ id: number;
|
|
|
+ owner: number;
|
|
|
+ spec: TurretSpec;
|
|
|
+ /** Head position (px). */
|
|
|
+ x: number;
|
|
|
+ y: number;
|
|
|
+ /** A pixel inside the base: when it is no longer solid, the turret has lost its footing. */
|
|
|
+ baseX: number;
|
|
|
+ baseY: number;
|
|
|
+ angle: number;
|
|
|
+ age: number;
|
|
|
+ reload: number;
|
|
|
+}
|
|
|
+
|
|
|
+/** A turret's base is laid in this tick's ops: its footing is only checked after this delay (s). */
|
|
|
+const TURRET_SETTLE_S = 0.1;
|
|
|
+/** A turret with nothing to shoot at looks again this often (s). */
|
|
|
+const TURRET_SCAN_S = 0.2;
|
|
|
+/** Line of sight: sample spacing (px), and the part next to the head that is not checked (px). */
|
|
|
+const TURRET_SIGHT_STEP = 4;
|
|
|
+const TURRET_SIGHT_SKIP = 14;
|
|
|
+/** Dragées leave from the tip of the nozzle, clear of the turret's own base (px). */
|
|
|
+const TURRET_MUZZLE = 26;
|
|
|
+
|
|
|
interface Cherry {
|
|
|
body: Body;
|
|
|
holder: number;
|
|
|
@@ -264,6 +291,8 @@ export class Game {
|
|
|
/** Side wind this tick (px/s²). */
|
|
|
private wind = 0;
|
|
|
private clouds: { x: number; y: number; r: number; life: number; maxLife: number; owner: number }[] = [];
|
|
|
+ private turrets: Turret[] = [];
|
|
|
+ private nextTurretId = 1;
|
|
|
/** Recently soaked areas, which sag for a while (Bébé Pompier). */
|
|
|
private soakZones: { x: number; y: number; r: number; time: number; next: number }[] = [];
|
|
|
/** Seconds before the next count of the cake left. */
|
|
|
@@ -387,6 +416,7 @@ export class Game {
|
|
|
if (this.cherry?.holder === id) this.dropCherry(p);
|
|
|
this.world.destroyBody(p.body);
|
|
|
this.players.delete(id);
|
|
|
+ this.turrets = this.turrets.filter((t) => t.owner !== id);
|
|
|
}
|
|
|
|
|
|
/** Moves a player (debug tools and tests). */
|
|
|
@@ -465,6 +495,7 @@ export class Game {
|
|
|
this.updateCherry();
|
|
|
this.updateItems();
|
|
|
this.updateClouds();
|
|
|
+ this.updateTurrets();
|
|
|
this.updateSoakZones();
|
|
|
this.updateChunks(result);
|
|
|
this.syncTerrain(result);
|
|
|
@@ -522,6 +553,7 @@ export class Game {
|
|
|
finalRush: this.rules !== null && this.timeLeft <= FINAL_RUSH_S,
|
|
|
wind: this.wind,
|
|
|
clouds: this.clouds.map((c) => [c.x, c.y, c.r, c.life / c.maxLife]),
|
|
|
+ turrets: this.turrets.map((t): TurretState => ({ id: t.id, x: t.x, y: t.y, angle: t.angle, owner: t.owner })),
|
|
|
cherry,
|
|
|
};
|
|
|
}
|
|
|
@@ -997,6 +1029,7 @@ export class Game {
|
|
|
shove: (x, y, r, vx, vy, fraction) => this.shove(p, x, y, r, vx, vy, fraction),
|
|
|
drench: (x, y, r) => this.drench(p, x, y, r),
|
|
|
cloud: (x, y, r, life) => this.clouds.push({ x, y, r, life, maxLife: life, owner: p.info.id }),
|
|
|
+ buildTurret: (x, y, spec) => this.buildTurret(p, x, y, spec),
|
|
|
swapWithRandomPlayer: () => this.swapWithRandom(p),
|
|
|
cue: (cue) => {
|
|
|
const at = p.body.getPosition();
|
|
|
@@ -1005,6 +1038,117 @@ export class Game {
|
|
|
};
|
|
|
}
|
|
|
|
|
|
+ /**
|
|
|
+ * Builds a turret on the ground near (x, y): straight down from there, or up to the surface
|
|
|
+ * when (x, y) is inside the cake. Its base is a disc of terrain half sunk in the ground.
|
|
|
+ */
|
|
|
+ private buildTurret(by: ServerPlayer, x: number, y: number, spec: TurretSpec): boolean {
|
|
|
+ const gx = Math.round(x);
|
|
|
+ let gy = Math.round(y);
|
|
|
+ const solid = (py: number): boolean => this.terrain.isSolid(gx, py);
|
|
|
+ if (solid(gy)) {
|
|
|
+ // Inside the cake: up to its surface.
|
|
|
+ let up = 0;
|
|
|
+ while (up < spec.groundReach && solid(gy - 1)) {
|
|
|
+ gy--;
|
|
|
+ up++;
|
|
|
+ }
|
|
|
+ if (solid(gy - 1)) return false;
|
|
|
+ } else {
|
|
|
+ let down = 0;
|
|
|
+ while (down < spec.groundReach && !solid(gy)) {
|
|
|
+ gy++;
|
|
|
+ down++;
|
|
|
+ }
|
|
|
+ if (!solid(gy)) return false;
|
|
|
+ }
|
|
|
+ const headY = gy - spec.height;
|
|
|
+ // No turret inside a player.
|
|
|
+ for (const p of this.alivePlayers()) {
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ if (Math.hypot(pos.x * PPM - gx, pos.y * PPM - (gy + headY) / 2) < p.bodyRadius + spec.baseRadius) return false;
|
|
|
+ }
|
|
|
+ const mine = this.turrets.filter((t) => t.owner === by.info.id);
|
|
|
+ if (mine.length >= spec.max) this.turrets = this.turrets.filter((t) => t !== mine[0]);
|
|
|
+ this.queueOp({ kind: 'fill', x: gx, y: gy, r: Math.round(spec.baseRadius), material: Math.round(spec.material) }, undefined, by.info.id);
|
|
|
+ this.turrets.push({
|
|
|
+ id: this.nextTurretId++,
|
|
|
+ owner: by.info.id,
|
|
|
+ spec,
|
|
|
+ x: gx,
|
|
|
+ y: headY,
|
|
|
+ baseX: gx,
|
|
|
+ baseY: gy - Math.round(spec.baseRadius / 2),
|
|
|
+ angle: -Math.PI / 2,
|
|
|
+ age: 0,
|
|
|
+ reload: spec.interval / 2,
|
|
|
+ });
|
|
|
+ return true;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Turrets fall with the cake they stand on, wear out, and shoot sugared almonds at the
|
|
|
+ * nearest player they can see (never their builder).
|
|
|
+ */
|
|
|
+ private updateTurrets(): void {
|
|
|
+ if (this.turrets.length === 0) return;
|
|
|
+ const gone: Turret[] = [];
|
|
|
+ for (const t of this.turrets) {
|
|
|
+ t.age += TICK_DT;
|
|
|
+ if (t.age > t.spec.life || (t.age > TURRET_SETTLE_S && !this.terrain.isSolid(t.baseX, t.baseY))) {
|
|
|
+ gone.push(t);
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ t.reload -= TICK_DT;
|
|
|
+ if (t.reload > 0) continue;
|
|
|
+ const target = this.turretTarget(t);
|
|
|
+ if (!target) {
|
|
|
+ t.reload = TURRET_SCAN_S;
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ t.reload = t.spec.interval;
|
|
|
+ const pos = target.body.getPosition();
|
|
|
+ const dx = pos.x * PPM - t.x;
|
|
|
+ const dy = pos.y * PPM - t.y;
|
|
|
+ const dist = Math.hypot(dx, dy);
|
|
|
+ const speed = t.spec.speed;
|
|
|
+ // Aim a little high: the dragée drops by g·t²/2 on its way (t ≈ dist / speed).
|
|
|
+ const vx = (dx / dist) * speed;
|
|
|
+ const vy = (dy / dist) * speed - (GRAVITY_PX * dist) / speed / 2;
|
|
|
+ t.angle = Math.atan2(vy, vx);
|
|
|
+ const start = TURRET_MUZZLE;
|
|
|
+ this.throwEntity(t.spec.projectile.kind, t.spec.projectile, t.owner, t.x + Math.cos(t.angle) * start, t.y + Math.sin(t.angle) * start, vx, vy);
|
|
|
+ this.fx.push({ kind: 'cue', id: t.owner, cue: 'turret', x: t.x, y: t.y });
|
|
|
+ }
|
|
|
+ for (const t of gone) this.fx.push({ kind: 'cue', id: t.owner, cue: 'turretDown', x: t.x, y: t.y });
|
|
|
+ if (gone.length > 0) this.turrets = this.turrets.filter((t) => !gone.includes(t));
|
|
|
+ }
|
|
|
+
|
|
|
+ /** The nearest living player in range, other than its builder, in plain sight of the turret's head. */
|
|
|
+ private turretTarget(t: Turret): ServerPlayer | null {
|
|
|
+ let best: ServerPlayer | null = null;
|
|
|
+ let bestDist = Infinity;
|
|
|
+ for (const p of this.alivePlayers()) {
|
|
|
+ if (p.info.id === t.owner) continue;
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ const dx = pos.x * PPM - t.x;
|
|
|
+ const dy = pos.y * PPM - t.y;
|
|
|
+ const dist = Math.hypot(dx, dy);
|
|
|
+ if (dist > t.spec.range || dist >= bestDist) continue;
|
|
|
+ let clear = true;
|
|
|
+ for (let d = TURRET_SIGHT_SKIP; d < dist - p.bodyRadius; d += TURRET_SIGHT_STEP) {
|
|
|
+ if (this.terrain.isSolid(Math.round(t.x + (dx / dist) * d), Math.round(t.y + (dy / dist) * d))) {
|
|
|
+ clear = false;
|
|
|
+ break;
|
|
|
+ }
|
|
|
+ }
|
|
|
+ if (!clear) continue;
|
|
|
+ best = p;
|
|
|
+ bestDist = dist;
|
|
|
+ }
|
|
|
+ return best;
|
|
|
+ }
|
|
|
+
|
|
|
/** 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);
|
|
|
@@ -1256,7 +1400,7 @@ export class Game {
|
|
|
}
|
|
|
|
|
|
private pickUp(p: ServerPlayer, item: Item, x: number, y: number): void {
|
|
|
- if (item.kind === 'firecracker' || item.kind === 'yarn') return; // projectiles are not pickups
|
|
|
+ if (item.kind === 'firecracker' || item.kind === 'yarn' || item.kind === 'dragee') 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';
|
|
|
@@ -1294,7 +1438,7 @@ export class Game {
|
|
|
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;
|
|
|
+ const fuse = spec?.kind === 'yarn' ? Infinity : spec ? spec.fuse : CANDLE_FUSE_S;
|
|
|
const [anchorX, anchorY] = spec?.kind === 'yarn' ? this.yarnAnchor(owner, x, y) : [x, y];
|
|
|
item.thrown = { owner, age: 0, fuse, arm: spec ? PROJECTILE_ARM_S : CANDLE_ARM_S, spec, anchorX, anchorY };
|
|
|
item.body.setLinearVelocity({ x: vx / PPM, y: vy / PPM });
|
|
|
@@ -1318,7 +1462,7 @@ export class Game {
|
|
|
}
|
|
|
if (t.fuse <= 0 || hit) {
|
|
|
this.removeItem(item);
|
|
|
- this.explodeAt(x, y, spec?.kind === 'firecracker' ? spec.blast : CANDLE_BLAST, t.owner, null);
|
|
|
+ this.explodeAt(x, y, spec ? spec.blast : CANDLE_BLAST, t.owner, null);
|
|
|
}
|
|
|
}
|
|
|
|