This commit is contained in:
33333-33333 2026-06-19 20:52:40 +09:00
commit 8eb66f2e66
12 changed files with 2715 additions and 522 deletions

View file

@ -1,4 +1,4 @@
"use strict";
"use strict";
class Item {
constructor(type, x, y) {
@ -7,10 +7,10 @@ class Item {
this.x = x;
this.y = y;
this.r = {
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
this.amount = {
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80;
this.age = 0;
this.seed = Math.random() * 1000;
@ -25,11 +25,26 @@ class Item {
this.seedTimer = rand(18, 38);
this.eatenAmount = 0;
this.fertilityBoost = 0;
this.lifeSpan = rand(420, 860);
this.wither = 0;
}
if (type === "bed") {
this.comfort = rand(0.86, 1.14);
this.wear = 0;
}
if (type === "ball") {
this.vx = rand(-5, 5);
this.vy = rand(-5, 5);
this.prevX = x;
this.prevY = y;
this.spin = rand(0, Math.PI * 2);
this.spinVelocity = rand(-1.6, 1.6);
this.lastKickedAt = -999;
this.lastKickerId = "";
this.lastPokedAt = -999;
this.pokeCombo = 0;
this.lastPokeAngle = rand(0, Math.PI * 2);
}
if (type === "zunchi") {
this.stage = "fresh";
this.stageTimer = 0;
@ -53,7 +68,7 @@ class Item {
}
}
if (this.type === "water") this.amount -= dt * 0.9;
if (this.type === "sweet") this.amount -= dt * 0.12;
if (["sweet", "love_mochi", "fight_mochi"].includes(this.type)) this.amount -= dt * 0.12;
if (this.type === "trace") this.amount -= dt * 1.35;
if (this.type === "splat") this.amount -= dt * 1.05;
if (this.type === "firecracker") {
@ -63,6 +78,7 @@ class Item {
else this.amount = 0;
}
}
if (this.type === "ball") this.updateBall(dt, worldRef);
if (this.type === "grass") {
this.lifecycleTimer += dt;
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
@ -82,6 +98,147 @@ class Item {
}
}
}
updateBall(dt, worldRef) {
this.prevX = this.x;
this.prevY = this.y;
const maxSpeed = 1250;
const clampVelocity = () => {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > maxSpeed) {
this.vx = (this.vx || 0) / speed * maxSpeed;
this.vy = (this.vy || 0) / speed * maxSpeed;
}
return Math.hypot(this.vx || 0, this.vy || 0);
};
const moving = clampVelocity();
if (moving > 0.04) {
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
this.resolveBallObstacleCollisions(dt, worldRef);
clampVelocity();
const groundFriction = Math.pow(0.72, dt);
this.vx *= groundFriction;
this.vy *= groundFriction;
}
this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18);
this.spinVelocity *= Math.pow(0.65, dt);
if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; }
}
resolveBallObstacleCollisions(dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const searchRadius = Math.max(130, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 96);
const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || [];
for (const it of items) {
if (!it || it === this || it.dead) continue;
if (it.type === "bed") {
this.applyBallHayDrag(it, dt, worldRef);
} else if (it.type === "stone") {
this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef);
} else if (it.type === "fence_v" || it.type === "fence_h") {
this.resolveBallFenceBounce(it, worldRef);
}
}
}
applyBallHayDrag(bed, dt, worldRef) {
const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40);
const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34);
const dx = this.x - bed.x;
const dy = this.y - bed.y;
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
if (!inside) return;
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
this.vx *= slow;
this.vy *= slow;
this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) {
this.lastHaySlowLogAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }));
}
}
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) {
let dx = this.x - obstacle.x;
let dy = this.y - obstacle.y;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) return;
if (d < 0.001) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; }
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = obstacle.x + nx * (hitRadius + 0.5);
this.y = obstacle.y + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - (1 + restitution) * toward * nx;
this.vy = (this.vy || 0) - (1 + restitution) * toward * ny;
} else {
this.vx = (this.vx || 0) + nx * 24;
this.vy = (this.vy || 0) + ny * 24;
}
this.vx *= 0.96;
this.vy *= 0.96;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
this.emitBallBounce(worldRef, obstacle);
}
resolveBallFenceBounce(fence, worldRef) {
const vertical = fence.type === "fence_v";
const len = Math.max(112, (fence.r || 42) * 3.55);
const thick = Math.max(10, (fence.r || 42) * 0.31);
const halfLen = len / 2;
const sx1 = vertical ? fence.x : fence.x - halfLen;
const sy1 = vertical ? fence.y - halfLen : fence.y;
const sx2 = vertical ? fence.x : fence.x + halfLen;
const sy2 = vertical ? fence.y + halfLen : fence.y;
const vx = sx2 - sx1;
const vy = sy2 - sy1;
const segLenSq = vx * vx + vy * vy || 1;
const u = clamp(((this.x - sx1) * vx + (this.y - sy1) * vy) / segLenSq, 0, 1);
const cx = sx1 + vx * u;
const cy = sy1 + vy * u;
const hitRadius = (this.r || 18) + thick / 2 + 2;
let dx = this.x - cx;
let dy = this.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) return;
if (d < 0.001) {
if (vertical) { dx = (this.vx || 0) >= 0 ? -1 : 1; dy = 0; }
else { dx = 0; dy = (this.vy || 0) >= 0 ? -1 : 1; }
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = cx + nx * (hitRadius + 0.5);
this.y = cy + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - 1.78 * toward * nx;
this.vy = (this.vy || 0) - 1.78 * toward * ny;
}
this.vx *= 0.94;
this.vy *= 0.94;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
this.emitBallBounce(worldRef, fence);
}
emitBallBounce(worldRef, obstacle) {
const now = worldRef?.time || 0;
if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return;
this.lastObstacleBounceAt = now;
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" }));
}
updateGrass(dt, worldRef) {
const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7;
const weather = worldRef.weather || "sunny";
@ -124,13 +281,22 @@ class Item {
const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * 0.5;
this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88);
this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1);
const grassLimit = CONFIG.grassLimit ?? 96;
const grassLimit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96);
if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2);
if (spawned > 0) this.growth = rand(0.52, 0.68);
this.seedTimer = rand(18, 34);
}
this.amount = clamp(this.growth * 120 * this.health, 3, 130);
if (this.age > (this.lifeSpan || 640) * 0.82) {
const fade = clamp((this.age - (this.lifeSpan || 640) * 0.82) / Math.max((this.lifeSpan || 640) * 0.18, 1), 0, 1);
this.wither = fade;
this.health = clamp((this.health ?? 1) - dt * (0.004 + fade * 0.020), 0, 1);
this.growth = clamp((this.growth ?? 0.25) - dt * fade * 0.005, 0, 0.88);
} else {
this.wither = Math.max(0, (this.wither || 0) - dt * 0.08);
}
this.amount = clamp(this.growth * 120 * this.health, 0, 130);
if (this.age > (this.lifeSpan || 640) * 1.05) this.amount -= dt * (1.2 + (this.wither || 0) * 24);
this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * 0.5;
if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
this.seedTimer = rand(24, 54);
@ -139,7 +305,7 @@ class Item {
}
seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) {
let made = 0;
const limit = CONFIG.grassLimit ?? 96;
const limit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96);
for (let n = 0; n < maxNew && (worldRef.itemCounts?.grass || 0) < limit; n++) {
const source = fertile && fertileSource ? fertileSource : this;
const spot = worldRef.findGrassSproutSpot
@ -157,7 +323,9 @@ class Item {
}
updateZunchi(dt, worldRef) {
const rainBoost = worldRef.weather === "light_rain" ? 1.35 : 1;
const rainy = worldRef.weather === "light_rain";
const rainBoost = rainy ? 1.95 : 1;
const decayBoost = rainy ? 2.15 : 1;
let waterBoost = 0;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
for (const it of nearby) {
@ -167,17 +335,19 @@ class Item {
this.stageTimer += dt * 2.0 * (rainBoost + waterBoost * 1.3);
if (this.stage === "fresh") {
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
this.amount -= dt * 0.05 * decayBoost;
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
} else if (this.stage === "dry") {
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
this.amount -= dt * 0.09 * decayBoost;
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
} else if (this.stage === "decomposing") {
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
this.amount -= dt * 0.12;
this.amount -= dt * 0.12 * decayBoost;
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
} else if (this.stage === "fertile_soil") {
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
this.amount -= dt * 0.26;
this.amount -= dt * 0.26 * decayBoost;
}
}
get dead() {
@ -187,8 +357,11 @@ class Item {
return {
type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed,
stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness,
growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost,
comfort: this.comfort, wear: this.wear, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax
growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost, lifeSpan: this.lifeSpan, wither: this.wither,
comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax,
vx: this.vx, vy: this.vy, prevX: this.prevX, prevY: this.prevY,
spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId,
lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle
};
}
static from(o) {
@ -200,10 +373,10 @@ class Item {
it.x = o.x;
it.y = o.y;
it.r = {
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
it.amount = o.amount ?? ({
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80);
it.age = o.age || 0;
it.seed = o.seed || Math.random() * 1000;
@ -223,6 +396,19 @@ class Item {
it.comfort = 1;
it.wear = 0;
}
if (type === "ball") {
it.vx = o.vx || 0;
it.vy = o.vy || 0;
it.prevX = o.prevX ?? o.x;
it.prevY = o.prevY ?? o.y;
it.spin = o.spin || 0;
it.spinVelocity = o.spinVelocity || 0;
it.lastKickedAt = o.lastKickedAt ?? -999;
it.lastKickerId = o.lastKickerId || "";
it.lastPokedAt = o.lastPokedAt ?? -999;
it.pokeCombo = o.pokeCombo || 0;
it.lastPokeAngle = o.lastPokeAngle || rand(0, Math.PI * 2);
}
if (type === "zunchi") {
it.stage = "fresh";
it.stageTimer = 0;
@ -243,8 +429,22 @@ class Item {
it.seedTimer = o.seedTimer ?? it.seedTimer;
it.eatenAmount = o.eatenAmount || 0;
it.fertilityBoost = o.fertilityBoost || 0;
it.lifeSpan = o.lifeSpan ?? it.lifeSpan;
it.wither = o.wither ?? it.wither ?? 0;
it.comfort = o.comfort ?? it.comfort;
it.wear = o.wear ?? it.wear;
it.crawlOpen = o.crawlOpen ?? it.crawlOpen;
it.vx = o.vx ?? it.vx ?? 0;
it.vy = o.vy ?? it.vy ?? 0;
it.prevX = o.prevX ?? it.prevX ?? it.x;
it.prevY = o.prevY ?? it.prevY ?? it.y;
it.spin = o.spin ?? it.spin ?? 0;
it.spinVelocity = o.spinVelocity ?? it.spinVelocity ?? 0;
it.lastKickedAt = o.lastKickedAt ?? it.lastKickedAt ?? -999;
it.lastKickerId = o.lastKickerId || it.lastKickerId || "";
it.lastPokedAt = o.lastPokedAt ?? it.lastPokedAt ?? -999;
it.pokeCombo = o.pokeCombo ?? it.pokeCombo ?? 0;
it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2);
it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi";
it.toolSize = o.toolSize || it.toolSize || "medium";
it.blastScale = o.blastScale || it.blastScale || 1;
@ -303,10 +503,12 @@ class Item {
ctx.save();
ctx.translate(this.x, this.y + dropY);
ctx.globalAlpha = visibleAlpha;
if (this.type === "food" || this.type === "sweet") {
if (this.type === "food" || this.type === "sweet" || this.type === "love_mochi" || this.type === "fight_mochi") {
const sweet = this.type === "sweet";
ctx.fillStyle = sweet ? "#fffaf1" : "#f1dfb7";
ctx.strokeStyle = sweet ? "#9a8a72" : "#8d6f48";
const loveMochi = this.type === "love_mochi";
const fightMochi = this.type === "fight_mochi";
ctx.fillStyle = loveMochi ? "#fff1f6" : (fightMochi ? "#fff3ec" : (sweet ? "#fffaf1" : "#f1dfb7"));
ctx.strokeStyle = loveMochi ? "#bf6f8b" : (fightMochi ? "#bc6d42" : (sweet ? "#9a8a72" : "#8d6f48"));
ctx.lineWidth = 2;
ctx.beginPath();
const bob = 0;
@ -321,6 +523,30 @@ class Item {
ctx.beginPath();
ctx.ellipse(-this.r * 0.32, -this.r * 0.16, this.r * 0.24, this.r * 0.10, -0.35, 0, Math.PI * 2);
ctx.fill();
} else if (loveMochi) {
ctx.fillStyle = "#ff6a9c";
ctx.font = `${Math.round((this.r || 13) * 1.15)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("❤", 0, 1);
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.beginPath();
ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2);
ctx.fill();
} else if (fightMochi) {
ctx.fillStyle = "#d55632";
ctx.font = `${Math.round((this.r || 13) * 1.02)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("✦", 0, 1);
ctx.strokeStyle = "rgba(168,76,42,0.72)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(-this.r * 0.44, -this.r * 0.42);
ctx.lineTo(this.r * 0.44, this.r * 0.42);
ctx.moveTo(-this.r * 0.44, this.r * 0.42);
ctx.lineTo(this.r * 0.44, -this.r * 0.42);
ctx.stroke();
} else {
ctx.fillStyle = "#fff7dc";
for (let i = 0; i < 5; i++) {
@ -343,7 +569,8 @@ class Item {
const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature";
const eaten = this.eatenAmount > 14 || growth < 0.16;
const grassLoad = 1;
ctx.strokeStyle = eaten ? "#7b8755" : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"));
const wither = clamp(this.wither || 0, 0, 1);
ctx.strokeStyle = eaten ? "#7b8755" : (wither > 0.08 ? `rgba(${Math.round(106 + wither * 40)}, ${Math.round(132 - wither * 32)}, ${Math.round(70 - wither * 18)}, 1)` : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438")));
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineCap = "round";
@ -399,6 +626,27 @@ class Item {
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "ball") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);
ctx.save();
ctx.rotate(this.spin || 0);
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)";
ctx.shadowBlur = 2 + moving * 3;
ctx.shadowOffsetY = 2;
ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("⚽", 0, 1);
if (moving > 0.55) {
ctx.globalAlpha = 0.14 + moving * 0.10;
ctx.shadowColor = "transparent";
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "firecracker") {
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;