-
${generationBlocks}
-
-
\u7e41\u6b96\u30da\u30a2
-
${pairCards}
-
+ const labels = layout.labels.map(label => `
äžä»Ł ${label.generation}
`).join("");
+ const partnerPaths = layout.partnerLinks.map(d => `
`).join("");
+ const childPaths = layout.childLinks.map(d => `
`).join("");
+ const nodeHtml = layout.nodes.map(p => lineageTreeNodeHtml(p.node, p.x, p.y)).join("");
+ const empty = !layout.childLinks.length ? `
èŠȘćéąäżăźç·ăŻăŸă ăăăŸăăă
` : "";
+
+ return `
+ ćź¶çł» ${index + 1} / ${uniqueComponent.length}ćč / çć ${aliveCount}
+
+
+ ${labels}${nodeHtml}${empty}
`;
}
@@ -148,19 +966,47 @@ function renderArchive() {
if (world.normalizeFamily) world.normalizeFamily();
const family = world.family || {};
const ids = Object.keys(family);
- const version = `${world.familyVersion || 0}:${ids.length}`;
+ const edgeCount = ids.reduce((sum, id) => {
+ const n = family[id];
+ return sum + (Array.isArray(n?.parents) ? n.parents.length : 0) + (Array.isArray(n?.children) ? n.children.length : 0);
+ }, 0);
+ const version = `${world.familyVersion || 0}:${ids.length}:${edgeCount}`;
if (uiCache.archiveVersion === version) return;
uiCache.archiveVersion = version;
uiCache.lastArchiveWindow = "";
- const components = liveFamilyComponents(family);
+ const components = liveFamilyComponents(family).flatMap(lineageSplitByActualEdges).map(c => lineageCleanComponent(c, family)).filter(c => c.length);
uiCache.archiveRows = components;
+ const token = `${version}:${Math.random().toString(36).slice(2)}`;
+ uiCache.archiveRenderToken = token;
if (!components.length) {
- ui.archiveContent.innerHTML = `
\u751f\u304d\u3066\u3044\u308b\u69cb\u6210\u54e1\u3092\u6301\u3064\u5bb6\u7cfb\u306f\u307e\u3060\u3042\u308a\u307e\u305b\u3093\u3002
`;
+ ui.archiveContent.innerHTML = `
çăăŠăăæ§æćĄăæă€ćź¶çł»ăŻăŸă ăăăŸăăă
`;
return;
}
- const shown = components.reduce((a, c) => a + c.length, 0);
- const note = `
${components.length} \u4ef6\u306e\u5bb6\u7cfb / ${shown} \u5339\u3092\u8868\u793a\u3002\u5168\u6ec5\u3057\u305f\u5bb6\u7cfb\u306f\u975e\u8868\u793a\u3067\u3059\u3002
`;
- ui.archiveContent.innerHTML = `${note}
${components.map((c, i) => lineageFamilyHtml(c, i, family)).join("")}
`;
+
+ ui.archiveContent.innerHTML = `
ćź¶çł»ćłăæç»äžâŠ 0 / ${components.length}
`;
+ const status = ui.archiveContent.querySelector(".lineage-render-status");
+ const forest = ui.archiveContent.querySelector(".lineage-forest");
+ let index = 0;
+ const pump = () => {
+ if (uiCache.archiveRenderToken !== token || !forest) return;
+ const started = performance.now();
+ let nodeBudget = 180;
+ while (index < components.length) {
+ const component = components[index];
+ forest.insertAdjacentHTML("beforeend", lineageFamilyHtml(component, index, family));
+ nodeBudget -= Math.max(1, component.length);
+ index += 1;
+ if (index < components.length && (nodeBudget <= 0 || performance.now() - started > 14)) break;
+ }
+ if (status) status.textContent = `ćź¶çł»ćłăæç»äžâŠ ${index} / ${components.length}`;
+ if (index < components.length) {
+ const schedule = window.requestIdleCallback || ((fn) => setTimeout(fn, 0));
+ schedule(pump, { timeout: 120 });
+ } else if (status) {
+ status.remove();
+ }
+ };
+ pump();
}
function validateFamilyTree() {
@@ -173,6 +1019,11 @@ function validateFamilyTree() {
for (const [id, count] of rendered.entries()) {
if (count > 1) issues.push({ type: "duplicate-render-node", id, count });
}
+ for (const component of uiCache.archiveRows || []) {
+ for (const n of component || []) {
+ if (n?.id && !rendered.has(n.id)) issues.push({ type: "missing-render-node", id: n.id });
+ }
+ }
return issues;
}
diff --git a/js/world.js b/js/world.js
index 9bf36e1..b5134aa 100644
--- a/js/world.js
+++ b/js/world.js
@@ -29,7 +29,7 @@ class World {
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
- this.pointer = { x: 0, y: 0, inside: false };
+ this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 };
this.lastPhase = "";
this.weather = "sunny";
this.fieldType = "garden";
@@ -85,8 +85,49 @@ class World {
return Math.max(0.22, field.worldScale || 1);
}
+ grassLimit() {
+ const field = this.fieldDefinition();
+ if (Number.isFinite(field?.grassLimit)) return Math.max(0, Math.round(field.grassLimit));
+ const base = CONFIG.grassLimit ?? 96;
+ const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1);
+ const ratio = this.fieldWorldScale() / mediumScale;
+ // äžă”ă€ășă CONFIG.grassLimit ăźćșæșă«ăăăăŁăŒă«ăăźè«çă”ă€ășă«ćżăăŠćąæžăăă
+ return Math.max(12, Math.round(base * ratio));
+ }
+
+ baseZoomMin() {
+ return CONFIG.fieldZoomMin || 0.42;
+ }
+
+ baseZoomMax() {
+ return CONFIG.fieldZoomMax || 2.45;
+ }
+
+ fitFieldZoom() {
+ const vw = Math.max(1, this.viewportW || this.w || 1000);
+ const vh = Math.max(1, this.viewportH || this.h || 720);
+ const ww = Math.max(1, this.w || vw);
+ const wh = Math.max(1, this.h || vh);
+ // The minimum visible zoom is field-size dependent. Small fields must not
+ // be allowed to shrink below the viewport, otherwise empty margins appear.
+ return Math.max(vw / ww, vh / wh);
+ }
+
+ zoomLimits() {
+ const min = Math.max(this.baseZoomMin(), this.fitFieldZoom());
+ // Keep the old upper bound for normal/large fields, but give small fields
+ // proportional zoom-in headroom because their no-margin minimum is higher.
+ const max = Math.max(this.baseZoomMax(), min * this.baseZoomMax());
+ return { min, max };
+ }
+
+ normalizedFieldZoom(value = this.fieldZoom) {
+ const limits = this.zoomLimits();
+ return clamp(Number(value) || limits.min, limits.min, limits.max);
+ }
+
viewScale() {
- return clamp(Number(this.fieldZoom) || 1, 0.42, 2.45);
+ return this.normalizedFieldZoom(this.fieldZoom);
}
visibleWorldW() {
@@ -161,6 +202,7 @@ class World {
const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH);
this.w = this.viewportW * scale;
this.h = this.viewportH * scale;
+ this.fieldZoom = this.normalizedFieldZoom(this.fieldZoom);
this.clampCamera();
if (opts.scaleContents && oldW > 0 && oldH > 0) {
const sx = this.w / oldW;
@@ -183,15 +225,20 @@ class World {
}
setFieldZoom(nextZoom) {
- const next = clamp(Number(nextZoom) || 1, 0.42, 2.45);
- if (Math.abs(next - (this.fieldZoom || 1)) < 0.001) return false;
+ const current = this.normalizedFieldZoom(this.fieldZoom);
+ const next = this.normalizedFieldZoom(nextZoom);
+ if (Math.abs(next - current) < 0.001) {
+ this.fieldZoom = current;
+ this.clampCamera();
+ return false;
+ }
this.fieldZoom = next;
this.clampCamera();
this.drawListDirty = true;
return true;
}
- edgeSpawnPoint(targetX = null, targetY = null, margin = 34) {
+ edgeSpawnPoint(targetX = null, targetY = null, margin = 34, outside = false) {
const w = Math.max(this.w || 1000, margin * 2 + 1);
const h = Math.max(this.h || 720, margin * 2 + 1);
let side;
@@ -201,19 +248,20 @@ class World {
} else {
side = Math.floor(Math.random() * 4);
}
- if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: margin, vx: rand(-10, 10), vy: rand(18, 34), angle: Math.PI / 2 };
- if (side === 1) return { x: w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-34, -18), vy: rand(-10, 10), angle: Math.PI };
- if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: h - margin, vx: rand(-10, 10), vy: rand(-34, -18), angle: -Math.PI / 2 };
- return { x: margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(18, 34), vy: rand(-10, 10), angle: 0 };
+ const p = outside ? -(margin + 24) : margin;
+ if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: p, vx: rand(-10, 10), vy: rand(28, 48), angle: Math.PI / 2 };
+ if (side === 1) return { x: outside ? w - p : w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-48, -28), vy: rand(-10, 10), angle: Math.PI };
+ if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: outside ? h - p : h - margin, vx: rand(-10, 10), vy: rand(-48, -28), angle: -Math.PI / 2 };
+ return { x: p, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(28, 48), vy: rand(-10, 10), angle: 0 };
}
toolSizeFor(type = "") {
- if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return "medium";
+ if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return "medium";
return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
}
toolSizeScale(type = "") {
- if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return 1;
+ if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return 1;
return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1;
}
@@ -224,7 +272,7 @@ class World {
item.toolSize = size;
this.toolSize = size;
item.r *= scale;
- if (["sweet", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
+ if (["sweet", "love_mochi", "fight_mochi", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
if (item.type === "firecracker") item.blastScale = scale;
return item;
}
@@ -256,7 +304,7 @@ class World {
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
- this.pointer = { x: this.w / 2, y: this.h / 2, inside: false };
+ this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2);
this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2);
this.clampCamera();
@@ -425,6 +473,20 @@ class World {
return changed;
}
+ resetFamilyTree() {
+ this.family = {};
+ this.familyCleanVersion = 0;
+ this.familyVersion = (this.familyVersion || 0) + 1;
+ this.maxGeneration = 1;
+ for (const t of this.tarinai || []) {
+ t.parents = [];
+ t.parentNames = [];
+ t.children = [];
+ t.generation = 1;
+ t.hasPaired = false;
+ }
+ }
+
validateFamily() {
this.normalizeFamily();
const family = this.family || {};
@@ -516,16 +578,16 @@ class World {
startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false;
+ if (a.isZunchiSlave || b.isZunchiSlave) return false;
if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false;
const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length);
const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length);
if (aJoined && !bJoined) b.generation = a.generation;
else if (bJoined && !aJoined) a.generation = b.generation;
else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; }
- a.hasPaired = true;
- b.hasPaired = true;
- this.recordFamily(a);
- this.recordFamily(b);
+ // Do not mark either parent as part of a family during the ritual.
+ // The archive should only gain nodes after a child has actually been created;
+ // otherwise childless ritual participants appear as isolated family trees.
const duration = 10.0;
a.birthRitualTimer = b.birthRitualTimer = duration;
a.birthRitualMax = b.birthRitualMax = duration;
@@ -568,6 +630,29 @@ class World {
return children[0] || null;
}
+ areParentChild(a, b) {
+ if (!a || !b) return false;
+ const aId = a.id || a;
+ const bId = b.id || b;
+ if (!aId || !bId) return false;
+ return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId));
+ }
+
+ areCoParents(a, b) {
+ if (!a || !b) return false;
+ const aId = a.id || a;
+ const bId = b.id || b;
+ if (!aId || !bId || aId === bId) return false;
+ if (a.birthPartnerId && a.birthPartnerId === bId) return true;
+ if (b.birthPartnerId && b.birthPartnerId === aId) return true;
+ const aChildren = new Set((a.children || []).filter(Boolean));
+ if (!aChildren.size) return false;
+ for (const id of (b.children || [])) {
+ if (aChildren.has(id)) return true;
+ }
+ return false;
+ }
+
spawnChild(a, b, index = 0, total = 1) {
const inherited = Math.random() < 0.56 ? a.type : b.type;
const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited;
@@ -590,8 +675,14 @@ class World {
birthTime: this.time,
});
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
+ a.hasPaired = true;
+ b.hasPaired = true;
a.affection = 0; b.affection = 0;
a.loneliness *= 0.65; b.loneliness *= 0.65;
+ a.adjustRelation?.(b, 0.65, -0.20, "co_parent");
+ b.adjustRelation?.(a, 0.65, -0.20, "co_parent");
+ a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28);
+ b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28);
a.children.push(child.id);
b.children.push(child.id);
this.recordFamily(a);
@@ -607,16 +698,18 @@ class World {
startConflict(a, b) {
if (!a || !b || a.dead || b.dead) return;
+ if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return;
- const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + rand(-5, 5);
- const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + rand(-5, 5);
+ const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
+ const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a;
const actorScore = actor === a ? scoreA : scoreB;
const targetScore = actor === a ? scoreB : scoreA;
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
- const intimidationChance = 0.40;
+ const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04;
+ const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90);
if (canIntimidate && Math.random() < intimidationChance) {
this.startIntimidation(actor, target, actorScore, targetScore);
return;
@@ -686,8 +779,8 @@ class World {
b.fightTargetIds = b.fightTargetIds.slice(-4);
a.fightTargetId = a.fightTargetIds[0];
b.fightTargetId = b.fightTargetIds[0];
- const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + rand(-7, 7);
- const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + rand(-7, 7);
+ const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7);
+ const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7);
const loser = aScore <= bScore ? a : b;
const winner = loser === a ? b : a;
loser.defeatedById = winner.id;
@@ -739,6 +832,15 @@ class World {
}
for (const t of this.tarinai) {
if (t.dead) continue;
+ const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed";
+ t.sleeping = false;
+ if (wasSleeping) {
+ t.state = "panic";
+ t.target = { x, y, dead: false };
+ t.surpriseTimer = Math.max(t.surpriseTimer, 0.95);
+ t.fearTimer = Math.max(t.fearTimer, 1.2);
+ t.thought = "çç«čă§ăăăè”·ăăăă";
+ }
t.stress = clamp(t.stress + rand(8, 15), 0, 130);
t.fearTimer = Math.max(t.fearTimer, 0.58);
const dx = t.x - x;
@@ -764,8 +866,35 @@ class World {
this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p);
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)");
}
+ let blastedBalls = 0;
+ for (const ball of this.items) {
+ if (!ball || ball.dead || ball.type !== "ball") continue;
+ let dx = ball.x - x;
+ let dy = ball.y - y;
+ let d = Math.hypot(dx, dy) || 1;
+ if (d > blastRadius * 1.18) continue;
+ const p = clamp(1 - d / (blastRadius * 1.18), 0, 1);
+ if (d < 0.001) {
+ const a = rand(0, Math.PI * 2);
+ dx = Math.cos(a); dy = Math.sin(a); d = 1;
+ }
+ const blast = 430 + p * 960;
+ ball.vx = (ball.vx || 0) + dx / d * blast + rand(-80, 80);
+ ball.vy = (ball.vy || 0) + dy / d * blast + rand(-80, 80);
+ const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
+ const cap = 1250;
+ if (speed > cap) {
+ ball.vx = ball.vx / speed * cap;
+ ball.vy = ball.vy / speed * cap;
+ }
+ ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38);
+ ball.lastPokedAt = this.time || 0;
+ ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
+ this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" }));
+ blastedBalls += 1;
+ }
audio.explode();
- this.log("\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident");
+ this.log(blastedBalls ? "çç«čăæŽŸæă«ăŻăăăăăŒă«ăæ„ć éăăă" : "çç«čăæŽŸæă«ăŻăăăćșăăăă€ăăă", "accident");
}
spawnZunchi(x, y) {
@@ -845,8 +974,12 @@ class World {
}));
}
+ isSleepFurniture(it) {
+ return Boolean(it && it.type === "bed");
+ }
+
bedOccupancy(bed) {
- if (!bed || bed.type !== "bed") return 0;
+ if (!this.isSleepFurniture(bed)) return 0;
let n = 0;
const radius = Math.max(86, bed.r * 2.45);
for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) {
@@ -857,7 +990,7 @@ class World {
}
bedComfort(bed) {
- if (!bed || bed.type !== "bed") return 0.7;
+ if (!this.isSleepFurniture(bed)) return 0.7;
const occ = this.bedOccupancy(bed);
return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
}
@@ -866,12 +999,13 @@ class World {
let best = null;
let bestScore = Infinity;
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
- if (bed.dead || bed.type !== "bed") continue;
+ if (bed.dead || !this.isSleepFurniture(bed)) continue;
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
const d = dist(t, bed);
const occ = this.bedOccupancy(bed);
const comfort = this.bedComfort(bed);
- const score = d - comfort * 46 + Math.max(0, occ - 3) * 34;
+ const crowdPenalty = Math.max(0, occ - 3) * 34;
+ const score = d - comfort * 46 + crowdPenalty;
if (score < bestScore) { best = bed; bestScore = score; }
}
return best;
@@ -901,6 +1035,12 @@ class World {
loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f");
winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1;
loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1;
+ loser.totalFightLosses = (loser.totalFightLosses || 0) + 1;
+ if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) {
+ const oldName = loser.formerName || loser.name;
+ this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "ă¶ăă
âŠ", "rgba(74,91,50,0.78)");
+ this.log(`${oldName}ăŻ5ćć§ć©ă«èČ ăăăăăĄă©ăăă«ăȘăŁăă`, "fight");
+ }
loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear);
this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
winner.affection = clamp(winner.affection + 0.8, 0, 80);
@@ -939,6 +1079,183 @@ class World {
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");
}
+ countBallChasers(ball, except = null) {
+ if (!ball) return 0;
+ let count = 0;
+ for (const t of this.tarinai || []) {
+ if (!t || t === except || t.dead) continue;
+ if (t.state === "play_ball" && t.target === ball) count += 1;
+ }
+ return count;
+ }
+
+ limitBallChasers() {
+ if (!this.itemCounts?.ball) return;
+ const groups = new Map();
+ for (const t of this.tarinai || []) {
+ if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue;
+ const arr = groups.get(t.target) || [];
+ arr.push(t);
+ groups.set(t.target, arr);
+ }
+ for (const [ball, arr] of groups) {
+ if (arr.length <= 5) continue;
+ arr.sort((a, b) => {
+ const ap = a.personality === "playful" ? -120 : 0;
+ const bp = b.personality === "playful" ? -120 : 0;
+ return dist(a, ball) + ap - (dist(b, ball) + bp);
+ });
+ for (const t of arr.slice(5)) {
+ t.state = "idle";
+ t.target = null;
+ t.thought = "ăăŒă«ăæ··ăżćăŁăŠăăăźă§çșăăŠăă";
+ t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45);
+ }
+ }
+ }
+
+ pokeBall(ball, x, y) {
+ if (!ball || ball.dead || ball.type !== "ball") return false;
+ const now = (typeof performance !== "undefined" && performance.now) ? performance.now() / 1000 : (this.time || 0);
+ let dx = ball.x - x;
+ let dy = ball.y - y;
+ let d = Math.hypot(dx, dy);
+ if (d < 0.001) {
+ const a = (ball.lastPokeAngle ?? rand(0, Math.PI * 2)) + rand(-0.55, 0.55);
+ dx = Math.cos(a);
+ dy = Math.sin(a);
+ d = 1;
+ }
+ const nx = dx / d;
+ const ny = dy / d;
+ const recent = now - (ball.lastPokedAt || -999) < 0.82;
+ ball.pokeCombo = recent ? Math.min(10, (ball.pokeCombo || 0) + 1) : 1;
+ ball.lastPokedAt = now;
+ ball.lastPokeAngle = Math.atan2(ny, nx);
+ const currentSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
+ const impulse = 145 + ball.pokeCombo * 64 + Math.min(260, currentSpeed * 0.24);
+ ball.vx = (ball.vx || 0) + nx * impulse;
+ ball.vy = (ball.vy || 0) + ny * impulse;
+ const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
+ const cap = 1250;
+ if (speed > cap) {
+ ball.vx = ball.vx / speed * cap;
+ ball.vy = ball.vy / speed * cap;
+ }
+ ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38);
+ ball.amount = Math.max(ball.amount || 999, 999);
+ audio.poke();
+ this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" }));
+ if (ball.pokeCombo >= 4 || speed > 430) this.log(`ăăŒă«ăéŁç¶ă§ă€ă€ăăćąăăćąăăă`, "observe");
+ else this.log(`ăăŒă«ăă€ă€ăăŠćŒŸăăă`, "observe");
+ return true;
+ }
+
+ resolveBallInteractions(dt) {
+ if (!this.itemCounts?.ball) return;
+ for (const ball of this.items) {
+ if (!ball || ball.dead || ball.type !== "ball") continue;
+ const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
+ const sweep = Math.min(420, ballSpeed * Math.max(0.016, dt || 0.016) + 84);
+ const range = (ball.r || 18) + 72 + sweep;
+ const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
+ const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
+ const sx = ball.x - px;
+ const sy = ball.y - py;
+ const segLenSq = sx * sx + sy * sy;
+ for (const t of this.nearbyTarinai(ball.x, ball.y, range)) {
+ if (!t || t.dead || t.state === "sleep") continue;
+ let hitX = ball.x;
+ let hitY = ball.y;
+ if (segLenSq > 1) {
+ const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1);
+ hitX = px + sx * u;
+ hitY = py + sy * u;
+ }
+ let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y));
+ const hitDistance = (ball.r || 18) + t.radius * 0.74;
+ if (d > hitDistance) continue;
+ const now = this.time || 0;
+ const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18;
+ if (repeat && ballSpeed < 300) continue;
+ let nx = (hitX - t.x) / d;
+ let ny = (hitY - t.y) / d;
+ if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
+ if (ballSpeed > 0.01) {
+ nx = (ball.vx || 0) / ballSpeed;
+ ny = (ball.vy || 0) / ballSpeed;
+ } else {
+ nx = t.facingDir ? t.facingDir() : 1;
+ ny = rand(-0.22, 0.22);
+ }
+ }
+
+ const isDangerous = ballSpeed >= 320;
+ if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) {
+ const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
+ const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny;
+ const damage = clamp((ballSpeed - 285) / 18, 3.5, 38);
+ const push = clamp(ballSpeed * 0.34, 95, 310);
+ t.lastBallDamageAt = now;
+ t.vx += bvx * push;
+ t.vy += bvy * push;
+ t.damage(damage, "è¶
é«éăźăăŒă«ă«ćœăăŁă");
+ const timidStress = t.personality === "timid" ? 1.55 : 1.0;
+ t.stress = clamp(t.stress + clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18), 0, 130);
+ t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
+ t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.82 : 0.38);
+ t.fallMax = Math.max(t.fallMax || 0.82, t.fallTimer);
+ t.fallDir = bvx >= 0 ? 1 : -1;
+ t.fearTimer = Math.max(t.fearTimer, 0.8);
+ this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.6));
+ this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" }));
+ if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}ăŻè¶
é«éăźăăŒă«ă«ćœăăŁăŠăăĄăŒăžăćăăă`, "accident");
+ ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10);
+ ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10);
+ ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38);
+ if (t.dead) continue;
+ }
+
+ const relVx = (t.vx || 0) - (ball.vx || 0);
+ const relVy = (t.vy || 0) - (ball.vy || 0);
+ const relativeSpeed = Math.hypot(relVx, relVy);
+ const playIntent = t.state === "play_ball" || t.target === ball;
+ const playful = t.personality === "playful";
+ const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260);
+ ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52;
+ ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52;
+ const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
+ const kickCap = 1250;
+ if (kickedSpeed > kickCap) {
+ ball.vx = ball.vx / kickedSpeed * kickCap;
+ ball.vy = ball.vy / kickedSpeed * kickCap;
+ }
+ ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38);
+ ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding);
+ ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding);
+ ball.lastKickedAt = now;
+ ball.lastKickerId = t.id;
+ t.vx -= nx * Math.min(22, impulse * 0.10);
+ t.vy -= ny * Math.min(22, impulse * 0.10);
+ t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100);
+ const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20;
+ const timidBump = t.personality === "timid" && !playIntent ? clamp(ballSpeed / 190, 0.9, 3.8) : 0;
+ t.stress = clamp(t.stress - playRelief + timidBump, 0, 130);
+ if (playful && playIntent) {
+ t.loneliness = clamp(t.loneliness - 0.9, 0, 100);
+ t.affection = clamp(t.affection + 0.18, 0, 100);
+ }
+ t.goodMode = playIntent || playful ? "smile" : t.goodMode;
+ if (playIntent && now > (t.nextPlayBubbleAt || 0)) {
+ t.nextPlayBubbleAt = now + rand(2.0, 3.8);
+ this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "ăŻă", "?" ]), "rgba(70,96,50,0.76)");
+ }
+ if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}ăŻăăŒă«ăèżœăăăăŠćŒŸăăă`, "observe");
+ this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" }));
+ }
+ }
+ }
+
nearest(entity, types, maxDist = Infinity) {
let best = null, bestD = maxDist;
const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
@@ -1205,7 +1522,7 @@ class World {
pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56);
pruneOldest("trace", CONFIG.traceLimit ?? 64);
pruneOldest("splat", CONFIG.splatLimit ?? 48);
- pruneOldest("grass", CONFIG.grassLimit ?? 96);
+ pruneOldest("grass", this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96));
const limit = CONFIG.itemLimit ?? Infinity;
if (!Number.isFinite(limit) || this.items.length <= limit) return;
@@ -1253,18 +1570,22 @@ class World {
this.time += dt;
this.day = Math.floor(this.time / CONFIG.dayLength) + 1;
+ if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4);
this.updateWeather(dt);
this.rebuildSpatial();
const perf = this.performanceLevel();
const itemCountBefore = this.items.length;
for (const it of this.items) it.update(dt, this);
+ if (this.itemCounts?.ball) this.rebuildSpatial();
for (const ef of this.effects) ef.update(dt);
for (const t of this.tarinai) {
t.update(dt);
}
+ this.limitBallChasers();
+ this.resolveBallInteractions(dt);
this.compactTimer += dt;
const compactInterval = perf >= 2 ? 0.95 : 0.58;
@@ -1297,16 +1618,17 @@ class World {
audio.phase();
}
- if (this.tarinai.length > 0 && this.tarinai.length < 5 && Math.random() < dt * 0.03) {
- const spot = this.edgeSpawnPoint();
- const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId() });
+ if (this.tarinai.length > 0 && this.tarinai.length <= 10 && Math.random() < dt * 0.022) {
+ const spot = this.edgeSpawnPoint(null, null, 42, true);
+ const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 });
t.wanderAngle = spot.angle;
- this.log("\u3069\u3053\u304b\u304b\u3089\u305f\u308a\u306a\u3044\u304c\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002");
+ t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
+ this.log(`${t.name}ăç»éąć€ăăèż·ă蟌ăă§ăăă`);
}
const grassCount = this.itemCounts.grass || 0;
- if (grassCount < Math.min(24, CONFIG.grassLimit ?? 96) && Math.random() < dt * 0.036) {
+ if (grassCount < Math.min(24, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)) && Math.random() < dt * 0.036) {
const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), {
allowOriginal: true,
minRadius: 28,
@@ -1355,8 +1677,34 @@ class World {
this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`);
}
+ findGrabTargetAt(x, y) {
+ let foundT = null;
+ for (let i = this.tarinai.length - 1; i >= 0; i--) {
+ const t = this.tarinai[i];
+ if (!t || t.dead) continue;
+ if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; }
+ }
+ let foundItem = null, foundItemD = Infinity;
+ for (let i = this.items.length - 1; i >= 0; i--) {
+ const it = this.items[i];
+ if (!it || it.dead || it.type === "trace" || it.type === "splat") continue;
+ const hit = Math.max(24, (it.r || 12) * (it.type === "ball" ? 4.0 : 2.2));
+ const d = distXY(x, y, it.x, it.y);
+ if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; }
+ }
+ if (foundT && foundItem) {
+ const td = distXY(x, y, foundT.x, foundT.y);
+ return td <= foundItemD + 8 ? foundT : foundItem;
+ }
+ return foundT || foundItem;
+ }
+
placeItem(item, dropped = true) {
if (!item) return null;
+ if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) {
+ showToast("ăăźăăŁăŒă«ăăźèăźäžéă«éăăŠăăŸăă");
+ return null;
+ }
if (dropped) {
item.dropMax = item.type === "stone" ? 0.72 : 0.58;
item.dropTimer = item.dropMax;
@@ -1381,19 +1729,30 @@ class World {
}
if (tool === "poke") {
+ let ballTarget = null;
+ let ballTargetD = Infinity;
+ for (let i = this.items.length - 1; i >= 0; i--) {
+ const it = this.items[i];
+ if (!it || it.dead || it.type !== "ball") continue;
+ const hit = Math.max(72, (it.r || 18) * 4.0);
+ const d = distXY(x, y, it.x, it.y);
+ if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; }
+ }
+ if (ballTarget && this.pokeBall(ballTarget, x, y)) return;
+
const target = [...this.tarinai].reverse().find(t => t.contains(x, y));
if (target) {
target.poke();
- if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`);
+ if (!target.dead) this.log(`${target.name}ăŻă€ă€ăăăăăăȘăăăšăèŠăăă`);
} else {
- this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002");
+ this.log("ç©șæ°ăă€ă€ăăăăăă«ăŻäœăăăăȘăăŁăă");
}
return;
}
if (tool === "new") {
- const spot = this.edgeSpawnPoint(x, y);
- const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1 });
+ const spot = this.edgeSpawnPoint(x, y, 42, true);
+ const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 });
t.wanderAngle = spot.angle;
audio.birth();
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
@@ -1415,14 +1774,14 @@ class World {
this.compactItems();
this.updateItemCounts();
this.rebuildSpatial();
- const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
+ const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u5a9a\u85ac\u9905", fight_mochi: "\u55a7\u5629\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return;
}
const itemType = {
- zunchi: "zunchi", sweet: "sweet", water: "water", grass: "grass",
- stone: "stone", bed: "bed", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
+ zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", water: "water", grass: "grass",
+ stone: "stone", bed: "bed", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
}[tool];
if (itemType) {
let px = x, py = y;
@@ -1436,9 +1795,10 @@ class World {
px = spot.x;
py = spot.y;
}
- this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true);
- const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
- this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
+ const placed = this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true);
+ if (!placed) return;
+ const label = { zunchi: "ăăăĄ", food: "éŁăčç©", sweet: "ăăă é€
", love_mochi: "ćȘèŹé€
", fight_mochi: "ć§ć©é€
", water: "æ°Ž", grass: "è", stone: "çł", bed: "ćčČèćŻćș", ball: "ăăŒă«", firecracker: "çç«č", fence_v: "çžŠăźæ”", fence_h: "æšȘăźæ”" }[itemType];
+ this.log(`${label}ăé
çœźăăă`);
}
}
@@ -1494,11 +1854,11 @@ class World {
this.toolSizes = data.toolSizes || this.toolSizes || {};
this.maxGeneration = data.maxGeneration || 1;
this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden";
- this.fieldZoom = clamp(Number(data.fieldZoom) || 1, 0.42, 2.45);
+ this.fieldZoom = Number(data.fieldZoom) || 1;
this.cameraX = Number(data.cameraX) || 0;
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").map(o => Item.from(o));
+ this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food" && o?.type !== "cardboard").map(o => Item.from(o));
this.effects = [];
this.compactItems();
this.compactEffects();