|
|
@@ -126,9 +126,9 @@ interface Thrown {
|
|
|
arm: number;
|
|
|
/** Null for a thrown candle. */
|
|
|
spec: ProjectileSpec | null;
|
|
|
- /** Yarn: where the wool thread was last laid. */
|
|
|
- trailX: number;
|
|
|
- trailY: number;
|
|
|
+ /** Yarn: where the thread is tied (solid ground under the thrower, or the launch point). */
|
|
|
+ anchorX: number;
|
|
|
+ anchorY: number;
|
|
|
}
|
|
|
|
|
|
interface Item {
|
|
|
@@ -142,7 +142,13 @@ interface Item {
|
|
|
|
|
|
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;
|
|
|
+const YARN_STEP = 3;
|
|
|
+/** How far below the thrower's feet the thread looks for ground to tie to (px). */
|
|
|
+const YARN_ANCHOR_REACH = 14;
|
|
|
+/** Free space kept between a player's body and any wool laid by a yarn ball (px). */
|
|
|
+const YARN_PLAYER_MARGIN = 3;
|
|
|
+/** Physics collision category of players (projectiles can opt out of hitting them). */
|
|
|
+const CATEGORY_PLAYER = 0x0002;
|
|
|
|
|
|
interface Cherry {
|
|
|
body: Body;
|
|
|
@@ -349,7 +355,14 @@ 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), { 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,
|
|
|
+ // A ball of yarn flies through players: it must not stick to its thrower or wrap anyone.
|
|
|
+ filterMaskBits: kind === 'yarn' ? ~CATEGORY_PLAYER & 0xffff : 0xffff,
|
|
|
+ });
|
|
|
this.items.set(id, { id, kind, body, age: 0, thrown: null });
|
|
|
return id;
|
|
|
}
|
|
|
@@ -424,7 +437,10 @@ export class Game {
|
|
|
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 };
|
|
|
+ const state: ItemState = { id: it.id, kind: it.kind, x: pos.x * PPM, y: pos.y * PPM, angle: it.body.getAngle(), lit: it.thrown !== null };
|
|
|
+ // A ball of yarn in flight trails its thread back to the anchor.
|
|
|
+ if (it.kind === 'yarn' && it.thrown) state.thread = [it.thrown.anchorX, it.thrown.anchorY];
|
|
|
+ return state;
|
|
|
});
|
|
|
}
|
|
|
|
|
|
@@ -550,7 +566,7 @@ export class Game {
|
|
|
const chunkId = a?.chunk ?? b?.chunk;
|
|
|
if (playerId === undefined || chunkId === undefined) return;
|
|
|
const chunk = this.chunks.get(chunkId);
|
|
|
- if (!chunk || chunk.speed() < CRUSH_MIN_SPEED) return;
|
|
|
+ if (!chunk || !chunk.canCrush || chunk.speed() < CRUSH_MIN_SPEED) return;
|
|
|
let total = 0;
|
|
|
for (const n of impulse.normalImpulses) total += n;
|
|
|
if (total > CRUSH_IMPULSE) this.crushedThisStep.add(playerId);
|
|
|
@@ -560,7 +576,7 @@ export class Game {
|
|
|
|
|
|
private createBodyFixture(body: Body, radius: number, mass: number, id: number): Fixture {
|
|
|
const tag: FixtureTag = { player: id };
|
|
|
- return body.createFixture(new Circle(radius / PPM), { density: mass, friction: 0, restitution: 0, userData: tag });
|
|
|
+ return body.createFixture(new Circle(radius / PPM), { density: mass, friction: 0, restitution: 0, userData: tag, filterCategoryBits: CATEGORY_PLAYER });
|
|
|
}
|
|
|
|
|
|
private resize(p: ServerPlayer, radius: number): void {
|
|
|
@@ -928,7 +944,8 @@ export class Game {
|
|
|
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 };
|
|
|
+ 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 });
|
|
|
item.body.setAngularVelocity(vx >= 0 ? 12 : -12);
|
|
|
}
|
|
|
@@ -939,11 +956,12 @@ export class Game {
|
|
|
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.
|
|
|
+ if (hit) {
|
|
|
+ // The ball sticks to what it hit: the thread is pulled taut from the anchor to here.
|
|
|
this.removeItem(item);
|
|
|
- this.queueOp({ kind: 'fill', x: Math.round(x), y: Math.round(y), r: spec.blobRadius, material: spec.material }, undefined, t.owner);
|
|
|
+ this.stretchThread(t, spec, x, y);
|
|
|
+ } else if (t.age >= spec.life) {
|
|
|
+ this.removeItem(item); // nothing to tie the thread to: it unravels
|
|
|
}
|
|
|
return;
|
|
|
}
|
|
|
@@ -953,21 +971,42 @@ export class Game {
|
|
|
}
|
|
|
}
|
|
|
|
|
|
- /** 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;
|
|
|
+ /**
|
|
|
+ * Where a thrown yarn ball's thread is tied: the solid ground right under the thrower's feet
|
|
|
+ * (so a bridge thrown from the ground holds by that end), or the launch point if airborne.
|
|
|
+ */
|
|
|
+ private yarnAnchor(owner: number, x: number, y: number): [number, number] {
|
|
|
+ const p = this.players.get(owner);
|
|
|
+ if (!p) return [x, y];
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ const px = Math.round(pos.x * PPM);
|
|
|
+ const py = Math.round(pos.y * PPM);
|
|
|
+ for (let dy = 0; dy <= p.bodyRadius + YARN_ANCHOR_REACH; dy++) {
|
|
|
+ for (const dx of [0, -6, 6, -12, 12]) if (this.terrain.isSolid(px + dx, py + dy)) return [px + dx, py + dy];
|
|
|
}
|
|
|
+ return [x, y];
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * Lays the wool: a straight thread of discs from the anchor to the impact point, and a blob
|
|
|
+ * where the ball stuck. Tied to the ground at one end and to what it hit at the other, it
|
|
|
+ * holds as a bridge; otherwise it falls like any unanchored piece (readme §5). No wool is
|
|
|
+ * ever laid inside a player.
|
|
|
+ */
|
|
|
+ private stretchThread(t: Thrown, spec: Extract<ProjectileSpec, { kind: 'yarn' }>, x: number, y: number): void {
|
|
|
+ const bodies = this.alivePlayers().map((p) => {
|
|
|
+ const pos = p.body.getPosition();
|
|
|
+ return { x: pos.x * PPM, y: pos.y * PPM, r: p.bodyRadius };
|
|
|
+ });
|
|
|
+ const clear = (cx: number, cy: number, r: number): boolean => bodies.every((b) => Math.hypot(b.x - cx, b.y - cy) > b.r + r + YARN_PLAYER_MARGIN);
|
|
|
+ const lay = (cx: number, cy: number, r: number): void => {
|
|
|
+ if (clear(cx, cy, r)) this.queueOp({ kind: 'fill', x: Math.round(cx), y: Math.round(cy), r, material: spec.material }, undefined, t.owner);
|
|
|
+ };
|
|
|
+ const dx = x - t.anchorX;
|
|
|
+ const dy = y - t.anchorY;
|
|
|
+ const steps = Math.max(1, Math.ceil(Math.hypot(dx, dy) / YARN_STEP));
|
|
|
+ for (let i = 0; i <= steps; i++) lay(t.anchorX + (dx * i) / steps, t.anchorY + (dy * i) / steps, spec.trailRadius);
|
|
|
+ lay(x, y, spec.blobRadius);
|
|
|
}
|
|
|
|
|
|
/**
|