This commit is contained in:
33333-33333 2026-06-22 02:03:19 +09:00
commit e8f1d1db1e
60 changed files with 2417 additions and 612 deletions

View file

@ -27,6 +27,19 @@ function passiveFoodDecayRate(item) {
return PASSIVE_FOOD_DECAY_PER_SECOND * sizeMul * typeMul;
}
function itemVisualFor(type = "") {
return typeof itemVisualDefinition === "function" ? itemVisualDefinition(type) : null;
}
function visualPaletteFor(type = "", fallback = {}) {
return itemVisualFor(type)?.palette || fallback || {};
}
function visualFieldScaleFor(type = "") {
const visual = itemVisualFor(type);
return visual?.fieldScale || visual?.scale || 1;
}
function drawOvalWaterDropSprite(ctx, r, palette = {}) {
const fill = palette.fill || "rgba(86, 157, 224, 0.82)";
const stroke = palette.stroke || "rgba(45, 101, 168, 0.52)";
@ -48,20 +61,23 @@ function drawOvalWaterDropSprite(ctx, r, palette = {}) {
ctx.restore();
}
function drawSleepTabletSprite(ctx, r) {
function drawSleepTabletSprite(ctx, r, palette = {}) {
const fill = palette.fill || "#ebe4ff";
const stroke = palette.stroke || "rgba(74, 60, 144, 0.72)";
const accent = palette.accent || "rgba(112, 91, 188, 0.82)";
ctx.save();
ctx.rotate(-0.18);
ctx.fillStyle = "#ebe4ff";
ctx.strokeStyle = "rgba(74, 60, 144, 0.72)";
ctx.fillStyle = fill;
ctx.strokeStyle = stroke;
ctx.lineWidth = Math.max(1.8, r * 0.14);
ctx.beginPath();
ctx.ellipse(0, 0, r * 0.92, r * 0.66, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(112, 91, 188, 0.82)";
ctx.fillStyle = accent;
ctx.beginPath();
ctx.arc(r * 0.12, -r * 0.04, r * 0.38, 0, Math.PI * 2);
ctx.fill();
ctx.fillStyle = "#ebe4ff";
ctx.fillStyle = fill;
ctx.beginPath();
ctx.arc(r * 0.28, -r * 0.11, r * 0.38, 0, Math.PI * 2);
ctx.fill();
@ -186,6 +202,10 @@ class Item {
this.comfort = rand(0.86, 1.14);
this.wear = 0;
}
if (type === "signboard") {
this.text = "";
this.textEditedAt = -999;
}
if (type === "ball") {
this.vx = rand(-5, 5);
this.vy = rand(-5, 5);
@ -232,7 +252,7 @@ class Item {
this.lingerTimer = 0;
this.lingerDuration = 2.0;
}
if (type === "pushpin") {
if (isPinType(type)) {
this.amount = 100;
this.vx = rand(-120, 120);
this.vy = rand(-90, 90);
@ -322,7 +342,7 @@ class Item {
}
}
if (this.type === "ball") this.updateBall(dt, worldRef);
if (this.type === "pushpin") this.updatePushpin(dt, worldRef);
if (isPinType(this.type)) this.updatePushpin(dt, worldRef);
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
if (this.type === "grass") {
this.lifecycleTimer += dt;
@ -347,20 +367,59 @@ class Item {
updateZunchiMotion(dt, worldRef) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed < 0.08) { this.vx = 0; this.vy = 0; return; }
this.prevX = this.x;
this.prevY = this.y;
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.34; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; }
const slow = Math.pow(0.68, dt * 2.4);
this.vx *= slow;
this.vy *= slow;
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { bounce: 0.34, frictionBase: 0.68, frictionRate: 2.4, spin: false, stopSpeed: 0.08 });
} else {
this.prevX = this.x;
this.prevY = this.y;
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.34; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.34; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.34; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.34; }
const slow = Math.pow(0.68, dt * 2.4);
this.vx *= slow;
this.vy *= slow;
worldRef.drawListDirty = true;
}
this.resolveHighSpeedZunchiTarinaiCollision?.(dt, worldRef, speed);
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
worldRef.drawListDirty = true;
}
resolveHighSpeedZunchiTarinaiCollision(dt, worldRef, speed = 0) {
if (!worldRef || speed < 140 || (this.amount || 0) <= 0) return;
const search = Math.max(38, (this.r || 14) + speed * Math.max(dt || 0.016, 0.016) + 18);
for (const t of worldRef.nearbyTarinai?.(this.x, this.y, search, true) || []) {
if (!t || t.dead || worldRef.isTarinaiHiddenInNestBox?.(t)) continue;
const d = distXY(this.x, this.y, t.x, t.y);
if (d > (t.radius || 20) * 0.72 + (this.r || 14)) continue;
const now = worldRef.time || 0;
if ((this.lastHitTarinaiAt || {})[t.id] && this.lastHitTarinaiAt[t.id] + 0.55 > now) continue;
this.lastHitTarinaiAt = this.lastHitTarinaiAt || {};
this.lastHitTarinaiAt[t.id] = now;
const nx = speed > 0 ? (this.vx || 1) / speed : rand(-1, 1);
const ny = speed > 0 ? (this.vy || 0) / speed : rand(-1, 1);
const impulse = clamp(speed * 1.15, 150, 620);
t.vx = (t.vx || 0) + nx * impulse + rand(-20, 20);
t.vy = (t.vy || 0) + ny * impulse * 0.72 + rand(-18, 10);
t.fallTimer = Math.max(t.fallTimer || 0, 0.85);
t.fallMax = Math.max(t.fallMax || 0.85, t.fallTimer);
t.fallDir = nx < 0 ? -1 : 1;
if (t.enterPanic) t.enterPanic({ threat: this, reason: "高速ずんちにぶつかって吹き飛んでいる", fear: 0.55, stress: 7, stressDuration: 3.2, surpriseTimer: 0.5, cause: "fast_zunchi_hit" });
else {
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.5);
t.fearTimer = Math.max(t.fearTimer || 0, 0.55);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : null, reason: "高速ずんちにぶつかって吹き飛んでいる", wake: true });
t.addStress ? t.addStress(7, { threshold: 8, duration: 3.2 }) : (t.stress = clamp((t.stress || 0) + 7, 0, 130));
}
worldRef.spawnFallEffect?.(t.x, t.y + (t.radius || 20) * 0.45, 0.65);
worldRef.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 14, life: 0.38, color: "rgba(77,92,42,0.36)" }));
this.vx *= -0.18;
this.vy *= -0.18;
break;
}
}
updateBall(dt, worldRef) {
@ -571,29 +630,13 @@ class Item {
this.updateLodgedPushpin(dt, worldRef);
return;
}
this.prevX = this.x;
this.prevY = this.y;
const moving = Math.hypot(this.vx || 0, this.vy || 0);
if (moving > 0.05) {
if (window.TarinaiPhysics?.applyKinematicItemMotion) {
window.TarinaiPhysics.applyKinematicItemMotion(this, worldRef, dt, { padding: 26, bounce: 0.44, spinBounce: -0.68, frictionBase: 0.18, frictionRate: 0.85, spinFrictionBase: 0.38, spinRestDamp: 0.08, stopSpeed: 0.05, zeroBelow: 7.5 });
} else {
this.prevX = this.x;
this.prevY = this.y;
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
const p = Math.max(26, CONFIG.worldPadding || 30);
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.44; this.spinVelocity *= -0.68; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.44; this.spinVelocity *= -0.68; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.44; this.spinVelocity *= -0.68; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.44; this.spinVelocity *= -0.68; }
const friction = Math.pow(0.18, dt * 0.85);
this.vx *= friction;
this.vy *= friction;
this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(7, this.r || 16);
this.spinVelocity *= Math.pow(0.38, dt);
if (Math.hypot(this.vx || 0, this.vy || 0) < 7.5) { this.vx = 0; this.vy = 0; this.spinVelocity *= 0.3; }
worldRef.drawListDirty = true;
} else {
this.vx = 0;
this.vy = 0;
this.spinVelocity *= Math.pow(0.08, dt);
this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt;
}
this.tryStickPushpin(worldRef);
}
@ -617,15 +660,17 @@ class Item {
attachPushpin(t, worldRef, d = null) {
if (!t || t.dead) return false;
if (t.stuckPushpinId && t.stuckPushpinId !== this.id) return false;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
const dx = this.x - t.x;
const dy = this.y - t.y;
const distToTarget = Number.isFinite(d) ? d : Math.hypot(dx, dy);
this.pinState = "lodged";
this.deletable = false;
this.pinTargetId = t.id;
this.pinAttachAngle = Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1));
this.pinAttachDistance = clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
this.pinOffsetY = clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
this.pinAttachAngle = isOshibyo ? 0.62 : Math.atan2(dy || -1, dx || (t.facingDir ? t.facingDir() : 1));
this.pinAttachDistance = isOshibyo ? (t.radius || 16) * 0.58 : clamp(distToTarget || (t.radius || 16) * 0.58, (t.radius || 16) * 0.20, (t.radius || 16) * 0.74);
this.pinOffsetY = isOshibyo ? (t.radius || 16) * 0.30 : clamp(dy, -(t.radius || 16) * 0.74, (t.radius || 16) * 0.38);
this.pinDamageTick = 0;
this.pinFallCheckTimer = 0;
this.vx = 0;
@ -635,16 +680,25 @@ class Item {
t.stuckPushpinId = this.id;
t.sleeping = false;
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 7);
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.8);
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
t.state = "panic";
t.target = t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) };
t.addStress ? t.addStress(18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + 18, 0, 130));
if (t.damage) t.damage(6, "\u753b\u92f2");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, isOshibyo ? 0.35 : 0.8);
if (isOshibyo) {
t.thought = "おしり鋲が刺さってずんちが出なくなった";
worldRef.log?.(`${t.name}におしり鋲が刺さった。`, "accident", { participants: [t] });
worldRef.effects?.push(new Effect("ring", t.x, t.y + (t.radius || 16) * 0.34, { size: Math.max(14, (t.radius || 16) * 0.58), life: 0.18, color: "rgba(73,119,205,0.36)" }));
return true;
}
if (t.enterPanic) {
t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 1.3, stress: behavior?.stressOnAttach ?? 18, hurtTimer: 1.2, awakeLockTimer: 7, surpriseTimer: 0.8, cause: "pushpin_attach" });
} else {
t.hurtTimer = Math.max(t.hurtTimer || 0, 1.2);
t.fearTimer = Math.max(t.fearTimer || 0, 1.3);
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true });
t.addStress ? t.addStress(behavior?.stressOnAttach ?? 18, { threshold: 8 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressOnAttach ?? 18), 0, 130));
}
if (t.damage && (behavior?.damageOnAttach ?? 6) > 0) t.damage(behavior?.damageOnAttach ?? 6, "画鋲");
if ((worldRef.time || 0) >= (this.pinLogAt || -999) + 1.2) {
this.pinLogAt = worldRef.time || 0;
worldRef.log?.(`${t.name}\u306b\u753b\u92f2\u304c\u523a\u3055\u3063\u305f\u3002`, "accident", { participants: [t] });
worldRef.log?.(`${t.name}に画鋲が刺さった。`, "accident", { participants: [t] });
}
worldRef.effects?.push(new Effect("ring", t.x, t.y - (t.radius || 16) * 0.12, { size: Math.max(18, (t.radius || 16) * 0.90), life: 0.22, color: "rgba(214,72,72,0.50)" }));
return true;
@ -656,30 +710,37 @@ class Item {
this.detachPushpin(worldRef, "owner-lost");
return;
}
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const isOshibyo = Boolean(behavior?.blocksZunchi);
t.stuckPushpinId = this.id;
this.deletable = false;
this.x = t.x + (t.radius || 16) * 0.36;
this.y = t.y - (t.radius || 16) * 0.04;
this.x = isOshibyo ? t.x + (t.radius || 16) * 0.42 : t.x + (t.radius || 16) * 0.36;
this.y = isOshibyo ? t.y + (t.radius || 16) * 0.34 : t.y - (t.radius || 16) * 0.04;
this.prevX = this.x;
this.prevY = this.y;
this.spin = 0.28;
this.spin = isOshibyo ? 0.52 : 0.28;
this.vx = t.vx || 0;
this.vy = t.vy || 0;
if (isOshibyo) {
if ((t.oshiriByoZunchiStock || 0) >= 6 && t.state !== "eat" && t.state !== "sleep") t.thought = "おしり鋲でずんちが溜まってつらい";
return;
}
this.pinDamageTick = (this.pinDamageTick || 0) + dt;
while (this.pinDamageTick >= 0.55) {
this.pinDamageTick -= 0.55;
if (t.damage) t.damage(1.4, "\u753b\u92f2");
if (t.damage && (behavior?.damagePerTick ?? 1.4) > 0) t.damage(behavior?.damagePerTick ?? 1.4, "画鋲");
t.hurtTimer = Math.max(t.hurtTimer || 0, 0.50);
t.addStress ? t.addStress(2.6, { threshold: 8, duration: 3.4 }) : (t.stress = clamp((t.stress || 0) + 2.6, 0, 130));
t.addStress ? t.addStress(behavior?.stressPerTick ?? 2.6, { threshold: 8, duration: 3.4 }) : (t.stress = clamp((t.stress || 0) + (behavior?.stressPerTick ?? 2.6), 0, 130));
if (Math.random() < 0.22) t.bubble?.("!!", 0.6, "rgba(168,72,72,0.82)");
}
t.sleeping = false;
t.state = "panic";
t.target = t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) };
t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
t.thought = "\u753b\u92f2\u304c\u523a\u3055\u3063\u3066\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
if (t.enterPanic) t.enterPanic({ threat: this, reason: "画鋲が刺さってパニックになっている", fear: 0.65, surpriseTimer: 0.22, awakeLockTimer: 2.4, cause: "pushpin_lodged" });
else {
t.setActionState?.("panic", { target: t.panicDestination ? t.panicDestination(this) : { x: t.x + rand(-80, 80), y: t.y + rand(-80, 80) }, reason: "画鋲が刺さってパニックになっている", wake: true });
t.fearTimer = Math.max(t.fearTimer || 0, 0.65);
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.22);
t.awakeLockTimer = Math.max(t.awakeLockTimer || 0, 2.4);
}
this.pinFallCheckTimer = (this.pinFallCheckTimer || 0) + dt;
while (this.pinFallCheckTimer >= 1.0) {
this.pinFallCheckTimer -= 1.0;
@ -692,7 +753,11 @@ class Item {
detachPushpin(worldRef, reason = "released") {
const t = worldRef?.tarinai?.find(o => o && o.id === this.pinTargetId) || null;
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const wasOshibyo = Boolean(behavior?.blocksZunchi);
const storedZunchi = wasOshibyo && t ? Math.max(0, Math.floor(t.oshiriByoZunchiStock || 0)) : 0;
if (t && t.stuckPushpinId === this.id) t.stuckPushpinId = null;
if (t && wasOshibyo) t.oshiriByoZunchiStock = 0;
this.pinState = "loose";
this.pinTargetId = "";
this.pinDamageTick = 0;
@ -714,6 +779,10 @@ class Item {
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, (this.r || 16) * 0.92), life: 0.16, color: "rgba(214,112,112,0.40)" }));
if (reason === "fall" && t) worldRef?.log?.(`${t.name}\u306e\u753b\u92f2\u304c\u629c\u3051\u843d\u3061\u305f\u3002`, "observe", { participants: [t] });
}
if (wasOshibyo && storedZunchi > 0 && worldRef?.spawnBurstZunchi) {
worldRef.spawnBurstZunchi(t || this, storedZunchi, this);
}
}
updateGrass(dt, worldRef) {
@ -928,7 +997,7 @@ class Item {
}
}
} else if (typeof isParamEffectItemType === "function" && isParamEffectItemType(this.type)) {
const visual = typeof itemVisualDefinition === "function" ? itemVisualDefinition(this.type) : null;
const visual = itemVisualFor(this.type);
const palette = visual?.palette || {};
const renderer = visual?.renderer || "split_pill";
const meta = {
@ -1008,7 +1077,7 @@ class Item {
ctx.restore();
}
} else if (this.type === "sleep_drug") {
drawSleepTabletSprite(ctx, this.r);
drawSleepTabletSprite(ctx, this.r * visualFieldScaleFor("sleep_drug"), visualPaletteFor("sleep_drug"));
} else if (this.type === "genkotsu") {
const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu");
const impact = clamp(this.impactFlash || 0, 0, 1);
@ -1045,9 +1114,10 @@ class Item {
ctx.fill();
}
ctx.restore();
} else if (this.type === "pushpin") {
} else if (isPinType(this.type)) {
const stuck = this.pinState === "lodged";
const assetId = stuck ? "pushpin_stuck" : "pushpin";
const behavior = typeof pinBehaviorFor === "function" ? pinBehaviorFor(this.type) : null;
const assetId = behavior ? (stuck ? behavior.lodgedAsset : behavior.looseAsset) : (stuck ? "pushpin_stuck" : "pushpin");
const img = typeof getRenderableImage === "function" ? getRenderableImage(assetId, assetId) : images.get(assetId);
ctx.save();
if (!stuck) ctx.rotate((this.spin || 0) + Math.PI * 0.5);
@ -1057,7 +1127,7 @@ class Item {
if (img) {
const metrics = typeof getImageMetrics === "function" ? getImageMetrics(assetId) : null;
const ratio = metrics?.ratio || (stuck ? 1 : 2.0);
const w = stuck ? this.r * 2.15 : this.r * 1.78;
const w = isOshibyo ? (stuck ? this.r * 3.1 : this.r * 1.55) : (stuck ? this.r * 2.15 : this.r * 1.78);
const h = w * ratio;
ctx.drawImage(img, -w * 0.5, -h * 0.5, w, h);
} else if (stuck) {
@ -1090,26 +1160,23 @@ class Item {
}
ctx.restore();
} else if (this.type === "water_bowl") {
ctx.fillStyle = "rgba(171, 120, 76, 0.72)";
ctx.strokeStyle = "rgba(94, 69, 48, 0.55)";
const palette = visualPaletteFor("water_bowl");
ctx.fillStyle = palette.bowlFill || "rgba(171, 120, 76, 0.72)";
ctx.strokeStyle = palette.bowlStroke || "rgba(94, 69, 48, 0.55)";
ctx.lineWidth = 2.2;
ctx.beginPath();
ctx.ellipse(0, 2, this.r * 1.35, this.r * 0.78, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(86, 157, 224, 0.58)";
ctx.strokeStyle = "rgba(62, 113, 178, 0.36)";
ctx.fillStyle = palette.waterFill || "rgba(86, 157, 224, 0.58)";
ctx.strokeStyle = palette.waterStroke || "rgba(62, 113, 178, 0.36)";
ctx.lineWidth = 1.6;
ctx.beginPath();
ctx.ellipse(0, -1, this.r * 1.02, this.r * 0.50, 0, 0, Math.PI * 2);
ctx.fill(); ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.54)";
ctx.fillStyle = palette.highlight || "rgba(255,255,255,0.54)";
ctx.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill();
} else if (this.type === "water") {
drawOvalWaterDropSprite(ctx, this.r, {
fill: "rgba(86, 157, 224, 0.82)",
stroke: "rgba(45, 101, 168, 0.52)",
highlight: "rgba(255,255,255,0.68)",
});
drawOvalWaterDropSprite(ctx, this.r * visualFieldScaleFor("water"), visualPaletteFor("water"));
} else if (this.type === "grass") {
const growth = this.growth ?? clamp(this.amount / 120, 0, 1);
const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature";
@ -1193,6 +1260,36 @@ class Item {
ctx.beginPath();
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
ctx.fill();
} else if (this.type === "signboard") {
ctx.save();
ctx.fillStyle = styleNight ? "#6d543b" : (styleWarm ? "#a67848" : "#8d633e");
ctx.strokeStyle = styleNight ? "#34281c" : "#573c27";
ctx.lineWidth = 2.2;
ctx.fillRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58);
ctx.strokeRect(-this.r * 0.14, -this.r * 0.05, this.r * 0.28, this.r * 1.58);
roundedRect(ctx, -this.r * 1.18, -this.r * 1.18, this.r * 2.36, this.r * 1.18, this.r * 0.14);
ctx.fill();
ctx.stroke();
ctx.fillStyle = "rgba(255,255,255,0.16)";
ctx.fillRect(-this.r * 0.92, -this.r * 0.96, this.r * 1.02, this.r * 0.14);
const raw = String(this.text || "").replace(/\s+/g, " ").trim();
if (raw) {
const charsPerLine = 7;
const lines = [];
for (let i = 0; i < raw.length && lines.length < 2; i += charsPerLine) lines.push(raw.slice(i, i + charsPerLine));
ctx.fillStyle = styleNight ? "rgba(255,249,236,0.92)" : "rgba(51,34,19,0.92)";
ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.38))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
lines.forEach((line, index) => ctx.fillText(line, 0, -this.r * 0.68 + index * this.r * 0.34));
} else {
ctx.fillStyle = styleNight ? "rgba(255,249,236,0.62)" : "rgba(51,34,19,0.50)";
ctx.font = `bold ${Math.max(8, Math.round(this.r * 0.30))}px ui-rounded, sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("…", 0, -this.r * 0.58);
}
ctx.restore();
} else if (this.type === "ant_nest") {
const count = clamp(Math.round(this.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
ctx.fillStyle = styleNight ? "#5e4634" : (styleWarm ? "#956b42" : "#77583a");