git
|
|
@ -33,3 +33,22 @@
|
|||
- Shortened ecology cards and added the HP/stress meter placement note: upper meter is HP, lower meter is stress.
|
||||
- Raised the friendship threshold to 16 affinity and reused the same threshold for friend count, friend behavior, friend UI, and observe highlights.
|
||||
- Observe mode now highlights friends of the selected tarinai as well as parents, children, and enemies.
|
||||
|
||||
## Ant nest update
|
||||
- Added a new placeable `ant_nest` object and tool button.
|
||||
- Added ant sprites cropped from the provided source image: queen, worker variants, and upside-down worker corpse.
|
||||
- Each ant nest starts with 6 workers, caps at 10 workers, and sends at most 3 workers outside at once.
|
||||
- Worker ants spawn above their home nest, random-walk, search for tarinai, and drag found tarinai back to the nest. Multiple workers dragging the same tarinai increase carrying speed.
|
||||
- Dragged tarinai struggle automatically and can kill workers. Dead workers become edible `ant_corpse` items and slightly reduce hygiene.
|
||||
- If a nest below capacity brings back a tarinai, its worker count increases by 1.
|
||||
- If a full nest brings back another tarinai, a queen ant appears and creates a new ant nest in an open spot.
|
||||
- Ant health does not recover.
|
||||
- Added the requested Irasutoya credit entry.
|
||||
|
||||
## Ant nest follow-up v5
|
||||
- Increased ant movement and hauling constants by 1.5x from the previous slow tuning.
|
||||
- Kept queen spawning to at most one queen per nest per in-game day, while making spawned queens keep retrying until a new ant nest is built somewhere.
|
||||
- Reduced the ant nest object radius from 34 to 17.
|
||||
- Removed the separate ant corpse sprite asset. Ant corpses now render by flipping the worker ant sprite vertically and remain stationary while decaying.
|
||||
- Added shared ant damage handling for firecracker explosions and dropped-object impacts.
|
||||
- Kept ant worker HP in the nest worker pool so damaged workers remain damaged after returning to and leaving the nest.
|
||||
|
|
|
|||
BIN
assets/objects/ant_corpse.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/objects/ant_queen.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
BIN
assets/objects/ant_worker.png
Normal file
|
After Width: | Height: | Size: 15 KiB |
BIN
assets/objects/ant_worker_mid.png
Normal file
|
After Width: | Height: | Size: 20 KiB |
BIN
assets/objects/ant_worker_small.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/objects/ant_worker_small_b.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/ui/tool_ant_nest.png
Normal file
|
After Width: | Height: | Size: 1.1 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.4 KiB |
|
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.8 KiB |
|
|
@ -1416,3 +1416,32 @@ body::before {
|
|||
opacity: 0.72;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.tool[data-tool="ant_nest"] { --tool-icon: url("../assets/ui/tool_ant_nest.png"); }
|
||||
.tool[data-tool="ant_nest"]::before {
|
||||
content: "";
|
||||
background: var(--tool-icon) center / contain no-repeat;
|
||||
}
|
||||
.tool[data-tool="ant_nest"]::after {
|
||||
content: "";
|
||||
background: var(--tool-icon) right -8px center / 54px 54px no-repeat;
|
||||
}
|
||||
|
||||
/* v15.7.2: object-faithful tool icons for ball and nest box. */
|
||||
.tool[data-tool="ball"] { --tool-icon: url("../assets/ui/tool_ball.png"); }
|
||||
.tool[data-tool="ball"]::before,
|
||||
.tool[data-tool="ball"]::after {
|
||||
content: "";
|
||||
background: var(--tool-icon) center / contain no-repeat;
|
||||
font-size: 0;
|
||||
}
|
||||
.tool[data-tool="ball"]::after {
|
||||
background-position: right -8px center;
|
||||
background-size: 54px 54px;
|
||||
opacity: 0.10;
|
||||
}
|
||||
.tool[data-tool="nest_box"] { --tool-icon: url("../assets/ui/tool_nest_box.png"); }
|
||||
.tool[data-tool="nest_box"]::after {
|
||||
content: "";
|
||||
background: var(--tool-icon) right -8px center / 54px 54px no-repeat;
|
||||
opacity: 0.10;
|
||||
}
|
||||
|
|
|
|||
15
index.html
|
|
@ -7,7 +7,7 @@
|
|||
<meta http-equiv="Pragma" content="no-cache" />
|
||||
<meta http-equiv="Expires" content="0" />
|
||||
<title>たりない観察</title>
|
||||
<link rel="stylesheet" href="css/styles.css?v=15.6.2" />
|
||||
<link rel="stylesheet" href="css/styles.css?v=15.7.2" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
|
|
@ -76,6 +76,7 @@
|
|||
<button class="tool" data-tool="stone">石</button>
|
||||
<button class="tool" data-tool="bed">干草寝床</button>
|
||||
<button class="tool" data-tool="nest_box">巣箱</button>
|
||||
<button class="tool" data-tool="ant_nest">アリの巣</button>
|
||||
<button class="tool" data-tool="ball">ボール</button>
|
||||
<button class="tool" data-tool="firecracker">爆竹</button>
|
||||
<button class="tool" data-tool="fence_v">縦の柵</button>
|
||||
|
|
@ -137,6 +138,7 @@
|
|||
<p><strong>製作:</strong> 333</p>
|
||||
<p><strong>たりない:</strong> むらさき氏</p>
|
||||
<p><strong>効果音:</strong> VOICEVOX: ずんだもん 効果音ラボ: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
|
||||
<p><strong>アリ素材:</strong> いらすとや: <a href="https://www.irasutoya.com/" target="_blank" rel="noopener">https://www.irasutoya.com/</a></p>
|
||||
</div>
|
||||
<div class="field-dialog-actions credits-actions">
|
||||
<button id="creditsCloseBtn" class="btn" type="button">閉じる</button>
|
||||
|
|
@ -165,16 +167,17 @@
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<script src="js/data.js?v=15.6.2"></script>
|
||||
<script src="js/data.js?v=15.8.0"></script>
|
||||
<script src="js/math.js?v=15.6.2"></script>
|
||||
<script src="js/assets.js?v=15.6.2"></script>
|
||||
<script src="js/audio.js?v=15.6.2"></script>
|
||||
<script src="js/render.js?v=15.6.2"></script>
|
||||
<script src="js/sim_core.js?v=15.6.2"></script>
|
||||
<script src="js/items.js?v=15.6.2"></script>
|
||||
<script src="js/health.js?v=15.6.2"></script>
|
||||
<script src="js/tarinai.js?v=15.6.2"></script>
|
||||
<script src="js/world.js?v=15.6.2"></script>
|
||||
<script src="js/items.js?v=15.8.0"></script>
|
||||
<script src="js/ants.js?v=15.8.0"></script>
|
||||
<script src="js/health.js?v=15.8.0"></script>
|
||||
<script src="js/tarinai.js?v=15.8.0"></script>
|
||||
<script src="js/world.js?v=15.8.0"></script>
|
||||
<script src="js/text_catalog.js?v=15.6.2"></script>
|
||||
<script src="js/ui.js?v=15.6.2"></script>
|
||||
<script src="js/ui_charts.js?v=15.6.2"></script>
|
||||
|
|
|
|||
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;
|
||||
|
|
@ -32,6 +32,8 @@ const SPRITES = [
|
|||
const DECOR_ASSETS = [
|
||||
{ id: "zunchi", path: "assets/objects/zunchi.png" },
|
||||
{ id: "zunchi_02", path: "assets/objects/zunchi_02.png" },
|
||||
{ id: "ant_queen", path: "assets/objects/ant_queen.png" },
|
||||
{ id: "ant_worker", path: "assets/objects/ant_worker.png" },
|
||||
];
|
||||
|
||||
const TOOL_DEFINITIONS = Object.freeze({
|
||||
|
|
@ -51,6 +53,7 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
stone: { id: "stone", itemType: "stone", label: "\u77f3", placeable: true, scalable: true, radius: 20, amount: 999, icon: "assets/ui/tool_stone.png", tooltip: "\u91cd\u3044\u77f3\u3002\u843d\u3068\u3059\u3068\u5371\u306a\u3044\u3002" },
|
||||
bed: { id: "bed", itemType: "bed", label: "\u5e72\u8349\u5bdd\u5e8a", placeable: true, scalable: false, radius: 37, amount: 999, icon: "assets/ui/tool_bed.png", tooltip: "\u8fd1\u304f\u3067\u7720\u308b\u3002\u4f53\u529b\u304c\u56de\u5fa9\u3059\u308b\u3002" },
|
||||
nest_box: { id: "nest_box", itemType: "nest_box", label: "\u5de3\u7bb1", placeable: true, scalable: false, radius: 42, amount: 999, icon: "assets/ui/tool_nest_box.png", collisionShape: "nest_box_3x3", tooltip: "\u3044\u308b\u3060\u3051\u3067\u30b9\u30c8\u30ec\u30b9\u4f4e\u6e1b\u3002\u7720\u308b\u3068\u7761\u7720\u306e\u8cea\u304c\u4e0a\u304c\u308b\u3002" },
|
||||
ant_nest: { id: "ant_nest", itemType: "ant_nest", label: "\u30a2\u30ea\u306e\u5de3", placeable: true, scalable: false, radius: 17, amount: 999, icon: "assets/ui/tool_ant_nest.png", collisionShape: "circle", tooltip: "\u50cd\u304d\u30a2\u30ea\u304c\u305f\u308a\u306a\u3044\u3092\u63a2\u3057\u3001\u5de3\u3078\u904b\u3076\u3002" },
|
||||
ball: { id: "ball", itemType: "ball", label: "\u30dc\u30fc\u30eb", placeable: true, scalable: false, radius: 18, amount: 999, icon: "assets/ui/tool_ball.png", collisionShape: "circle", tooltip: "\u305f\u308a\u306a\u3044\u304c\u904a\u3076\u305f\u3081\u306e\u304a\u3082\u3061\u3083\u3002" },
|
||||
firecracker: { id: "firecracker", itemType: "firecracker", label: "\u7206\u7af9", placeable: true, scalable: true, radius: 15, amount: 999, icon: "assets/ui/tool_firecracker.png", tooltip: "\u7206\u767a\u3092\u8d77\u3053\u3059\u3002" },
|
||||
fence_v: { id: "fence_v", itemType: "fence_v", label: "\u7e26\u306e\u67f5", placeable: true, scalable: true, radius: 42, amount: 999, icon: "assets/ui/tool_fence_v.png", collisionShape: "rect", tooltip: "\u305f\u308a\u306a\u3044\u3092\u901a\u305b\u3093\u307c\u3059\u308b\u3002" },
|
||||
|
|
@ -59,6 +62,7 @@ const TOOL_DEFINITIONS = Object.freeze({
|
|||
trace: { id: "trace", itemType: "trace", label: "\u8db3\u8de1", placeable: false, scalable: false, radius: 16, amount: 220, simulationOnly: true },
|
||||
splat: { id: "splat", itemType: "splat", label: "\u3057\u3076\u304d", placeable: false, scalable: false, radius: 26, amount: 260, simulationOnly: true },
|
||||
food: { id: "food", itemType: "food", label: "\u98df\u3079\u7269", placeable: false, scalable: false, radius: 14, amount: 85, simulationOnly: true },
|
||||
ant_corpse: { id: "ant_corpse", itemType: "ant_corpse", label: "\u30a2\u30ea\u306e\u6b7b\u9ab8", placeable: false, scalable: false, radius: 8, amount: 24, simulationOnly: true },
|
||||
});
|
||||
|
||||
function toolDefinition(id = "") {
|
||||
|
|
@ -125,6 +129,8 @@ const ALPHA_BOUNDS = {
|
|||
zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 },
|
||||
zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 },
|
||||
zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 },
|
||||
ant_queen: { x: 0, y: 0, w: 300, h: 155, imageW: 300, imageH: 155 },
|
||||
ant_worker: { x: 0, y: 0, w: 140, h: 72, imageW: 140, imageH: 72 },
|
||||
};
|
||||
|
||||
const NEEDS = ["\u98df\u3079\u7269", "\u4ef2\u9593", "\u7720\u308a", "\u52c7\u6c17", "\u6c34", "\u9759\u3051\u3055", "\u3042\u305f\u305f\u304b\u3055", "\u610f\u5473", "\u7a7a\u9593", "\u81ea\u4fe1"];
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ const HEALTH = (() => {
|
|||
explosionDisease: "\u7206\u767a\u75c5",
|
||||
fightDisease: "\u304d\u305a\u3064\u304d\u75c5",
|
||||
zunchiDisease: "\u305a\u3093\u3061\u75c5",
|
||||
antEaten: "\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f",
|
||||
});
|
||||
const DISEASE_RE = /([^\u3001\u3002\s]+\u75c5)/;
|
||||
|
||||
|
|
@ -33,6 +34,7 @@ const HEALTH = (() => {
|
|||
if (/\u7206\u767a\u75c5|explosion.*disease/.test(text)) return CAUSES.explosionDisease;
|
||||
if (/\u3051\u3093\u304b\u75c5|\u304d\u305a\u3064\u304d\u75c5|fight.*disease/.test(text)) return CAUSES.fightDisease;
|
||||
if (/\u305a\u3093\u3061\u75c5|zunchi_sick|zunchi.*\u75c5/.test(text)) return CAUSES.zunchiDisease;
|
||||
if (/\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f|eaten.*ant|ant.*eaten/.test(text)) return CAUSES.antEaten;
|
||||
if (/\u75c5/.test(text)) {
|
||||
const disease = text.match(DISEASE_RE);
|
||||
return disease ? disease[1] : CAUSES.zunchiDisease;
|
||||
|
|
|
|||
93
js/items.js
|
|
@ -58,6 +58,20 @@ class Item {
|
|||
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;
|
||||
|
|
@ -84,6 +98,7 @@ class Item {
|
|||
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) {
|
||||
|
|
@ -460,7 +475,8 @@ class Item {
|
|||
comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, foodServingScale: this.foodServingScale, foodServingsMax: this.foodServingsMax, foodServingsRemaining: this.foodServingsRemaining, 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
|
||||
lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle,
|
||||
antCount: this.antCount, antSpawnTimer: this.antSpawnTimer, antSpawnCooldown: this.antSpawnCooldown, antWorkers: Array.isArray(this.antWorkers) ? this.antWorkers.slice() : undefined, queenSpawnAt: this.queenSpawnAt, workerSprite: this.workerSprite, decayTimer: this.decayTimer
|
||||
};
|
||||
}
|
||||
static from(o) {
|
||||
|
|
@ -511,6 +527,22 @@ class Item {
|
|||
it.freshness = 1;
|
||||
it.zunchiVariant = "zunchi";
|
||||
}
|
||||
if (type === "ant_nest") {
|
||||
it.antCount = Number.isFinite(o.antCount) ? clamp(Math.round(o.antCount), 0, ANT_NEST_MAX_COUNT || 10) : (ANT_NEST_START_COUNT || 6);
|
||||
it.antSpawnTimer = o.antSpawnTimer ?? rand(0.6, 2.2);
|
||||
it.antSpawnCooldown = o.antSpawnCooldown ?? rand(0.8, 2.4);
|
||||
const legacyPool = Array.isArray(o.antWorkers) ? o.antWorkers : (Array.isArray(o.workerHpPool) ? o.workerHpPool : null);
|
||||
it.antWorkers = Array.isArray(legacyPool)
|
||||
? legacyPool.map(v => clamp(Number(v) || ANT_WORKER_HP || 32, 0.1, ANT_WORKER_HP || 32)).slice(0, ANT_NEST_MAX_COUNT || 10)
|
||||
: (typeof buildAntWorkerPool === "function"
|
||||
? buildAntWorkerPool(it.antCount)
|
||||
: Array.from({ length: it.antCount }, () => ANT_WORKER_HP || 32));
|
||||
it.queenSpawnAt = Number.isFinite(o.queenSpawnAt) ? o.queenSpawnAt : -999;
|
||||
}
|
||||
if (type === "ant_corpse") {
|
||||
it.workerSprite = o.workerSprite || "ant_worker";
|
||||
it.decayTimer = o.decayTimer || 0;
|
||||
}
|
||||
if (type === "firecracker") {
|
||||
it.fuseTimer = 5;
|
||||
it.fuseMax = 5;
|
||||
|
|
@ -541,6 +573,22 @@ class Item {
|
|||
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.antCount = Number.isFinite(o.antCount) ? clamp(Math.round(o.antCount), 0, ANT_NEST_MAX_COUNT || 10) : it.antCount;
|
||||
it.antSpawnTimer = o.antSpawnTimer ?? it.antSpawnTimer;
|
||||
it.antSpawnCooldown = o.antSpawnCooldown ?? it.antSpawnCooldown;
|
||||
if (type === "ant_nest") {
|
||||
const legacyPool = Array.isArray(o.antWorkers) ? o.antWorkers : (Array.isArray(o.workerHpPool) ? o.workerHpPool : null);
|
||||
if (Array.isArray(legacyPool)) {
|
||||
it.antWorkers = legacyPool.map(v => clamp(Number(v) || 0, 0, ANT_WORKER_HP || 32)).filter(v => v > 0).slice(0, ANT_NEST_MAX_COUNT || 10);
|
||||
} else if (!Array.isArray(it.antWorkers)) {
|
||||
it.antWorkers = typeof buildAntWorkerPool === "function"
|
||||
? buildAntWorkerPool(it.antCount || (ANT_NEST_START_COUNT || 6))
|
||||
: Array.from({ length: it.antCount || (ANT_NEST_START_COUNT || 6) }, () => ANT_WORKER_HP || 32);
|
||||
}
|
||||
it.queenSpawnAt = Number.isFinite(o.queenSpawnAt) ? o.queenSpawnAt : (it.queenSpawnAt ?? -999);
|
||||
}
|
||||
it.workerSprite = o.workerSprite || it.workerSprite;
|
||||
it.decayTimer = o.decayTimer ?? it.decayTimer;
|
||||
it.toolSize = o.toolSize || it.toolSize || "medium";
|
||||
it.foodServingScale = o.foodServingScale || it.foodServingScale || ({ small: 0.68, medium: 1.0, large: 1.48 }[it.toolSize] || 1);
|
||||
if (isServingFoodType(it.type)) {
|
||||
|
|
@ -779,6 +827,49 @@ class Item {
|
|||
ctx.ellipse(randSeed(this.seed + i, -this.r * 0.84, this.r * 0.84), randSeed(this.seed + 20 + i, this.r * 0.32, this.r * 0.68), this.r * 0.26, this.r * 0.045, randSeed(this.seed + 40 + i, -0.7, 0.7), 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);
|
||||
|
|
|
|||
|
|
@ -952,7 +952,7 @@ class Tarinai {
|
|||
const low = this.lowHealthSprite();
|
||||
if (low === "stretch") return low;
|
||||
if (this.defeatedTimer > 0.04) return this.variantSprite("flee");
|
||||
if (this.state === "intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate");
|
||||
if (this.state === "intimidate" || this.state === "ant_intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate");
|
||||
if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear");
|
||||
if (this.state === "fight" || this.fightTimer > 0.04) return "angry";
|
||||
if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress");
|
||||
|
|
@ -1377,6 +1377,40 @@ class Tarinai {
|
|||
return;
|
||||
}
|
||||
|
||||
const nearbyAnts = (this.world.ants || []).filter(a => a && !a.dead && distXY(this.x, this.y, a.x, a.y) <= (a.kind === "queen" ? 112 : 82));
|
||||
let nearestAnt = null;
|
||||
let nearestAntDist = Infinity;
|
||||
for (const ant of nearbyAnts) {
|
||||
const ad = distXY(this.x, this.y, ant.x, ant.y);
|
||||
if (ad < nearestAntDist) {
|
||||
nearestAnt = ant;
|
||||
nearestAntDist = ad;
|
||||
}
|
||||
}
|
||||
if (nearestAnt) {
|
||||
const aggressiveToAnts = this.shouldApplyPersonalityBehavior("aggression", 1);
|
||||
if (aggressiveToAnts && this.energy > 12 && this.fightTimer <= 0.04 && this.defeatedTimer <= 0.04) {
|
||||
if (nearestAntDist < Math.max(36, this.radius + (nearestAnt.r || 4) + 8) && Math.random() < dt * 0.28) {
|
||||
// Intimidation has no effect on ants. This is only a visible reaction.
|
||||
this.state = "ant_intimidate";
|
||||
this.target = nearestAnt;
|
||||
this.bubble("!", 0.9, "rgba(145,68,52,0.78)");
|
||||
this.thought = "\u30a2\u30ea\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
|
||||
return;
|
||||
}
|
||||
this.state = "ant_attack";
|
||||
this.target = nearestAnt;
|
||||
this.goodMode = "angry";
|
||||
this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
|
||||
return;
|
||||
}
|
||||
this.state = "panic";
|
||||
this.target = this.panicDestination(nearestAnt);
|
||||
this.fearTimer = Math.max(this.fearTimer, 0.34);
|
||||
this.thought = "\u30a2\u30ea\u304b\u3089\u9003\u3052\u3066\u3044\u308b";
|
||||
return;
|
||||
}
|
||||
|
||||
const dayP = this.world.dayProgress();
|
||||
const personalSleep = this.sleepStart < this.sleepEnd
|
||||
? dayP >= this.sleepStart && dayP < this.sleepEnd
|
||||
|
|
@ -1581,7 +1615,7 @@ class Tarinai {
|
|||
}
|
||||
|
||||
if (this.hunger > 62) {
|
||||
const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass"];
|
||||
const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "sleep_drug", "grass", "ant_corpse"];
|
||||
const item = this.world.nearest(this, itemTypes, 420);
|
||||
if (item) {
|
||||
this.state = "seek_food";
|
||||
|
|
@ -1744,6 +1778,29 @@ class Tarinai {
|
|||
break;
|
||||
}
|
||||
|
||||
if (canBite && it.type === "ant_corpse") {
|
||||
const bite = Math.min(it.amount, rand(3.2, 5.4));
|
||||
if (bite > 0) {
|
||||
this.state = "eat";
|
||||
this.target = it;
|
||||
this.vx *= Math.pow(0.18, dt * 60);
|
||||
this.vy *= Math.pow(0.18, dt * 60);
|
||||
it.amount -= bite;
|
||||
this.hunger -= bite * 0.62;
|
||||
this.recoverHealth(bite * 0.12);
|
||||
this.stress = clamp(this.stress + bite * 0.05 * this.personalityProfile().fear, 0, 130);
|
||||
this.eatTimer = Math.max(this.eatTimer, 0.26);
|
||||
this.eatCooldown = rand(0.75, 1.25);
|
||||
this.bubble("\u3082\u3050", 2.2, "rgba(72,60,48,0.78)");
|
||||
const mouth = this.mouthPosition(it);
|
||||
this.world.spawnEatEffect(mouth.x, mouth.y, "#4a3f36");
|
||||
this.lastMealColor = "#4a3f36";
|
||||
this.makePoop(bite * 0.08);
|
||||
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.62; }
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (it.type === "zunchi") {
|
||||
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1);
|
||||
if (!this.isZunchiSlave) {
|
||||
|
|
@ -2019,7 +2076,7 @@ class Tarinai {
|
|||
return;
|
||||
}
|
||||
|
||||
if (this.intimidateTimer > 0.04 || this.state === "intimidate") {
|
||||
if (this.intimidateTimer > 0.04 || this.state === "intimidate" || this.state === "ant_intimidate") {
|
||||
this.vx *= Math.pow(0.22, dt * 60);
|
||||
this.vy *= Math.pow(0.22, dt * 60);
|
||||
return;
|
||||
|
|
@ -2083,6 +2140,19 @@ class Tarinai {
|
|||
strength = headbutt > -0.15 ? 2.65 : 0.65;
|
||||
sign = headbutt > -0.15 ? 1 : -1;
|
||||
}
|
||||
} else if (this.state === "ant_attack") {
|
||||
strength = d < this.radius + ((this.target && this.target.r) || 4) + 6 ? 0.18 : 1.32;
|
||||
sign = 1;
|
||||
if (d < this.radius + ((this.target && this.target.r) || 4) + 10) {
|
||||
const aggression = Math.max(0, this.currentPersonality?.aggression || 0);
|
||||
if ((this.world.time || 0) >= (this.nextAntAttackAt || 0)) {
|
||||
this.nextAntAttackAt = (this.world.time || 0) + 0.34;
|
||||
if (Number.isFinite(this.target.hp)) this.target.hp = Math.max(0, this.target.hp - (4.6 + aggression * 3.4));
|
||||
this.fearTimer = Math.max(this.fearTimer, 0.12);
|
||||
this.thought = "\u30a2\u30ea\u3092\u653b\u6483\u3057\u3066\u3044\u308b";
|
||||
if (Math.random() < 0.55) this.bubble("!", 0.8, "rgba(145,68,52,0.76)");
|
||||
}
|
||||
}
|
||||
}
|
||||
ax += (dx / d) * baseSpeed * sign * strength;
|
||||
ay += (dy / d) * baseSpeed * sign * strength;
|
||||
|
|
|
|||
167
js/world.js
|
|
@ -15,6 +15,7 @@ class World {
|
|||
this.toolSizes = {};
|
||||
this.tarinai = [];
|
||||
this.items = [];
|
||||
this.ants = [];
|
||||
this.effects = [];
|
||||
this.logs = [];
|
||||
this.eventCounters = {};
|
||||
|
|
@ -319,6 +320,7 @@ class World {
|
|||
this.toolSizes = this.toolSizes || {};
|
||||
this.tarinai = [];
|
||||
this.items = [];
|
||||
this.ants = [];
|
||||
this.effects = [];
|
||||
this.logs = [];
|
||||
this.eventCounters = {};
|
||||
|
|
@ -956,6 +958,37 @@ class World {
|
|||
}
|
||||
}
|
||||
|
||||
damageAntsInRadius(x, y, radius, damageFn, reason = "", opts = {}) {
|
||||
let hit = 0;
|
||||
for (const ant of this.ants || []) {
|
||||
if (!ant || ant.dead) continue;
|
||||
const d = Math.max(1, distXY(ant.x, ant.y, x, y));
|
||||
if (d > radius + (ant.r || 4)) continue;
|
||||
const p = clamp(1 - d / Math.max(1, radius), 0, 1);
|
||||
if (p <= 0 && d > radius) continue;
|
||||
const damage = typeof damageFn === "function" ? damageFn(p, ant, d) : Number(damageFn || 0);
|
||||
if (damage > 0) ant.hp = Math.max(0, (ant.hp ?? ant.maxHp ?? ANT_WORKER_HP ?? 32) - damage);
|
||||
if (opts.push) {
|
||||
let nx = (ant.x - x) / d;
|
||||
let ny = (ant.y - y) / d;
|
||||
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
|
||||
const a = rand(0, Math.PI * 2);
|
||||
nx = Math.cos(a);
|
||||
ny = Math.sin(a);
|
||||
}
|
||||
const push = typeof opts.push === "function" ? opts.push(p, ant, d) : Number(opts.push || 0);
|
||||
ant.vx = (ant.vx || 0) + nx * push;
|
||||
ant.vy = (ant.vy || 0) + ny * push;
|
||||
ant.x = clamp(ant.x + nx * Math.min(18, push * 0.018), CONFIG.worldPadding, this.w - CONFIG.worldPadding);
|
||||
ant.y = clamp(ant.y + ny * Math.min(18, push * 0.018), CONFIG.worldPadding, this.h - CONFIG.worldPadding);
|
||||
}
|
||||
hit += 1;
|
||||
if (ant.hp <= 0 && ant.dieAsCorpse) ant.dieAsCorpse(ant.homeNest?.());
|
||||
}
|
||||
if (hit > 0) this.drawListDirty = true;
|
||||
return hit;
|
||||
}
|
||||
|
||||
explodeFirecracker(it) {
|
||||
if (!it || it.dead || it.type !== "firecracker") return;
|
||||
it.amount = 0;
|
||||
|
|
@ -1026,6 +1059,9 @@ class World {
|
|||
if (!t.dead && p > 0.22 && Math.random() < 0.035 * p) t.infectExplosionDisease?.(it);
|
||||
if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
|
||||
}
|
||||
this.damageAntsInRadius(x, y, blastRadius, p => 5 + p * 18, "\u7206\u7af9", {
|
||||
push: p => (260 + (p * p * 0.55 + p * 0.45) * 920) * 0.42 * (it.blastScale || 1),
|
||||
});
|
||||
let blastedBalls = 0;
|
||||
for (const ball of this.items) {
|
||||
if (!ball || ball.dead || ball.type !== "ball") continue;
|
||||
|
|
@ -1375,9 +1411,12 @@ class World {
|
|||
t.fallDir = nx >= 0 ? 1 : -1;
|
||||
hit += 1;
|
||||
}
|
||||
const antHit = this.damageAntsInRadius(it.x, it.y, radius + 48, p => damage * (0.35 + p * 0.65), isStone ? "\u77f3\u304c\u843d\u3061\u305f" : "\u843d\u3061\u3066\u304d\u305f\u9053\u5177\u306b\u5f53\u305f\u3063\u305f", {
|
||||
push: p => push * (0.35 + p),
|
||||
});
|
||||
this.effects.push(new Effect("ring", it.x, it.y, { size: isStone ? 32 : 18, life: 0.34, color: isStone ? "rgba(128,96,58,0.76)" : "rgba(174,128,70,0.52)" }));
|
||||
if (hit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45);
|
||||
if (isStone && hit > 0) audio.poke();
|
||||
if (hit > 0 || antHit > 0) this.spawnFallEffect(it.x, it.y + (it.r || 12) * 0.6, isStone ? 0.9 : 0.45);
|
||||
if (isStone && (hit > 0 || antHit > 0)) audio.poke();
|
||||
if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
|
||||
}
|
||||
|
||||
|
|
@ -2055,10 +2094,13 @@ class World {
|
|||
|
||||
sortedDrawList() {
|
||||
if (!this.drawList) this.drawList = [];
|
||||
if (this.drawListDirty || this.drawList.length !== this.tarinai.length) {
|
||||
const antCount = (this.ants || []).filter(a => a && !a.dead).length;
|
||||
const tarinaiCount = (this.tarinai || []).filter(t => t && !t.dead).length;
|
||||
if (this.drawListDirty || this.drawList.length !== tarinaiCount + antCount) {
|
||||
this.drawList.length = 0;
|
||||
for (const t of this.tarinai) if (!t.dead) this.drawList.push(t);
|
||||
this.drawList.sort((a, b) => a.y - b.y);
|
||||
for (const t of this.tarinai || []) if (t && !t.dead) this.drawList.push(t);
|
||||
for (const a of this.ants || []) if (a && !a.dead) this.drawList.push(a);
|
||||
this.drawList.sort((a, b) => (a.y || 0) - (b.y || 0));
|
||||
this.drawListDirty = false;
|
||||
}
|
||||
return this.drawList;
|
||||
|
|
@ -2166,6 +2208,114 @@ class World {
|
|||
this.tarinai.length = write;
|
||||
}
|
||||
|
||||
compactAnts() {
|
||||
if (!this.ants) this.ants = [];
|
||||
let write = 0;
|
||||
for (let read = 0; read < this.ants.length; read++) {
|
||||
const a = this.ants[read];
|
||||
if (a && !a.dead) this.ants[write++] = a;
|
||||
}
|
||||
if (this.ants.length !== write) this.drawListDirty = true;
|
||||
this.ants.length = write;
|
||||
}
|
||||
|
||||
updateAnts(dt) {
|
||||
if (!this.ants) this.ants = [];
|
||||
const nests = (this.items || []).filter(it => it && !it.dead && it.type === "ant_nest");
|
||||
for (const nest of nests) {
|
||||
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(nest);
|
||||
if (!Number.isFinite(nest.antCount)) nest.antCount = ANT_NEST_START_COUNT || 6;
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
|
||||
nest.antSpawnTimer = Math.max(0, (nest.antSpawnTimer || 0) - dt);
|
||||
const outside = this.ants.filter(a => a && !a.dead && a.kind === "worker" && a.homeId === nest.id).length;
|
||||
const available = Array.isArray(nest.antWorkers) ? nest.antWorkers.length : Math.max(0, (nest.antCount || 0) - outside);
|
||||
const maxOutside = Math.min(ANT_NEST_MAX_OUTSIDE || 3, nest.antCount || 0);
|
||||
if (available > 0 && outside < maxOutside && (nest.antSpawnTimer || 0) <= 0 && (this.tarinai || []).some(t => t && !t.dead && !t.insideNestBoxId)) {
|
||||
const hp = Array.isArray(nest.antWorkers) && nest.antWorkers.length ? clamp(nest.antWorkers.pop(), 0, ANT_WORKER_HP || 32) : (ANT_WORKER_HP || 32);
|
||||
const a = new AntActor(this, {
|
||||
kind: "worker",
|
||||
homeId: nest.id,
|
||||
x: nest.x + rand(-nest.r * 0.18, nest.r * 0.18),
|
||||
y: nest.y - nest.r * 1.00 + rand(-3, 3),
|
||||
hp,
|
||||
maxHp: ANT_WORKER_HP || 32,
|
||||
});
|
||||
this.ants.push(a);
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, nest);
|
||||
nest.antSpawnTimer = rand(1.6, 3.8) + outside * 0.45;
|
||||
this.drawListDirty = true;
|
||||
}
|
||||
}
|
||||
for (const a of this.ants) a.update(dt);
|
||||
}
|
||||
|
||||
completeAntHaul(home, target) {
|
||||
if (!home || home.dead || home.type !== "ant_nest" || !target || target.dead) return;
|
||||
if (typeof ensureAntNestWorkerPool === "function") ensureAntNestWorkerPool(home);
|
||||
const before = typeof syncAntNestCount === "function"
|
||||
? syncAntNestCount(this, home)
|
||||
: clamp(Math.round(home.antCount ?? ANT_NEST_START_COUNT ?? 6), 0, ANT_NEST_MAX_COUNT ?? 10);
|
||||
if (before >= (ANT_NEST_MAX_COUNT || 10)) {
|
||||
this.spawnQueenAnt(home);
|
||||
} else {
|
||||
if (Array.isArray(home.antWorkers)) home.antWorkers.push(ANT_WORKER_HP || 32);
|
||||
home.antCount = clamp(before + 1, 0, ANT_NEST_MAX_COUNT || 10);
|
||||
if (typeof syncAntNestCount === "function") syncAntNestCount(this, home);
|
||||
}
|
||||
target.x = home.x;
|
||||
target.y = home.y;
|
||||
target.die("\u30a2\u30ea\u306b\u98df\u3079\u3089\u308c\u305f");
|
||||
this.drawListDirty = true;
|
||||
}
|
||||
|
||||
spawnQueenAnt(home) {
|
||||
if (!home || home.dead || home.type !== "ant_nest") return null;
|
||||
if ((home.queenSpawnAt || -999) + (CONFIG.dayLength || 120) > (this.time || 0)) return null;
|
||||
if ((this.ants || []).some(a => a && !a.dead && a.kind === "queen" && a.homeId === home.id)) return null;
|
||||
const spot = this.findAntNestFoundingSpot(home) || {
|
||||
x: clamp((home.x || this.w * 0.5) + rand(-220, 220), 56, this.w - 56),
|
||||
y: clamp((home.y || this.h * 0.5) + rand(-160, 160), 56, this.h - 56),
|
||||
};
|
||||
home.queenSpawnAt = this.time || 0;
|
||||
const queen = new AntActor(this, {
|
||||
kind: "queen",
|
||||
homeId: home.id,
|
||||
x: home.x,
|
||||
y: home.y - (home.r || 34) * 0.9,
|
||||
foundingX: spot.x,
|
||||
foundingY: spot.y,
|
||||
foundingDelayUntil: (this.time || 0) + 0.35,
|
||||
});
|
||||
this.ants.push(queen);
|
||||
this.log("\u5973\u738b\u30a2\u30ea\u304c\u65b0\u3057\u3044\u5de3\u5834\u6240\u3092\u63a2\u3057\u59cb\u3081\u305f\u3002", "event");
|
||||
this.drawListDirty = true;
|
||||
return queen;
|
||||
}
|
||||
|
||||
findAntNestFoundingSpot(source) {
|
||||
const baseX = Number.isFinite(source?.x) ? source.x : this.w * 0.5;
|
||||
const baseY = Number.isFinite(source?.y) ? source.y : this.h * 0.5;
|
||||
let best = null;
|
||||
let bestD = -Infinity;
|
||||
for (let i = 0; i < 70; i++) {
|
||||
const a = rand(0, Math.PI * 2);
|
||||
const r = rand(150, Math.min(430, Math.max(this.w, this.h) * 0.52));
|
||||
const x = clamp(baseX + Math.cos(a) * r, 56, this.w - 56);
|
||||
const y = clamp(baseY + Math.sin(a) * r * 0.74, 56, this.h - 56);
|
||||
const nest = new Item("ant_nest", x, y);
|
||||
const spot = this.findPlacementSpot ? this.findPlacementSpot(nest, { allowOriginal: true, maxRadius: 80, attempts: 10 }) : { x, y };
|
||||
if (!spot) continue;
|
||||
let nearest = Infinity;
|
||||
for (const it of this.items || []) {
|
||||
if (!it || it.dead || it.type !== "ant_nest") continue;
|
||||
nearest = Math.min(nearest, distXY(spot.x, spot.y, it.x, it.y));
|
||||
}
|
||||
if (nearest > bestD) { best = spot; bestD = nearest; }
|
||||
if (nearest > 280) break;
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
|
||||
environmentScores() {
|
||||
const alive = this.tarinai || [];
|
||||
|
|
@ -2184,8 +2334,9 @@ class World {
|
|||
const zunchi = this.itemCounts?.zunchi || 0;
|
||||
const splat = this.itemCounts?.splat || 0;
|
||||
const trace = this.itemCounts?.trace || 0;
|
||||
const antCorpse = this.itemCounts?.ant_corpse || 0;
|
||||
const security = clamp(100 - fightPressure - blood * 1.6, 0, 100);
|
||||
const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - blood * 1.8 - diseaseCount * 8.0, 0, 100);
|
||||
const hygiene = clamp(100 - zunchi * 2.0 - splat * 1.2 - trace * 0.30 - antCorpse * 0.65 - blood * 1.8 - diseaseCount * 8.0, 0, 100);
|
||||
const happiness = clamp(100 - Math.max(0, 100 - security) * 0.32 - Math.max(0, 100 - hygiene) * 0.24 - avgStress * 0.42, 0, 100);
|
||||
return { security, hygiene, happiness };
|
||||
}
|
||||
|
|
@ -2282,6 +2433,7 @@ class World {
|
|||
this.resolveBallBallCollisions(dt);
|
||||
this.rebuildSpatial();
|
||||
}
|
||||
this.updateAnts?.(dt);
|
||||
|
||||
for (const ef of this.effects) ef.update(dt);
|
||||
|
||||
|
|
@ -2299,6 +2451,7 @@ class World {
|
|||
itemsCompacted = this.compactItems();
|
||||
this.compactEffects();
|
||||
this.compactTarinai();
|
||||
this.compactAnts?.();
|
||||
this.compactTimer = 0;
|
||||
}
|
||||
|
||||
|
|
@ -2722,6 +2875,7 @@ class World {
|
|||
maxGeneration: this.maxGeneration, lastBirthAt: this.lastBirthAt || -999,
|
||||
tarinai: this.tarinai.map(t => t.serialize()),
|
||||
items: this.items.map(i => i.serialize()),
|
||||
ants: (this.ants || []).map(a => a.serialize ? a.serialize() : a),
|
||||
logs: this.logs.slice(0, 30),
|
||||
family: this.family || {},
|
||||
weather: this.weather || "sunny",
|
||||
|
|
@ -2751,6 +2905,7 @@ class World {
|
|||
this.cameraY = Number(data.cameraY) || 0;
|
||||
this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o));
|
||||
this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food" && o?.type !== "cardboard").map(o => Item.from(o));
|
||||
this.ants = (data.ants || []).map(o => AntActor.from(this, o));
|
||||
this.effects = [];
|
||||
this.compactItems();
|
||||
this.compactEffects();
|
||||
|
|
|
|||