884 lines
41 KiB
JavaScript
884 lines
41 KiB
JavaScript
// tarinai_colony_game build 15.6.2: food servings, nest box access, and editable tarinai data
|
|
"use strict";
|
|
|
|
const FOOD_SERVING_TYPES = new Set(["food", "sweet", "love_mochi", "fight_mochi", "sleep_drug"]);
|
|
const FOOD_SERVINGS_BY_SIZE = { small: 1, medium: 5, large: 15 };
|
|
function foodServingsForSize(size = "medium") {
|
|
return FOOD_SERVINGS_BY_SIZE[size] || FOOD_SERVINGS_BY_SIZE.medium;
|
|
}
|
|
function isServingFoodType(type = "") {
|
|
return FOOD_SERVING_TYPES.has(type);
|
|
}
|
|
|
|
class Item {
|
|
constructor(type, x, y) {
|
|
this.id = crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
|
|
this.type = type;
|
|
this.x = x;
|
|
this.y = y;
|
|
this.r = itemRadiusFor(type, 12);
|
|
this.amount = itemAmountFor(type, 80);
|
|
this.age = 0;
|
|
this.seed = Math.random() * 1000;
|
|
this.dropTimer = 0;
|
|
this.dropMax = 0;
|
|
this.dropImpactDone = false;
|
|
this.lifecycleTimer = rand(0, type === "grass" ? 1.15 : 0.55);
|
|
this.lifecycleInterval = type === "grass" ? (1.75 + stableUnit(this.id, "grass-life") * 0.90) : (type === "zunchi" ? (0.95 + stableUnit(this.id, "zunchi-life") * 0.45) : 0);
|
|
if (type === "grass") {
|
|
this.growth = rand(0.18, 0.42);
|
|
this.health = 1;
|
|
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;
|
|
this.fertility = 0;
|
|
this.freshness = 1;
|
|
this.zunchiVariant = stableUnit(this.id, "zunchi-variant") < 0.5 ? "zunchi" : "zunchi_02";
|
|
}
|
|
if (type === "ant_nest") {
|
|
this.antCount = ANT_NEST_START_COUNT || 6;
|
|
this.antSpawnTimer = rand(0.6, 2.2);
|
|
this.antSpawnCooldown = rand(0.8, 2.4);
|
|
this.antWorkers = typeof buildAntWorkerPool === "function"
|
|
? buildAntWorkerPool(this.antCount)
|
|
: Array.from({ length: this.antCount }, () => ANT_WORKER_HP || 32);
|
|
this.queenSpawnAt = -999;
|
|
}
|
|
if (type === "ant_corpse") {
|
|
this.amount = 24;
|
|
this.workerSprite = "ant_worker";
|
|
this.decayTimer = 0;
|
|
}
|
|
if (type === "firecracker") {
|
|
this.fuseTimer = 5;
|
|
this.fuseMax = 5;
|
|
}
|
|
if (type === "genkotsu") {
|
|
this.amount = 100;
|
|
this.dropMax = 0;
|
|
this.dropImpactDone = false;
|
|
this.impactFlash = 0;
|
|
}
|
|
if (isServingFoodType(type)) {
|
|
this.toolSize = "medium";
|
|
this.foodServingScale = 1;
|
|
this.foodServingsMax = foodServingsForSize(this.toolSize);
|
|
this.foodServingsRemaining = this.foodServingsMax;
|
|
this.amount = this.foodServingsRemaining;
|
|
}
|
|
}
|
|
update(dt, worldRef = world) {
|
|
this.age += dt;
|
|
if (this.dropTimer > 0) {
|
|
const beforeDrop = this.dropTimer;
|
|
this.dropTimer = Math.max(0, this.dropTimer - dt);
|
|
if (beforeDrop > 0 && this.dropTimer <= 0 && !this.dropImpactDone) {
|
|
this.dropImpactDone = true;
|
|
if (worldRef.itemDropImpact) worldRef.itemDropImpact(this);
|
|
}
|
|
}
|
|
if (this.type === "water") this.amount -= dt * 0.9;
|
|
if (["sweet", "love_mochi", "fight_mochi", "sleep_drug"].includes(this.type) && !Number.isFinite(this.foodServingsRemaining)) 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 === "ant_corpse") this.amount -= dt * 0.018;
|
|
if (this.type === "firecracker") {
|
|
this.fuseTimer = Math.max(0, (this.fuseTimer ?? 5) - dt);
|
|
if (this.fuseTimer <= 0) {
|
|
if (worldRef.explodeFirecracker) worldRef.explodeFirecracker(this);
|
|
else this.amount = 0;
|
|
}
|
|
}
|
|
if (this.type === "genkotsu") {
|
|
this.impactFlash = Math.max(0, (this.impactFlash || 0) - dt);
|
|
if (this.dropImpactDone) this.amount -= dt * 115;
|
|
else this.amount = Math.max(this.amount || 0, 100);
|
|
}
|
|
if (this.type === "ball") this.updateBall(dt, worldRef);
|
|
if (this.type === "zunchi") this.updateZunchiMotion(dt, worldRef);
|
|
if (this.type === "grass") {
|
|
this.lifecycleTimer += dt;
|
|
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
|
|
const interval = (this.lifecycleInterval || 1.9) * factor;
|
|
if (this.lifecycleTimer >= interval) {
|
|
const lifecycleDt = Math.min(this.lifecycleTimer, 4.8);
|
|
this.lifecycleTimer = 0;
|
|
this.updateGrass(lifecycleDt, worldRef);
|
|
}
|
|
} else if (this.type === "zunchi") {
|
|
this.lifecycleTimer += dt;
|
|
const interval = this.lifecycleInterval || (0.95 + stableUnit(this.id, "zunchi-life") * 0.45);
|
|
if (this.lifecycleTimer >= interval) {
|
|
const lifecycleDt = Math.min(this.lifecycleTimer, 2.4);
|
|
this.lifecycleTimer = 0;
|
|
this.updateZunchi(lifecycleDt, worldRef);
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
this.spin = (this.spin || 0) + speed * dt / Math.max(8, this.r || 14);
|
|
worldRef.drawListDirty = true;
|
|
}
|
|
|
|
updateBall(dt, worldRef) {
|
|
this.prevX = this.x;
|
|
this.prevY = this.y;
|
|
const moving = Math.hypot(this.vx || 0, this.vy || 0);
|
|
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);
|
|
this.x = clamp(this.x, p, worldRef.w - p);
|
|
this.y = clamp(this.y, p, worldRef.h - p);
|
|
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(150, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 120);
|
|
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);
|
|
}
|
|
const rects = worldRef.solidObstacleRects ? worldRef.solidObstacleRects(it) : [];
|
|
if (!rects.length) continue;
|
|
for (const rect of rects) this.resolveBallRectBounce(rect, worldRef, it);
|
|
}
|
|
}
|
|
|
|
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) {
|
|
const px = this.prevX;
|
|
const py = this.prevY;
|
|
if (!Number.isFinite(px) || !Number.isFinite(py)) return;
|
|
const sx = this.x - px;
|
|
const sy = this.y - py;
|
|
const len2 = sx * sx + sy * sy;
|
|
if (len2 <= 0.0001) return;
|
|
const t = clamp(((obstacle.x - px) * sx + (obstacle.y - py) * sy) / len2, 0, 1);
|
|
const cx = px + sx * t;
|
|
const cy = py + sy * t;
|
|
dx = cx - obstacle.x;
|
|
dy = cy - obstacle.y;
|
|
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 = -sx / Math.sqrt(len2); dy = -sy / Math.sqrt(len2); d = 1; }
|
|
}
|
|
}
|
|
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);
|
|
}
|
|
|
|
resolveBallRectBounce(rect, worldRef, source = null) {
|
|
if (!rect) return;
|
|
const hitRadius = (this.r || 18) + 2;
|
|
const cx = clamp(this.x, rect.left, rect.right);
|
|
const cy = clamp(this.y, rect.top, rect.bottom);
|
|
let dx = this.x - cx;
|
|
let dy = this.y - cy;
|
|
let d = Math.hypot(dx, dy);
|
|
if (d >= hitRadius) {
|
|
const px = this.prevX;
|
|
const py = this.prevY;
|
|
const expanded = { left: rect.left - hitRadius, right: rect.right + hitRadius, top: rect.top - hitRadius, bottom: rect.bottom + hitRadius };
|
|
if (!Number.isFinite(px) || !Number.isFinite(py) || !worldRef?.segmentIntersectsRect?.(px, py, this.x, this.y, expanded)) return;
|
|
let nx = 0;
|
|
let ny = 0;
|
|
const vx = this.vx || 0;
|
|
const vy = this.vy || 0;
|
|
const candidates = [];
|
|
const sx = this.x - px;
|
|
const sy = this.y - py;
|
|
if (px < expanded.left && sx > 0) candidates.push({ nx: -1, ny: 0, t: (expanded.left - px) / Math.max(sx, 0.001) });
|
|
if (px > expanded.right && sx < 0) candidates.push({ nx: 1, ny: 0, t: (px - expanded.right) / Math.max(-sx, 0.001) });
|
|
if (py < expanded.top && sy > 0) candidates.push({ nx: 0, ny: -1, t: (expanded.top - py) / Math.max(sy, 0.001) });
|
|
if (py > expanded.bottom && sy < 0) candidates.push({ nx: 0, ny: 1, t: (py - expanded.bottom) / Math.max(-sy, 0.001) });
|
|
if (candidates.length) {
|
|
candidates.sort((a, b) => a.t - b.t);
|
|
nx = candidates[0].nx;
|
|
ny = candidates[0].ny;
|
|
} else if (Math.abs(vx) >= Math.abs(vy)) {
|
|
nx = vx >= 0 ? -1 : 1;
|
|
} else {
|
|
ny = vy >= 0 ? -1 : 1;
|
|
}
|
|
const toward = vx * nx + vy * ny;
|
|
if (nx < 0) this.x = expanded.left - 0.5;
|
|
else if (nx > 0) this.x = expanded.right + 0.5;
|
|
if (ny < 0) this.y = expanded.top - 0.5;
|
|
else if (ny > 0) this.y = expanded.bottom + 0.5;
|
|
if (nx) this.y = clamp(this.y, expanded.top, expanded.bottom);
|
|
if (ny) this.x = clamp(this.x, expanded.left, expanded.right);
|
|
if (toward < 0) {
|
|
this.vx = vx - 1.78 * toward * nx;
|
|
this.vy = vy - 1.78 * toward * ny;
|
|
} else {
|
|
this.vx = vx + nx * 18;
|
|
this.vy = vy + ny * 18;
|
|
}
|
|
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, source || rect.item || rect);
|
|
return;
|
|
}
|
|
if (d < 0.001) {
|
|
const left = Math.abs(this.x - rect.left);
|
|
const right = Math.abs(rect.right - this.x);
|
|
const top = Math.abs(this.y - rect.top);
|
|
const bottom = Math.abs(rect.bottom - this.y);
|
|
const m = Math.min(left, right, top, bottom);
|
|
if (m === left) { dx = -1; dy = 0; }
|
|
else if (m === right) { dx = 1; dy = 0; }
|
|
else if (m === top) { dx = 0; dy = -1; }
|
|
else { dx = 0; dy = 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;
|
|
} else {
|
|
this.vx = (this.vx || 0) + nx * 18;
|
|
this.vy = (this.vy || 0) + ny * 18;
|
|
}
|
|
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, source || rect.item || rect);
|
|
}
|
|
|
|
resolveBallFenceBounce(fence, worldRef) {
|
|
const rects = worldRef?.solidObstacleRects ? worldRef.solidObstacleRects(fence) : [];
|
|
for (const rect of rects) this.resolveBallRectBounce(rect, 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";
|
|
const grassLoad = worldRef.grassLoadLevel ? worldRef.grassLoadLevel() : 0;
|
|
if (worldRef.grassBlockedAt && worldRef.grassBlockedAt(this.x, this.y, this)) {
|
|
this.growth = Math.max(0, (this.growth ?? 0.25) - dt * 0.28);
|
|
this.amount -= dt * 34;
|
|
return;
|
|
}
|
|
let fertility = 0;
|
|
let fertileSource = null;
|
|
let bestFertility = 0;
|
|
let waterNear = weather === "light_rain" ? 0.35 : 0;
|
|
const nearbyRadius = grassLoad >= 3 ? 190 : grassLoad >= 2 ? 230 : grassLoad >= 1 ? 290 : 360;
|
|
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, nearbyRadius) : (worldRef.items || []);
|
|
let scanned = 0;
|
|
const scanLimit = grassLoad >= 3 ? 18 : grassLoad >= 2 ? 26 : grassLoad >= 1 ? 40 : 9999;
|
|
for (const it of nearby) {
|
|
if (++scanned > scanLimit) break;
|
|
if (it === this) continue;
|
|
const d = distXY(this.x, this.y, it.x, it.y);
|
|
if ((it.type === "trace" || it.type === "splat") && d < 170) {
|
|
const influence = clamp(1 - d / 170, 0, 1) * (it.type === "splat" ? 0.92 : 0.55);
|
|
fertility += influence;
|
|
if (influence > bestFertility) { bestFertility = influence; fertileSource = it; }
|
|
}
|
|
if (it.type === "zunchi" && (it.stage === "decomposing" || it.stage === "fertile_soil")) {
|
|
const influence = clamp(1 - d / nearbyRadius, 0, 1) * (it.stage === "fertile_soil" ? 1.25 : 0.72);
|
|
fertility += influence;
|
|
if (influence > bestFertility) {
|
|
bestFertility = influence;
|
|
fertileSource = it;
|
|
}
|
|
}
|
|
if (it.type === "water") waterNear += clamp(1 - d / 90, 0, 1) * 0.25;
|
|
}
|
|
this.fertilityBoost = clamp(fertility, 0, 1.4);
|
|
const weatherBoost = weather === "sunny" ? 0.09 : weather === "light_rain" ? 0.42 : 0.08;
|
|
const dryStress = weather === "sunny" && light > 0.78 && waterNear < 0.04 && this.fertilityBoost < 0.12 ? 0.012 : 0;
|
|
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 = 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);
|
|
}
|
|
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);
|
|
if (Math.random() < 0.42 + this.fertilityBoost * 0.18) this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 1);
|
|
}
|
|
}
|
|
seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) {
|
|
let made = 0;
|
|
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
|
|
? worldRef.findGrassSproutSpot(source, Boolean(fertile && fertileSource))
|
|
: { x: clamp(this.x + rand(-58, 58), 44, worldRef.w - 44), y: clamp(this.y + rand(-42, 42), 44, worldRef.h - 44) };
|
|
if (!spot) break;
|
|
const sprout = new Item("grass", spot.x, spot.y);
|
|
sprout.growth = rand(0.08, 0.18);
|
|
sprout.amount = sprout.growth * 120;
|
|
worldRef.items.push(sprout);
|
|
if (worldRef.itemCounts) worldRef.itemCounts.grass = (worldRef.itemCounts.grass || 0) + 1;
|
|
made += 1;
|
|
}
|
|
return made;
|
|
}
|
|
|
|
updateZunchi(dt, worldRef) {
|
|
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) {
|
|
if (it.type !== "water") continue;
|
|
waterBoost += clamp(1 - distXY(this.x, this.y, it.x, it.y) / 80, 0, 1);
|
|
}
|
|
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 * 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 * decayBoost;
|
|
}
|
|
}
|
|
get dead() {
|
|
return this.amount <= 0;
|
|
}
|
|
draw(ctx, t, lighting = null) {
|
|
const lightState = lighting || getLightingState(world);
|
|
const night = clamp(lightState.nightStrength * 1.35, 0, 1);
|
|
const warm = lightState.warmth;
|
|
const styleNight = lightState.light < 0.42;
|
|
const styleWarm = !styleNight && (lightState.goldenStrength > 0.22 || warm > 0.55);
|
|
const servingRatio = isServingFoodType(this.type) ? ((this.foodServingsRemaining ?? this.amount) / Math.max(1, this.foodServingsMax || foodServingsForSize(this.toolSize || "medium"))) : null;
|
|
const visibleAlpha = isServingFoodType(this.type) ? clamp(servingRatio ?? 1, 0.24, 1) : clamp(this.amount / 40, 0.24, 1);
|
|
const dropT = this.dropMax > 0 ? clamp(this.dropTimer / this.dropMax, 0, 1) : 0;
|
|
const dropFallHeight = this.type === "genkotsu" ? Math.max(360, this.r * 5.8) : 170;
|
|
const dropY = dropT > 0 ? -dropFallHeight * dropT : 0;
|
|
const shadow = projectedShadowParams(lightState, Math.max(0.4, this.r / 18));
|
|
if (this.type !== "trace" && this.type !== "splat") {
|
|
const zunchiId = this.type === "zunchi" ? (this.zunchiVariant || "zunchi") : null;
|
|
const zunchiImg = zunchiId ? (typeof getRenderableImage === "function" ? getRenderableImage(zunchiId, "zunchi") : (images.get(zunchiId) || images.get("zunchi"))) : null;
|
|
if (zunchiImg) {
|
|
const metrics = getImageMetrics(zunchiId || "zunchi") || getImageMetrics("zunchi");
|
|
const ratio = metrics?.ratio || 178 / 236;
|
|
const zunchiH = this.r * 1.66 * ratio;
|
|
drawImageProjectedShadow(ctx, zunchiImg, this.x, this.y + imageVisibleBottomFromTop(zunchiImg, -this.r * 1.35, zunchiH), this.r * 2.2, zunchiH, shadow, {
|
|
alpha: shadow.alpha * visibleAlpha * 1.18,
|
|
widthScale: 0.92,
|
|
heightScale: 0.92,
|
|
});
|
|
} else if (this.type === "grass") {
|
|
ctx.save();
|
|
ctx.globalAlpha = clamp(shadow.alpha * visibleAlpha * 0.62, 0.035, 0.13);
|
|
ctx.fillStyle = shadow.color;
|
|
ctx.beginPath();
|
|
ctx.ellipse(this.x, this.y + this.r * 0.46, this.r * 0.62, this.r * 0.12, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
} else {
|
|
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.52, this.r * 0.82, this.r * 0.26, { ...shadow, alpha: shadow.alpha * visibleAlpha * 1.25 });
|
|
}
|
|
}
|
|
drawItemGlow(ctx, this, lightState, visibleAlpha);
|
|
if (world.pointer?.inside && this.type !== "trace" && this.type !== "splat" && distXY(this.x, this.y, world.pointer.x, world.pointer.y) < this.r * 2.2) {
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.32;
|
|
ctx.strokeStyle = "rgba(255, 250, 220, 0.82)";
|
|
ctx.lineWidth = 1.4;
|
|
ctx.beginPath();
|
|
ctx.ellipse(this.x, this.y + this.r * 0.1, this.r * 1.35, this.r * 0.92, 0, 0, Math.PI * 2);
|
|
ctx.stroke();
|
|
ctx.restore();
|
|
}
|
|
ctx.save();
|
|
ctx.translate(this.x, this.y + dropY);
|
|
ctx.globalAlpha = visibleAlpha;
|
|
if (this.type === "food" || this.type === "sweet" || this.type === "love_mochi" || this.type === "fight_mochi") {
|
|
const sweet = this.type === "sweet";
|
|
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;
|
|
ctx.ellipse(0, bob, this.r * 1.25, this.r * 0.75, Math.sin(this.seed) * 0.4, 0, Math.PI * 2);
|
|
ctx.fill(); ctx.stroke();
|
|
if (sweet) {
|
|
ctx.beginPath();
|
|
ctx.fillStyle = "#76b94d";
|
|
ctx.ellipse(-1, -this.r * 0.24, this.r * 0.76, this.r * 0.26, Math.sin(this.seed) * 0.25, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = "rgba(255,255,255,0.62)";
|
|
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("\u2764", 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.strokeStyle = "rgba(168,76,42,0.82)";
|
|
ctx.lineWidth = 1.8;
|
|
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++) {
|
|
ctx.beginPath();
|
|
ctx.arc(randSeed(this.seed + i, -8, 8), randSeed(this.seed + i + 7, -5, 5), 1.6, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
} else if (this.type === "sleep_drug") {
|
|
ctx.fillStyle = "#f5f0ff";
|
|
ctx.strokeStyle = "rgba(112, 83, 170, 0.55)";
|
|
ctx.lineWidth = 2;
|
|
ctx.rotate(-0.45);
|
|
roundedRect(ctx, -this.r * 0.95, -this.r * 0.45, this.r * 1.90, this.r * 0.90, this.r * 0.45);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle = "rgba(152, 116, 218, 0.58)";
|
|
ctx.fillRect(-1, -this.r * 0.42, 2, this.r * 0.84);
|
|
ctx.rotate(0.45);
|
|
ctx.fillStyle = "rgba(74,58,104,0.72)";
|
|
ctx.font = `${Math.round((this.r || 13) * 0.78)}px sans-serif`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText("Z", 0, this.r * 1.18);
|
|
} else if (this.type === "genkotsu") {
|
|
const img = typeof getRenderableImage === "function" ? getRenderableImage("genkotsu", "genkotsu") : images.get("genkotsu");
|
|
const impact = clamp(this.impactFlash || 0, 0, 1);
|
|
ctx.save();
|
|
ctx.shadowColor = "rgba(54,42,31,0.26)";
|
|
ctx.shadowBlur = 5 + impact * 8;
|
|
ctx.shadowOffsetY = 4;
|
|
const squashY = 1 - impact * 0.08;
|
|
const stretchX = 1 + impact * 0.05;
|
|
ctx.scale(stretchX, squashY);
|
|
if (img) {
|
|
const metrics = typeof getImageMetrics === "function" ? getImageMetrics("genkotsu") : null;
|
|
const w = this.r * 2.55;
|
|
const h = w * (metrics?.ratio || 1.27);
|
|
ctx.drawImage(img, -w * 0.5, -h * 0.76, w, h);
|
|
} else {
|
|
ctx.fillStyle = "rgba(255,255,255,0.94)";
|
|
ctx.strokeStyle = "rgba(64,64,64,0.78)";
|
|
ctx.lineWidth = 5;
|
|
roundedRect(ctx, -this.r * 0.72, -this.r * 1.45, this.r * 1.44, this.r * 1.84, 18);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle = "rgba(42,46,52,0.92)";
|
|
ctx.font = `bold ${Math.round(this.r * 0.45)}px sans-serif`;
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText("\u6b63", 0, -this.r * 0.74);
|
|
ctx.fillText("\u7fa9", 0, -this.r * 0.27);
|
|
}
|
|
if (impact > 0.01) {
|
|
ctx.globalAlpha = impact * 0.20;
|
|
ctx.fillStyle = "#ffffff";
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, -this.r * 0.65, this.r * 0.92, this.r * 0.22, -0.10, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
} else if (this.type === "water_bowl") {
|
|
ctx.fillStyle = "rgba(171, 120, 76, 0.72)";
|
|
ctx.strokeStyle = "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.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.beginPath(); ctx.arc(-this.r * 0.32, -this.r * 0.18, 2.8, 0, Math.PI * 2); ctx.fill();
|
|
} else if (this.type === "water") {
|
|
ctx.fillStyle = "rgba(86, 157, 224, 0.50)";
|
|
ctx.strokeStyle = "rgba(62, 113, 178, 0.42)";
|
|
ctx.lineWidth = 2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, Math.sin(t * 1.7 + this.seed) * 1.5, this.r * 1.05, this.r * 0.8, 0, 0, Math.PI * 2);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle = "rgba(255,255,255,0.55)";
|
|
ctx.beginPath(); ctx.arc(-4, -4, 3, 0, Math.PI * 2); ctx.fill();
|
|
} 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";
|
|
const eaten = this.eatenAmount > 14 || growth < 0.16;
|
|
const grassLoad = 1;
|
|
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";
|
|
ctx.lineWidth = grassLoad >= 2 ? (styleNight ? 1.3 : 1.5) : (styleNight ? 1.7 : 2);
|
|
const matureBlades = grassLoad >= 3 ? 2 : grassLoad >= 2 ? 3 : grassLoad >= 1 ? 5 : 7;
|
|
const bladeCount = eaten ? 2 : (stage === "sprout" ? 2 : stage === "young" ? (grassLoad >= 2 ? 2 : 3) : matureBlades);
|
|
for (let i = 0; i < bladeCount; i++) {
|
|
const spread = stage === "mature" ? (grassLoad >= 2 ? 0.92 : 1.18) : 0.70;
|
|
const a = -Math.PI / 2 + randSeed(this.seed + i, -spread, spread);
|
|
const len = this.r * randSeed(this.seed + i + 10, 0.45, stage === "mature" ? (grassLoad >= 2 ? 1.08 : 1.30) : 1.05) * clamp(growth, 0.18, 1.00) * (eaten ? 0.42 : 1);
|
|
const rootX = stage === "mature" ? randSeed(this.seed + i + 31, -this.r * (grassLoad >= 2 ? 0.24 : 0.32), this.r * (grassLoad >= 2 ? 0.24 : 0.32)) : 0;
|
|
ctx.beginPath();
|
|
ctx.moveTo(rootX, 8);
|
|
ctx.quadraticCurveTo(rootX + Math.cos(a) * len * 0.55, 8 - len * 0.24, rootX + Math.cos(a) * len, Math.sin(a) * len * 0.78);
|
|
ctx.stroke();
|
|
}
|
|
if (stage === "mature" && !eaten && grassLoad <= 1) {
|
|
ctx.save();
|
|
ctx.globalAlpha = 0.16;
|
|
ctx.fillStyle = styleNight ? "rgba(95,132,114,0.65)" : "rgba(138,189,108,0.72)";
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 1, this.r * 0.72, this.r * 0.22, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
}
|
|
} else if (this.type === "stone") {
|
|
ctx.fillStyle = styleNight ? "#8f94a6" : (styleWarm ? "#e4d6b5" : "#d9d0b8");
|
|
ctx.strokeStyle = styleNight ? "#5f6478" : "#8a7c63";
|
|
ctx.shadowColor = "transparent";
|
|
ctx.shadowBlur = 0;
|
|
ctx.lineWidth = 2;
|
|
roundedBlob(ctx, 0, 0, this.r * 1.25, this.r * 0.85, 8);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle = "rgba(255,255,255,0.34)";
|
|
ctx.beginPath(); ctx.ellipse(-6, -5, 6, 3, -0.3, 0, Math.PI * 2); ctx.fill();
|
|
} else if (this.type === "bed") {
|
|
ctx.fillStyle = styleWarm ? "rgba(224, 190, 94, 0.90)" : (styleNight ? "rgba(177, 156, 95, 0.84)" : "rgba(205, 169, 86, 0.88)" );
|
|
ctx.strokeStyle = styleWarm ? "rgba(135, 101, 45, 0.62)" : (styleNight ? "rgba(102, 88, 56, 0.68)" : "rgba(117, 91, 47, 0.68)" );
|
|
ctx.lineWidth = 2.2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 3, this.r * 1.45, this.r * 0.72, -0.08, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.strokeStyle = "rgba(116, 89, 42, 0.45)";
|
|
ctx.lineWidth = 1.5;
|
|
for (let i = 0; i < 13; i++) {
|
|
const x = randSeed(this.seed + i, -this.r * 1.10, this.r * 1.08);
|
|
const y = randSeed(this.seed + i + 20, -this.r * 0.36, this.r * 0.46);
|
|
const len = randSeed(this.seed + i + 40, this.r * 0.34, this.r * 0.70);
|
|
const a = randSeed(this.seed + i + 60, -0.75, 0.75);
|
|
ctx.beginPath();
|
|
ctx.moveTo(x - Math.cos(a) * len * 0.5, y - Math.sin(a) * len * 0.5);
|
|
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
|
|
ctx.stroke();
|
|
}
|
|
} else if (this.type === "nest_box") {
|
|
ctx.fillStyle = styleNight ? "#5f4933" : (styleWarm ? "#89613e" : "#775237");
|
|
ctx.strokeStyle = styleNight ? "#31261f" : "#3f2e24";
|
|
ctx.lineWidth = 2.6;
|
|
roundedRect(ctx, -this.r * 1.28, -this.r * 0.80, this.r * 2.56, this.r * 1.52, 9);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle = styleNight ? "#6e5439" : "#8a5f3b";
|
|
roundedRect(ctx, -this.r * 1.12, -this.r * 0.62, this.r * 2.24, this.r * 1.18, 6);
|
|
ctx.fill();
|
|
ctx.strokeStyle = styleNight ? "rgba(42,32,25,0.76)" : "rgba(65,45,32,0.72)";
|
|
ctx.lineWidth = 2.0;
|
|
ctx.beginPath();
|
|
ctx.moveTo(-this.r * 0.95, -this.r * 0.50);
|
|
ctx.lineTo(this.r * 0.95, -this.r * 0.50);
|
|
ctx.moveTo(-this.r * 0.95, this.r * 0.48);
|
|
ctx.lineTo(this.r * 0.95, this.r * 0.48);
|
|
ctx.stroke();
|
|
ctx.fillStyle = styleNight ? "#2b211c" : "#3c281f";
|
|
ctx.beginPath();
|
|
ctx.arc(0, -this.r * 0.08, this.r * 0.40, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
} 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");
|
|
ctx.strokeStyle = styleNight ? "rgba(43,32,26,0.78)" : "rgba(72,48,31,0.72)";
|
|
ctx.lineWidth = 2.2;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 8, this.r * 1.35, this.r * 0.78, 0.08, 0, Math.PI * 2);
|
|
ctx.fill(); ctx.stroke();
|
|
ctx.fillStyle = styleNight ? "#211a17" : "#30231d";
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 2, this.r * 0.58, this.r * 0.36, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = "rgba(38,37,35,0.88)";
|
|
for (let i = 0; i < Math.min(6, count); i++) {
|
|
const ax = randSeed(this.seed + i * 11, -this.r * 1.0, this.r * 1.0);
|
|
const ay = randSeed(this.seed + i * 17, -this.r * 0.45, this.r * 0.55);
|
|
ctx.beginPath();
|
|
ctx.ellipse(ax, ay, 3.4, 2.1, randSeed(this.seed + i * 23, -0.8, 0.8), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.fillStyle = "rgba(255,248,220,0.82)";
|
|
ctx.font = "bold 10px ui-rounded, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.textBaseline = "middle";
|
|
ctx.fillText(`${count}`, 0, this.r * 1.25);
|
|
} else if (this.type === "ant_corpse") {
|
|
const img = typeof getRenderableImage === "function" ? getRenderableImage("ant_worker", "ant_worker") : images.get("ant_worker");
|
|
ctx.save();
|
|
ctx.rotate((stableUnit(this.id || this.seed, "ant-corpse-rot") - 0.5) * 0.26);
|
|
ctx.scale(1, -1);
|
|
ctx.globalAlpha *= clamp((this.amount || 0) / 24, 0.35, 1);
|
|
if (img) {
|
|
const w = this.r * 3.9;
|
|
const metrics = getImageMetrics("ant_worker");
|
|
const h = w * (metrics?.ratio || 0.52);
|
|
ctx.drawImage(img, -w * 0.5, -h * 0.55, w, h);
|
|
} else {
|
|
ctx.fillStyle = "rgba(42,39,35,0.85)";
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, this.r * 1.35, this.r * 0.72, 0, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
ctx.restore();
|
|
} 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("\u26bd", 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;
|
|
ctx.rotate(-0.22 + Math.sin(this.seed) * 0.12);
|
|
ctx.fillStyle = flash > 0.65 ? "#ffed77" : "#d94b43";
|
|
ctx.strokeStyle = "rgba(96,48,34,0.78)";
|
|
ctx.lineWidth = 2;
|
|
roundedRect(ctx, -this.r * 0.72, -this.r * 0.86, this.r * 1.44, this.r * 1.72, 5);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = "#f4d15b";
|
|
ctx.fillRect(-this.r * 0.55, -this.r * 0.54, this.r * 1.1, this.r * 0.20);
|
|
ctx.fillRect(-this.r * 0.55, this.r * 0.34, this.r * 1.1, this.r * 0.20);
|
|
ctx.strokeStyle = "rgba(74,55,39,0.82)";
|
|
ctx.lineWidth = 2.2;
|
|
ctx.beginPath();
|
|
ctx.moveTo(0, -this.r * 0.88);
|
|
ctx.quadraticCurveTo(this.r * 0.32, -this.r * 1.34, this.r * 0.92, -this.r * 1.42);
|
|
ctx.stroke();
|
|
ctx.fillStyle = flash > 0.1 ? "rgba(255,221,82,0.92)" : "rgba(255,162,62,0.78)";
|
|
ctx.beginPath();
|
|
ctx.arc(this.r * 0.98, -this.r * 1.42, 2.5 + flash * 2.2, 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.fillStyle = "rgba(42,36,29,0.66)";
|
|
ctx.font = "bold 10px ui-rounded, sans-serif";
|
|
ctx.textAlign = "center";
|
|
ctx.fillText(String(Math.ceil(this.fuseTimer ?? 5)), 0, this.r * 1.42);
|
|
} else if (this.type === "fence_v" || this.type === "fence_h") {
|
|
const vertical = this.type === "fence_v";
|
|
const len = Math.max(112, this.r * 3.55);
|
|
const thick = Math.max(10, this.r * 0.31);
|
|
ctx.save();
|
|
ctx.rotate(vertical ? 0 : Math.PI / 2);
|
|
const wood = styleNight ? "#7a5b3c" : (styleWarm ? "#a96f35" : "#965f31");
|
|
const edge = styleNight ? "#4d3d2f" : "#5f3f25";
|
|
const hi = styleNight ? "rgba(198,166,116,0.20)" : "rgba(232,176,94,0.32)";
|
|
ctx.fillStyle = wood;
|
|
ctx.strokeStyle = edge;
|
|
ctx.lineWidth = 2.4;
|
|
ctx.beginPath();
|
|
roundedRect(ctx, -thick / 2, -len / 2, thick, len, thick / 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
ctx.fillStyle = hi;
|
|
ctx.beginPath();
|
|
roundedRect(ctx, -thick * 0.26, -len / 2 + 6, thick * 0.20, len - 12, thick / 3);
|
|
ctx.fill();
|
|
ctx.restore();
|
|
} else if (this.type === "zunchi") {
|
|
const zunchiId = this.zunchiVariant || "zunchi";
|
|
const img = images.get(zunchiId) || images.get("zunchi");
|
|
if (img) {
|
|
const metrics = getImageMetrics(zunchiId) || getImageMetrics("zunchi");
|
|
const ratio = metrics?.ratio || 178 / 236;
|
|
const stageAlpha = this.stage === "fresh" ? clamp(this.freshness ?? 1, 0.45, 1) : this.stage === "dry" ? 0.82 : this.stage === "decomposing" ? 0.62 : clamp(this.fertility ?? 0.28, 0.24, 0.46);
|
|
ctx.globalAlpha *= stageAlpha;
|
|
ctx.drawImage(img, -this.r * 1.1, -this.r * 1.35, this.r * 2.2, this.r * 1.66 * ratio);
|
|
}
|
|
} else if (this.type === "trace") {
|
|
ctx.fillStyle = "rgba(122, 100, 76, 0.12)";
|
|
ctx.strokeStyle = "rgba(122, 100, 76, 0.16)";
|
|
ctx.lineWidth = 1.5;
|
|
ctx.beginPath();
|
|
ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.75, Math.sin(this.seed) * 0.7, 0, Math.PI * 2);
|
|
ctx.fill(); ctx.stroke();
|
|
} else if (this.type === "splat") {
|
|
const fade = clamp(this.amount / 260, 0, 1);
|
|
ctx.globalAlpha = visibleAlpha * fade;
|
|
ctx.fillStyle = `rgba(123, 214, 52, ${0.34 * fade})`;
|
|
ctx.strokeStyle = `rgba(85, 160, 38, ${0.22 * fade})`;
|
|
ctx.lineWidth = 1.8;
|
|
const blobs = [
|
|
[-12, -2, 10, 8], [0, 0, 16, 10], [13, 5, 10, 8], [-2, -12, 9, 7], [7, -8, 7, 5], [-18, 8, 6, 5]
|
|
];
|
|
for (const [bx, by, bw, bh] of blobs) {
|
|
ctx.beginPath();
|
|
ctx.ellipse(bx, by, bw, bh, randSeed(this.seed + bx * 0.1, -0.5, 0.5), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
ctx.stroke();
|
|
}
|
|
for (let i = 0; i < 7; i++) {
|
|
ctx.beginPath();
|
|
ctx.arc(randSeed(this.seed + i, -28, 24), randSeed(this.seed + i + 9, -18, 18), randSeed(this.seed + i + 19, 2.5, 4.8), 0, Math.PI * 2);
|
|
ctx.fill();
|
|
}
|
|
}
|
|
ctx.restore();
|
|
}
|
|
}
|