git
This commit is contained in:
parent
874911821f
commit
10fbeb2327
18 changed files with 794 additions and 16 deletions
403
js/ants.js
Normal file
403
js/ants.js
Normal file
|
|
@ -0,0 +1,403 @@
|
|||
"use strict";
|
||||
|
||||
const ANT_NEST_START_COUNT = 6;
|
||||
const ANT_NEST_MAX_COUNT = 10;
|
||||
const ANT_NEST_MAX_OUTSIDE = 3;
|
||||
const ANT_WORKER_HP = 32;
|
||||
const ANT_QUEEN_HP = 180;
|
||||
const ANT_WORKER_RADIUS = 4.2;
|
||||
const ANT_QUEEN_RADIUS = 9.5;
|
||||
const ANT_WORKER_SEARCH_SPEED = 8.625;
|
||||
const ANT_WORKER_RETURN_SPEED = 12.0;
|
||||
const ANT_WORKER_RETURN_WEAK_SPEED = 6.0;
|
||||
const ANT_WORKER_DRAG_SPEED = 4.125;
|
||||
const ANT_QUEEN_SPEED = 9.0;
|
||||
|
||||
function clampAntHp(v) {
|
||||
return clamp(Number(v) || 0, 0, ANT_WORKER_HP);
|
||||
}
|
||||
|
||||
function buildAntWorkerPool(count = ANT_NEST_START_COUNT) {
|
||||
const n = clamp(Math.round(Number(count) || 0), 0, ANT_NEST_MAX_COUNT);
|
||||
return Array.from({ length: n }, () => ANT_WORKER_HP);
|
||||
}
|
||||
|
||||
function sanitizeAntWorkerPool(list, fallbackCount = 0) {
|
||||
if (!Array.isArray(list)) return buildAntWorkerPool(fallbackCount);
|
||||
const out = list.map(v => clampAntHp(v)).filter(v => v > 0.01);
|
||||
return out.slice(0, ANT_NEST_MAX_COUNT);
|
||||
}
|
||||
|
||||
function ensureAntNestWorkerPool(nest) {
|
||||
if (!nest || nest.type !== "ant_nest") return [];
|
||||
if (!Array.isArray(nest.antWorkers)) {
|
||||
nest.antWorkers = buildAntWorkerPool(nest.antCount ?? ANT_NEST_START_COUNT);
|
||||
} else {
|
||||
nest.antWorkers = sanitizeAntWorkerPool(nest.antWorkers, nest.antCount ?? nest.antWorkers.length);
|
||||
}
|
||||
return nest.antWorkers;
|
||||
}
|
||||
|
||||
function syncAntNestCount(worldRef, nest) {
|
||||
if (!nest || nest.type !== "ant_nest") return 0;
|
||||
ensureAntNestWorkerPool(nest);
|
||||
const outside = (worldRef?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length;
|
||||
nest.antCount = clamp((nest.antWorkers?.length || 0) + outside, 0, ANT_NEST_MAX_COUNT);
|
||||
return nest.antCount;
|
||||
}
|
||||
|
||||
class AntActor {
|
||||
constructor(worldRef, opts = {}) {
|
||||
this.world = worldRef;
|
||||
this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `ant-${Date.now()}-${Math.random()}`);
|
||||
this.kind = opts.kind || "worker";
|
||||
this.homeId = opts.homeId || "";
|
||||
this.targetId = opts.targetId || "";
|
||||
this.x = Number.isFinite(opts.x) ? opts.x : 0;
|
||||
this.y = Number.isFinite(opts.y) ? opts.y : 0;
|
||||
this.vx = opts.vx || 0;
|
||||
this.vy = opts.vy || 0;
|
||||
this.r = opts.r || (this.kind === "queen" ? ANT_QUEEN_RADIUS : ANT_WORKER_RADIUS);
|
||||
this.maxHp = Number.isFinite(opts.maxHp) ? opts.maxHp : (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP);
|
||||
this.hp = Number.isFinite(opts.hp) ? opts.hp : this.maxHp;
|
||||
this.state = opts.state || (this.kind === "queen" ? "founding" : "search");
|
||||
this.age = opts.age || 0;
|
||||
this.seed = opts.seed || Math.random() * 1000;
|
||||
this.wanderAngle = opts.wanderAngle || rand(0, Math.PI * 2);
|
||||
this.nextTurn = opts.nextTurn || rand(0.4, 1.6);
|
||||
this.returnTimer = opts.returnTimer || 0;
|
||||
this.foundingX = opts.foundingX;
|
||||
this.foundingY = opts.foundingY;
|
||||
this.foundingDelayUntil = Number.isFinite(opts.foundingDelayUntil) ? opts.foundingDelayUntil : -Infinity;
|
||||
this.carryLogAt = opts.carryLogAt || -999;
|
||||
this.foundingAttempts = opts.foundingAttempts || 0;
|
||||
this.dead = Boolean(opts.dead);
|
||||
}
|
||||
|
||||
serialize() {
|
||||
return {
|
||||
id: this.id, kind: this.kind, homeId: this.homeId, targetId: this.targetId,
|
||||
x: this.x, y: this.y, vx: this.vx, vy: this.vy, r: this.r, hp: this.hp, maxHp: this.maxHp,
|
||||
state: this.state, age: this.age, seed: this.seed, wanderAngle: this.wanderAngle,
|
||||
nextTurn: this.nextTurn, returnTimer: this.returnTimer, foundingX: this.foundingX,
|
||||
foundingY: this.foundingY, foundingDelayUntil: this.foundingDelayUntil, carryLogAt: this.carryLogAt, foundingAttempts: this.foundingAttempts, dead: this.dead,
|
||||
};
|
||||
}
|
||||
|
||||
static from(worldRef, data) {
|
||||
return new AntActor(worldRef, data || {});
|
||||
}
|
||||
|
||||
homeNest() {
|
||||
return (this.world?.items || []).find(it => it && !it.dead && it.id === this.homeId && it.type === "ant_nest") || null;
|
||||
}
|
||||
|
||||
targetTarinai() {
|
||||
return (this.world?.tarinai || []).find(t => t && !t.dead && t.id === this.targetId) || null;
|
||||
}
|
||||
|
||||
activeHaulersForTarget() {
|
||||
return (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId && a.targetId === this.targetId);
|
||||
}
|
||||
|
||||
isLeadHauler() {
|
||||
const haulers = this.activeHaulersForTarget();
|
||||
if (!haulers.length) return true;
|
||||
let lead = haulers[0];
|
||||
for (const a of haulers) if (String(a.id) < String(lead.id)) lead = a;
|
||||
return lead === this;
|
||||
}
|
||||
|
||||
chooseTarget(maxDist = 56) {
|
||||
let best = null;
|
||||
let bestScore = Infinity;
|
||||
for (const t of this.world?.tarinai || []) {
|
||||
if (!t || t.dead || t.insideNestBoxId || t.isZunchiSlave) continue;
|
||||
const d = distXY(this.x, this.y, t.x, t.y);
|
||||
if (d > maxDist) continue;
|
||||
if ((t.antDraggedTimer || 0) > 0 && (t.antDraggedBy || "") && t.antDraggedBy !== this.id) {
|
||||
const haulers = (this.world?.ants || []).filter(a => a && !a.dead && a.kind === "worker" && a.state === "drag" && a.targetId === t.id).length;
|
||||
if (haulers >= ANT_NEST_MAX_OUTSIDE) continue;
|
||||
}
|
||||
const weak = clamp((100 - (t.energy || 0)) / 100, 0, 1);
|
||||
const score = d - weak * 18 + stableUnit(`${this.id}:${t.id}`, "ant-prey") * 6;
|
||||
if (score < bestScore) {
|
||||
best = t;
|
||||
bestScore = score;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
update(dt) {
|
||||
if (this.dead) return;
|
||||
this.age += dt;
|
||||
if (this.kind === "queen") return this.updateQueen(dt);
|
||||
return this.updateWorker(dt);
|
||||
}
|
||||
|
||||
updateWorker(dt) {
|
||||
const home = this.homeNest();
|
||||
if (!home) {
|
||||
this.dead = true;
|
||||
return;
|
||||
}
|
||||
if (this.hp <= 0) {
|
||||
this.dieAsCorpse(home);
|
||||
return;
|
||||
}
|
||||
if (this.state === "drag") return this.updateDrag(dt, home);
|
||||
if (this.state === "return") return this.updateReturn(dt, home);
|
||||
this.updateSearch(dt, home);
|
||||
}
|
||||
|
||||
updateSearch(dt, home) {
|
||||
const target = this.chooseTarget(52);
|
||||
if (target) {
|
||||
this.state = "drag";
|
||||
this.targetId = target.id;
|
||||
this.carryLogAt = this.world?.time || 0;
|
||||
target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.45);
|
||||
target.antDraggedBy = this.id;
|
||||
target.fearTimer = Math.max(target.fearTimer || 0, 0.55);
|
||||
target.thought = "\u30a2\u30ea\u306b\u904b\u3070\u308c\u3066\u3044\u308b";
|
||||
this.world.drawListDirty = true;
|
||||
return;
|
||||
}
|
||||
this.nextTurn -= dt;
|
||||
if (this.nextTurn <= 0) {
|
||||
const toHome = Math.atan2(home.y - this.y, home.x - this.x);
|
||||
const far = distXY(this.x, this.y, home.x, home.y);
|
||||
const bias = far > 220 ? 0.58 : 0.18;
|
||||
this.wanderAngle = this.wanderAngle * (1 - bias) + toHome * bias + rand(-1.2, 1.2);
|
||||
this.nextTurn = rand(0.7, 1.9);
|
||||
}
|
||||
const hpFactor = clamp(this.hp / ANT_WORKER_HP, 0.45, 1.0);
|
||||
const speed = (ANT_WORKER_SEARCH_SPEED + Math.sin(this.age * 2.4 + this.seed) * 0.75) * hpFactor;
|
||||
this.vx = Math.cos(this.wanderAngle) * speed;
|
||||
this.vy = Math.sin(this.wanderAngle) * speed * 0.78;
|
||||
this.x = clamp(this.x + this.vx * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
|
||||
this.y = clamp(this.y + this.vy * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
|
||||
if (distXY(this.x, this.y, home.x, home.y) > 220) this.state = "return";
|
||||
}
|
||||
|
||||
returnToNest(home) {
|
||||
if (!home || home.dead || home.type !== "ant_nest") {
|
||||
this.dead = true;
|
||||
return;
|
||||
}
|
||||
ensureAntNestWorkerPool(home).push(clampAntHp(this.hp));
|
||||
syncAntNestCount(this.world, home);
|
||||
this.dead = true;
|
||||
this.world.drawListDirty = true;
|
||||
}
|
||||
|
||||
updateReturn(dt, home) {
|
||||
const d = distXY(this.x, this.y, home.x, home.y) || 1;
|
||||
if (d < Math.max(16, home.r * 0.66)) {
|
||||
this.returnToNest(home);
|
||||
return;
|
||||
}
|
||||
const speed = this.hp <= 8 ? ANT_WORKER_RETURN_WEAK_SPEED : ANT_WORKER_RETURN_SPEED;
|
||||
this.x += (home.x - this.x) / d * speed * dt;
|
||||
this.y += (home.y - this.y) / d * speed * dt;
|
||||
this.vx = (home.x - this.x) / d * speed;
|
||||
this.vy = (home.y - this.y) / d * speed;
|
||||
}
|
||||
|
||||
updateDrag(dt, home) {
|
||||
const target = this.targetTarinai();
|
||||
if (!target || target.dead || target.insideNestBoxId) {
|
||||
this.targetId = "";
|
||||
this.state = "return";
|
||||
return;
|
||||
}
|
||||
const haulers = Math.max(1, this.activeHaulersForTarget().length);
|
||||
const leadHauler = this.isLeadHauler();
|
||||
target.antDraggedTimer = Math.max(target.antDraggedTimer || 0, 0.35);
|
||||
target.antDraggedBy = this.id;
|
||||
target.state = "panic";
|
||||
target.sleeping = false;
|
||||
target.target = null;
|
||||
target.fearTimer = Math.max(target.fearTimer || 0, 0.36);
|
||||
target.thought = "\u30a2\u30ea\u306b\u5de3\u3078\u904b\u3070\u308c\u3066\u3044\u308b";
|
||||
const dHome = distXY(target.x, target.y, home.x, home.y) || 1;
|
||||
if (dHome < Math.max(18, home.r * 0.66)) {
|
||||
this.world.completeAntHaul?.(home, target);
|
||||
this.targetId = "";
|
||||
this.state = "return";
|
||||
return;
|
||||
}
|
||||
const hpFactor = clamp(this.hp / ANT_WORKER_HP, 0.45, 1.0);
|
||||
const speed = ANT_WORKER_DRAG_SPEED * hpFactor;
|
||||
const ux = (home.x - target.x) / dHome;
|
||||
const uy = (home.y - target.y) / dHome;
|
||||
if (leadHauler) {
|
||||
target.x = clamp(target.x + ux * speed * dt, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding);
|
||||
target.y = clamp(target.y + uy * speed * dt, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding);
|
||||
target.energy = clamp((target.energy || 0) - dt * 0.028, 0, 100);
|
||||
}
|
||||
const offsetAngle = this.seed + this.age * 2.0 + (haulers > 1 ? this.activeHaulersForTarget().findIndex(a => a === this) * 1.7 : 0);
|
||||
const ring = target.radius * (0.64 + 0.06 * Math.min(haulers, 3));
|
||||
this.x = target.x - ux * ring + Math.cos(offsetAngle) * target.radius * 0.24;
|
||||
this.y = target.y - uy * ring * 0.72 + Math.sin(offsetAngle) * target.radius * 0.18;
|
||||
this.vx = ux * speed;
|
||||
this.vy = uy * speed;
|
||||
|
||||
const aggression = Math.max(0, target.currentPersonality?.aggression || 0);
|
||||
const aggressiveAttack = target.shouldApplyPersonalityBehavior?.("aggression", 1) || aggression >= 0.5 || target.state === "ant_attack";
|
||||
const struggle = aggressiveAttack
|
||||
? (1.25 + aggression * 1.6 + clamp((target.energy || 0) / 100, 0, 1) * 0.35) / Math.max(1, haulers * 0.9)
|
||||
: (0.42 + clamp((target.energy || 0) / 100, 0, 1) * 0.18) / Math.max(1, haulers);
|
||||
this.hp -= dt * struggle;
|
||||
|
||||
if ((this.world.time || 0) - (this.carryLogAt || -999) > 8 && Math.random() < dt * 0.04) {
|
||||
this.carryLogAt = this.world.time || 0;
|
||||
this.world.log(`${target.name}\u304c\u30a2\u30ea\u306b\u5f15\u3063\u5f35\u3089\u308c\u3066\u3044\u308b\u3002`, "event");
|
||||
}
|
||||
if (this.hp <= 0) this.dieAsCorpse(home);
|
||||
}
|
||||
|
||||
dieAsCorpse(home = null) {
|
||||
if (this.dead) return;
|
||||
this.dead = true;
|
||||
const corpse = new Item("ant_corpse", this.x, this.y);
|
||||
corpse.amount = 24;
|
||||
corpse.workerSprite = this.workerSpriteId();
|
||||
this.world.items.push(corpse);
|
||||
this.world.effects?.push(new Effect("zunchi_miasma", this.x, this.y - 4, { size: 10, life: 0.44, color: "rgba(82,61,42,0.28)" }));
|
||||
if (home) syncAntNestCount(this.world, home);
|
||||
this.world.updateItemCounts?.();
|
||||
this.world.drawListDirty = true;
|
||||
}
|
||||
|
||||
buildNestAtFoundingSpot(force = false) {
|
||||
if (!this.world) return null;
|
||||
const base = new Item("ant_nest", this.foundingX, this.foundingY);
|
||||
let spot = null;
|
||||
if (this.world.findPlacementSpot) {
|
||||
spot = this.world.findPlacementSpot(base, { allowOriginal: true, maxRadius: force ? 520 : 220, attempts: force ? 160 : 72 });
|
||||
}
|
||||
if (!spot && force) {
|
||||
spot = this.world.placementClampPointFor ? this.world.placementClampPointFor(base, this.foundingX, this.foundingY) : { x: this.foundingX, y: this.foundingY };
|
||||
}
|
||||
if (!spot) return null;
|
||||
base.x = spot.x;
|
||||
base.y = spot.y;
|
||||
base.antCount = ANT_NEST_START_COUNT;
|
||||
base.antWorkers = buildAntWorkerPool(ANT_NEST_START_COUNT);
|
||||
if (!force && this.world.placementBlocked?.(base)) return null;
|
||||
this.world.items.push(base);
|
||||
this.world.itemCounts[base.type] = (this.world.itemCounts[base.type] || 0) + 1;
|
||||
this.world.rebuildSpatial?.();
|
||||
this.world.drawListDirty = true;
|
||||
this.world.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u30a2\u30ea\u306e\u5de3\u3092\u4f5c\u3063\u305f\u3002", "event");
|
||||
return base;
|
||||
}
|
||||
|
||||
updateQueen(dt) {
|
||||
if (!Number.isFinite(this.foundingX) || !Number.isFinite(this.foundingY)) {
|
||||
const p = this.world?.findAntNestFoundingSpot?.(this) || { x: this.x + rand(-140, 140), y: this.y + rand(-100, 100) };
|
||||
this.foundingX = p.x;
|
||||
this.foundingY = p.y;
|
||||
}
|
||||
let d = distXY(this.x, this.y, this.foundingX, this.foundingY) || 1;
|
||||
if (d < 20) {
|
||||
if ((this.world?.time || 0) < (this.foundingDelayUntil || -Infinity)) {
|
||||
this.vx = 0;
|
||||
this.vy = 0;
|
||||
return;
|
||||
}
|
||||
this.foundingAttempts += 1;
|
||||
const placed = this.buildNestAtFoundingSpot(this.foundingAttempts >= 8);
|
||||
if (placed) {
|
||||
this.dead = true;
|
||||
return;
|
||||
}
|
||||
const retrySpot = this.world?.findAntNestFoundingSpot?.({ x: this.x, y: this.y }) || this.world?.findAntNestFoundingSpot?.(this.homeNest?.() || this);
|
||||
if (retrySpot) {
|
||||
this.foundingX = retrySpot.x;
|
||||
this.foundingY = retrySpot.y;
|
||||
} else {
|
||||
this.wanderAngle += rand(-1.4, 1.4);
|
||||
this.foundingX = clamp(this.x + Math.cos(this.wanderAngle) * 90, 56, this.world.w - 56);
|
||||
this.foundingY = clamp(this.y + Math.sin(this.wanderAngle) * 70, 56, this.world.h - 56);
|
||||
}
|
||||
d = distXY(this.x, this.y, this.foundingX, this.foundingY) || 1;
|
||||
}
|
||||
const speed = ANT_QUEEN_SPEED;
|
||||
this.x += (this.foundingX - this.x) / d * speed * dt;
|
||||
this.y += (this.foundingY - this.y) / d * speed * dt;
|
||||
this.vx = (this.foundingX - this.x) / d * speed;
|
||||
this.vy = (this.foundingY - this.y) / d * speed;
|
||||
}
|
||||
|
||||
workerSpriteId() {
|
||||
return "ant_worker";
|
||||
}
|
||||
|
||||
spriteId() {
|
||||
return this.kind === "queen" ? "ant_queen" : this.workerSpriteId();
|
||||
}
|
||||
|
||||
draw(ctx, time = 0, lighting = null) {
|
||||
if (this.dead) return;
|
||||
const id = this.spriteId();
|
||||
const img = typeof getRenderableImage === "function" ? getRenderableImage(id, id) : images.get(id);
|
||||
const lightState = lighting || getLightingState(this.world);
|
||||
ctx.save();
|
||||
const shadow = projectedShadowParams(lightState, this.kind === "queen" ? 0.58 : 0.26);
|
||||
drawProjectedShadow(ctx, this.x, this.y + this.r * 0.62, this.r * 1.5, this.r * 0.30, { ...shadow, alpha: shadow.alpha * 0.72 });
|
||||
ctx.translate(this.x, this.y);
|
||||
const angle = this.kind === "queen"
|
||||
? Math.atan2((this.foundingY ?? this.y) - this.y, (this.foundingX ?? this.x) - this.x)
|
||||
: Math.atan2(this.vy || 0, this.vx || 1);
|
||||
ctx.rotate(angle);
|
||||
if (img) {
|
||||
const w = this.kind === "queen" ? this.r * 4.1 : this.r * 3.8;
|
||||
const metrics = getImageMetrics(id);
|
||||
const h = w * (metrics?.ratio || 0.52);
|
||||
ctx.globalAlpha = 0.96;
|
||||
ctx.drawImage(img, -w * 0.5, -h * 0.58, w, h);
|
||||
} else {
|
||||
ctx.fillStyle = "rgba(44,44,44,0.92)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(0, 0, this.r * 1.3, this.r * 0.72, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.beginPath();
|
||||
ctx.arc(this.r * 1.15, -this.r * 0.05, this.r * 0.46, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
ctx.restore();
|
||||
this.drawHealthBar(ctx);
|
||||
}
|
||||
|
||||
drawHealthBar(ctx) {
|
||||
const maxHp = Math.max(1, this.maxHp || (this.kind === "queen" ? ANT_QUEEN_HP : ANT_WORKER_HP));
|
||||
const pct = clamp((this.hp || 0) / maxHp, 0, 1);
|
||||
const bw = this.kind === "queen" ? 28 : 18;
|
||||
const bh = this.kind === "queen" ? 4 : 3;
|
||||
const y = this.y - this.r * (this.kind === "queen" ? 2.0 : 2.6);
|
||||
ctx.save();
|
||||
ctx.fillStyle = "rgba(255,252,242,0.86)";
|
||||
roundedRect(ctx, this.x - bw / 2 - 1.5, y - 1.5, bw + 3, bh + 3, 4);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "rgba(63,50,42,0.30)";
|
||||
roundedRect(ctx, this.x - bw / 2, y, bw, bh, 3);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = pct > 0.45 ? "rgba(118,190,80,0.88)" : (pct > 0.20 ? "rgba(232,172,68,0.90)" : "rgba(218,82,70,0.92)");
|
||||
roundedRect(ctx, this.x - bw / 2, y, bw * pct, bh, 3);
|
||||
ctx.fill();
|
||||
ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
window.AntActor = AntActor;
|
||||
window.ANT_NEST_START_COUNT = ANT_NEST_START_COUNT;
|
||||
window.ANT_NEST_MAX_COUNT = ANT_NEST_MAX_COUNT;
|
||||
window.ANT_NEST_MAX_OUTSIDE = ANT_NEST_MAX_OUTSIDE;
|
||||
window.ANT_WORKER_HP = ANT_WORKER_HP;
|
||||
window.ANT_QUEEN_HP = ANT_QUEEN_HP;
|
||||
window.buildAntWorkerPool = buildAntWorkerPool;
|
||||
window.ensureAntNestWorkerPool = ensureAntNestWorkerPool;
|
||||
window.syncAntNestCount = syncAntNestCount;
|
||||
Loading…
Add table
Add a link
Reference in a new issue