1448 lines
65 KiB
JavaScript
1448 lines
65 KiB
JavaScript
|
|
"use strict";
|
|||
|
|
|
|||
|
|
class Tarinai {
|
|||
|
|
constructor(world, opts = {}) {
|
|||
|
|
this.world = world;
|
|||
|
|
this.id = opts.id || (crypto.randomUUID ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`);
|
|||
|
|
this.name = opts.name || makeName(world);
|
|||
|
|
this.personality = opts.personality || personalityForSeed(this.id);
|
|||
|
|
this.type = opts.type || baseSpriteId();
|
|||
|
|
const typeMeta = SPRITES.find(s => s.id === this.type);
|
|||
|
|
if (typeMeta?.actionOnly || typeMeta?.displayOnly) this.type = baseSpriteId();
|
|||
|
|
this.trait = makeTrait(this.type);
|
|||
|
|
const pdef = PERSONALITIES[this.personality] || PERSONALITIES.calm;
|
|||
|
|
this.trait.social *= pdef.social ?? 1;
|
|||
|
|
this.trait.stress /= pdef.fear ?? 1;
|
|||
|
|
this.trait.sleep *= pdef.sleep ?? 1;
|
|||
|
|
this.x = opts.x ?? rand(100, world.w - 100);
|
|||
|
|
this.y = opts.y ?? rand(100, world.h - 100);
|
|||
|
|
this.vx = opts.vx ?? rand(-12, 12);
|
|||
|
|
this.vy = opts.vy ?? rand(-12, 12);
|
|||
|
|
this.scale = opts.scale ?? rand(0.22, 0.33);
|
|||
|
|
this.radius = 56 * this.scale;
|
|||
|
|
this.age = opts.age ?? rand(0, 20);
|
|||
|
|
this.lifeSpan = opts.lifeSpan ?? rand(1400, 2200);
|
|||
|
|
if (this.lifeSpan < 1200) this.lifeSpan = rand(1400, 2200);
|
|||
|
|
this.generation = opts.generation ?? 1;
|
|||
|
|
this.hasPaired = opts.hasPaired ?? false;
|
|||
|
|
this.hunger = opts.hunger ?? rand(16, 45);
|
|||
|
|
this.loneliness = opts.loneliness ?? rand(18, 62);
|
|||
|
|
this.energy = opts.energy ?? rand(54, 98);
|
|||
|
|
this.stress = opts.stress ?? rand(0, 20);
|
|||
|
|
this.hpBarTimer = opts.hpBarTimer ?? 0;
|
|||
|
|
this.affection = opts.affection ?? rand(0, 30);
|
|||
|
|
this.need = opts.need || pick(NEEDS);
|
|||
|
|
this.state = opts.state || "idle";
|
|||
|
|
this.dead = false;
|
|||
|
|
this.deathReason = "";
|
|||
|
|
this.thought = "";
|
|||
|
|
this.target = null;
|
|||
|
|
this.targetKey = "";
|
|||
|
|
this.targetSince = 0;
|
|||
|
|
this.targetBestDist = Infinity;
|
|||
|
|
this.targetGiveupKey = opts.targetGiveupKey || "";
|
|||
|
|
this.targetGiveupUntil = opts.targetGiveupUntil ?? 0;
|
|||
|
|
this.panicTarget = opts.panicTarget || null;
|
|||
|
|
this.panicTargetUntil = opts.panicTargetUntil ?? 0;
|
|||
|
|
this.panicStuckTimer = opts.panicStuckTimer ?? 0;
|
|||
|
|
this.wanderAngle = rand(0, Math.PI * 2);
|
|||
|
|
this.reproductionTimer = rand(4, CONFIG.reproductionCooldown);
|
|||
|
|
this.lastLog = 0;
|
|||
|
|
this.sleeping = false;
|
|||
|
|
this.blink = rand(0, 10);
|
|||
|
|
this.lastSocial = 0;
|
|||
|
|
this.goodMode = opts.goodMode ?? stableChoice(this.id, "good-mode", displayNormalSprites()) ?? "smile";
|
|||
|
|
this.facing = opts.facing ?? (stableUnit(this.id, "facing") < 0.5 ? -1 : 1);
|
|||
|
|
this.visualFacing = opts.visualFacing ?? this.facing;
|
|||
|
|
this.facingLockUntil = opts.facingLockUntil ?? 0;
|
|||
|
|
this.eatTimer = opts.eatTimer ?? 0;
|
|||
|
|
this.eatCooldown = 0;
|
|||
|
|
this.foodReactTimer = opts.foodReactTimer ?? 0;
|
|||
|
|
this.surpriseTimer = opts.surpriseTimer ?? 0;
|
|||
|
|
this.fearTimer = opts.fearTimer ?? 0;
|
|||
|
|
this.intimidateTimer = opts.intimidateTimer ?? 0;
|
|||
|
|
this.intimidateTargetId = opts.intimidateTargetId || null;
|
|||
|
|
this.intimidatedTimer = opts.intimidatedTimer ?? 0;
|
|||
|
|
this.birthRitualTimer = opts.birthRitualTimer ?? 0;
|
|||
|
|
this.birthRitualMax = opts.birthRitualMax ?? 0;
|
|||
|
|
this.birthPartnerId = opts.birthPartnerId || null;
|
|||
|
|
this.birthRitualRole = opts.birthRitualRole || 0;
|
|||
|
|
this.birthRitualLeader = !!opts.birthRitualLeader;
|
|||
|
|
this.pokeFlashTimer = opts.pokeFlashTimer ?? 0;
|
|||
|
|
this.zunchiStain = 0;
|
|||
|
|
this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain");
|
|||
|
|
this.tempComfort = opts.tempComfort ?? 0.62;
|
|||
|
|
this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55;
|
|||
|
|
this.parents = opts.parents || [];
|
|||
|
|
this.parentNames = opts.parentNames || [];
|
|||
|
|
this.children = opts.children || [];
|
|||
|
|
this.birthTime = opts.birthTime ?? this.world.time;
|
|||
|
|
this.nextEatSound = opts.nextEatSound ?? 0;
|
|||
|
|
this.fightTimer = opts.fightTimer ?? 0;
|
|||
|
|
this.fightCooldown = opts.fightCooldown ?? rand(0, CONFIG.fightCooldown);
|
|||
|
|
this.fightTargetId = opts.fightTargetId || null;
|
|||
|
|
this.fightTargetIds = Array.isArray(opts.fightTargetIds) ? [...opts.fightTargetIds] : (this.fightTargetId ? [this.fightTargetId] : []);
|
|||
|
|
this.nextHeadbutt = opts.nextHeadbutt ?? 0;
|
|||
|
|
this.digest = opts.digest ?? rand(0, 2.4);
|
|||
|
|
this.poopCount = opts.poopCount ?? 0;
|
|||
|
|
this.stretchTimer = opts.stretchTimer ?? 0;
|
|||
|
|
this.lastMealColor = opts.lastMealColor || "#f7cf67";
|
|||
|
|
this.grassEatTimer = opts.grassEatTimer ?? 0;
|
|||
|
|
this.hurtTimer = opts.hurtTimer ?? 0;
|
|||
|
|
this.defeatedTimer = opts.defeatedTimer ?? 0;
|
|||
|
|
this.defeatedById = opts.defeatedById || null;
|
|||
|
|
this.fightWinnerId = opts.fightWinnerId || null;
|
|||
|
|
this.relationships = opts.relationships && typeof opts.relationships === "object" ? JSON.parse(JSON.stringify(opts.relationships)) : {};
|
|||
|
|
this.fallTimer = opts.fallTimer ?? 0;
|
|||
|
|
this.fallMax = opts.fallMax ?? 1.15;
|
|||
|
|
this.fallDir = opts.fallDir ?? (stableUnit(this.id, "fall-dir") < 0.5 ? -1 : 1);
|
|||
|
|
this.blastSpinTimer = opts.blastSpinTimer ?? 0;
|
|||
|
|
this.blastSpinMax = opts.blastSpinMax ?? 0;
|
|||
|
|
this.postBirthPeaceTimer = opts.postBirthPeaceTimer ?? 0;
|
|||
|
|
this.sleepStart = 0.60 + stableUnit(this.id, "sleep-start") * 0.22;
|
|||
|
|
this.sleepEnd = 0.15 + stableUnit(this.id, "sleep-end") * 0.14;
|
|||
|
|
this.sleepFacing = stableUnit(this.id, "sleep-facing") < 0.5 ? -1 : 1;
|
|||
|
|
this.nextBubbleAt = 0;
|
|||
|
|
this.nextSleepBubbleAt = 0;
|
|||
|
|
this.nextFearBubbleAt = 0;
|
|||
|
|
this.aiTimer = opts.aiTimer ?? stableUnit(this.id, "ai-start") * 0.22;
|
|||
|
|
this.envTimer = opts.envTimer ?? stableUnit(this.id, "env-start") * 0.55;
|
|||
|
|
this.visibleSpriteId = opts.visibleSpriteId || null;
|
|||
|
|
this.spriteLockUntil = opts.spriteLockUntil ?? 0;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
serialize() {
|
|||
|
|
return {
|
|||
|
|
id: this.id, name: this.name, type: this.type, x: this.x, y: this.y, vx: this.vx, vy: this.vy,
|
|||
|
|
scale: this.scale, age: this.age, lifeSpan: this.lifeSpan, generation: this.generation, hasPaired: this.hasPaired,
|
|||
|
|
hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer,
|
|||
|
|
affection: this.affection, need: this.need, state: this.state,
|
|||
|
|
goodMode: this.goodMode, facing: this.facing, visualFacing: this.visualFacing, facingLockUntil: this.facingLockUntil, eatTimer: this.eatTimer,
|
|||
|
|
foodReactTimer: this.foodReactTimer, surpriseTimer: this.surpriseTimer, fearTimer: this.fearTimer,
|
|||
|
|
intimidateTimer: this.intimidateTimer, intimidateTargetId: this.intimidateTargetId, intimidatedTimer: this.intimidatedTimer,
|
|||
|
|
birthRitualTimer: this.birthRitualTimer, birthRitualMax: this.birthRitualMax, birthPartnerId: this.birthPartnerId,
|
|||
|
|
birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader,
|
|||
|
|
pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed,
|
|||
|
|
tempComfort: this.tempComfort, tempTimer: this.tempTimer,
|
|||
|
|
parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime,
|
|||
|
|
fightTimer: this.fightTimer, fightCooldown: this.fightCooldown, fightTargetId: this.fightTargetId, fightTargetIds: this.fightTargetIds, targetGiveupKey: this.targetGiveupKey, targetGiveupUntil: this.targetGiveupUntil,
|
|||
|
|
panicTarget: this.panicTarget, panicTargetUntil: this.panicTargetUntil, panicStuckTimer: this.panicStuckTimer,
|
|||
|
|
nextHeadbutt: this.nextHeadbutt, digest: this.digest, poopCount: this.poopCount, stretchTimer: this.stretchTimer,
|
|||
|
|
grassEatTimer: this.grassEatTimer, hurtTimer: this.hurtTimer,
|
|||
|
|
defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId,
|
|||
|
|
relationships: this.relationships, personality: this.personality, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir,
|
|||
|
|
blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer,
|
|||
|
|
lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
static from(world, o) {
|
|||
|
|
return new Tarinai(world, o);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get lack() {
|
|||
|
|
return clamp((this.hunger + this.loneliness + this.stress + (100 - this.energy)) / 4, 0, 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get mood() {
|
|||
|
|
return clamp(100 - (this.hunger * 0.40 + this.loneliness * 0.20 + this.stress * 0.40), 0, 100);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get ageSinceBirth() {
|
|||
|
|
return Math.max(0, this.world.time - this.birthTime);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get growth() {
|
|||
|
|
return clamp(this.ageSinceBirth / CONFIG.childGrowTime, 0, 1);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
get juvenile() {
|
|||
|
|
return this.generation > 1 && this.growth < 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
parentToFollow() {
|
|||
|
|
if (!this.juvenile || !this.parents?.length) return null;
|
|||
|
|
let best = null, bestD = Infinity;
|
|||
|
|
for (const id of this.parents) {
|
|||
|
|
const p = this.world.tarinai.find(t => t.id === id && !t.dead);
|
|||
|
|
if (!p) continue;
|
|||
|
|
const d = dist(this, p);
|
|||
|
|
if (d < bestD) { best = p; bestD = d; }
|
|||
|
|
}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
label() {
|
|||
|
|
const sp = SPRITES.find(s => s.id === this.type);
|
|||
|
|
return sp ? sp.label : this.type;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
personalityProfile() {
|
|||
|
|
return PERSONALITIES[this.personality] || PERSONALITIES.calm;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
personalityLabel() {
|
|||
|
|
return this.personalityProfile().label || this.personality || "\u4e0d\u660e";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
relationTo(id) {
|
|||
|
|
if (!id) return relationDefaults();
|
|||
|
|
if (!this.relationships) this.relationships = {};
|
|||
|
|
if (!this.relationships[id]) this.relationships[id] = relationDefaults();
|
|||
|
|
return this.relationships[id];
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
adjustRelation(other, affinityDelta = 0, fearDelta = 0, event = "") {
|
|||
|
|
if (!other || !other.id || other === this) return;
|
|||
|
|
const rel = this.relationTo(other.id);
|
|||
|
|
rel.affinity = clamp((rel.affinity || 0) + affinityDelta, -24, 40);
|
|||
|
|
rel.fear = clamp((rel.fear || 0) + fearDelta, 0, 40);
|
|||
|
|
rel.fightsWon = rel.fightsWon || 0;
|
|||
|
|
rel.fightsLost = rel.fightsLost || 0;
|
|||
|
|
if (event) { rel.lastEvent = event; rel.lastTime = this.world.time; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
strongestRelation(kind = "friend") {
|
|||
|
|
let bestId = null;
|
|||
|
|
let bestScore = kind === "fear" ? 5 : 8;
|
|||
|
|
for (const [id, rel] of Object.entries(this.relationships || {})) {
|
|||
|
|
const score = kind === "fear" ? (rel.fear || 0) : (rel.affinity || 0);
|
|||
|
|
if (score > bestScore) { bestId = id; bestScore = score; }
|
|||
|
|
}
|
|||
|
|
return bestId ? { id: bestId, score: bestScore, name: relationDisplayName(this.world, bestId) } : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
relationSummary() {
|
|||
|
|
const friend = this.strongestRelation("friend");
|
|||
|
|
const fear = this.strongestRelation("fear");
|
|||
|
|
return { friend, fear };
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bestFriendLive(minScore = 10, maxDist = Infinity) {
|
|||
|
|
let best = null, bestScore = minScore;
|
|||
|
|
for (const [id, rel] of Object.entries(this.relationships || {})) {
|
|||
|
|
const score = rel.affinity || 0;
|
|||
|
|
if (score <= bestScore) continue;
|
|||
|
|
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
|
|||
|
|
if (!t) continue;
|
|||
|
|
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
|||
|
|
best = t;
|
|||
|
|
bestScore = score;
|
|||
|
|
}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
recentFightRival(maxAge = 52, maxDist = Infinity) {
|
|||
|
|
let best = null, bestT = -Infinity;
|
|||
|
|
for (const [id, rel] of Object.entries(this.relationships || {})) {
|
|||
|
|
const event = String(rel.lastEvent || "");
|
|||
|
|
const when = rel.lastTime || -Infinity;
|
|||
|
|
if (this.world.time - when > maxAge) continue;
|
|||
|
|
if (!(event.includes("fight") || event.includes("\u55a7\u5629") || (rel.fightsWon || 0) || (rel.fightsLost || 0))) continue;
|
|||
|
|
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
|
|||
|
|
if (!t) continue;
|
|||
|
|
if (Number.isFinite(maxDist) && dist(this, t) > maxDist) continue;
|
|||
|
|
if (when > bestT) { best = t; bestT = when; }
|
|||
|
|
}
|
|||
|
|
return best;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
poke() {
|
|||
|
|
audio.poke();
|
|||
|
|
this.damage(rand(9, 16), "\u3064\u3064\u304b\u308c\u3059\u304e");
|
|||
|
|
this.stress = clamp(this.stress + 22, 0, 130);
|
|||
|
|
this.surpriseTimer = 0.55;
|
|||
|
|
this.fearTimer = Math.max(this.fearTimer, 1.2);
|
|||
|
|
this.pokeFlashTimer = 0.55;
|
|||
|
|
this.hurtTimer = Math.max(this.hurtTimer, 2.4);
|
|||
|
|
this.vx += rand(-100, 100);
|
|||
|
|
this.vy += rand(-100, 100);
|
|||
|
|
if (this.energy <= 0.5) this.die("\u3064\u3064\u304b\u308c\u3059\u304e");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
showHpBar(duration = 4.8) {
|
|||
|
|
this.hpBarTimer = Math.max(this.hpBarTimer || 0, duration);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
damage(amount, reason = "") {
|
|||
|
|
const before = this.energy;
|
|||
|
|
this.energy = clamp(this.energy - Math.max(0, amount || 0), 0, 100);
|
|||
|
|
if (this.energy < before - 0.01) this.showHpBar();
|
|||
|
|
if (this.energy <= 0.5 && reason) this.die(reason);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
recoverHealth(amount) {
|
|||
|
|
if (this.dead) return;
|
|||
|
|
const before = this.energy;
|
|||
|
|
this.energy = clamp(this.energy + Math.max(0, amount || 0), 0, 100);
|
|||
|
|
if (this.energy > before + 0.01 && before < 99.5) this.showHpBar(2.4);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
familyJoined() {
|
|||
|
|
return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
displayGeneration() {
|
|||
|
|
return this.familyJoined() ? this.generation : null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
targetIdentity(target = this.target) {
|
|||
|
|
if (!target) return "";
|
|||
|
|
if (target.id) return `${target.type || "tarinai"}:${target.id}`;
|
|||
|
|
if (target.type) return `${target.type}:${Math.round(target.x || 0)}:${Math.round(target.y || 0)}`;
|
|||
|
|
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return `point:${Math.round(target.x / 8)}:${Math.round(target.y / 8)}`;
|
|||
|
|
return "";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
targetIgnoresFence(target = this.target) {
|
|||
|
|
return Boolean(target?.type === "sweet" || this.state === "panic" || this.state === "fight" || this.state === "intimidate" || this.state === "birth_ritual" || this.state === "cursor_enemy");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
shouldAvoidTarget(target) {
|
|||
|
|
if (!target || !Number.isFinite(target.x) || !Number.isFinite(target.y)) return false;
|
|||
|
|
if (this.targetIgnoresFence(target)) return false;
|
|||
|
|
const key = this.targetIdentity(target);
|
|||
|
|
if (key && this.targetGiveupKey === key && this.world.time < (this.targetGiveupUntil || 0)) return true;
|
|||
|
|
return this.world.pathBlockedByFence ? this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10) : false;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
maintainTargetProgress(dt) {
|
|||
|
|
const target = this.target;
|
|||
|
|
if (!target || target.dead || !Number.isFinite(target.x) || !Number.isFinite(target.y)) {
|
|||
|
|
this.targetKey = "";
|
|||
|
|
this.targetSince = this.world.time;
|
|||
|
|
this.targetBestDist = Infinity;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (this.targetIgnoresFence(target)) return;
|
|||
|
|
const key = this.targetIdentity(target);
|
|||
|
|
const d = distXY(this.x, this.y, target.x, target.y);
|
|||
|
|
if (key !== this.targetKey) {
|
|||
|
|
this.targetKey = key;
|
|||
|
|
this.targetSince = this.world.time;
|
|||
|
|
this.targetBestDist = d;
|
|||
|
|
} else if (d < (this.targetBestDist || Infinity) - 8) {
|
|||
|
|
this.targetBestDist = d;
|
|||
|
|
this.targetSince = this.world.time;
|
|||
|
|
}
|
|||
|
|
if (this.world.pathBlockedByFence && this.world.pathBlockedByFence(this.x, this.y, target.x, target.y, this.radius + 10)) {
|
|||
|
|
this.targetGiveupKey = key;
|
|||
|
|
this.targetGiveupUntil = this.world.time + 8;
|
|||
|
|
this.target = null;
|
|||
|
|
this.state = "idle";
|
|||
|
|
this.wanderAngle += rand(-1.4, 1.4);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (this.world.time - (this.targetSince || this.world.time) > 30 && d > this.radius + 24) {
|
|||
|
|
this.targetGiveupKey = key;
|
|||
|
|
this.targetGiveupUntil = this.world.time + 12;
|
|||
|
|
this.target = null;
|
|||
|
|
this.state = "idle";
|
|||
|
|
this.wanderAngle += rand(-2.2, 2.2);
|
|||
|
|
this.thought = "\u8fbf\u308a\u7740\u3051\u305a\u3001\u3042\u304d\u3089\u3081\u305f";
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
panicDestination(threat = null, force = false) {
|
|||
|
|
const now = this.world.time || 0;
|
|||
|
|
const current = this.panicTarget;
|
|||
|
|
if (!force && current && now < (this.panicTargetUntil || 0) && distXY(this.x, this.y, current.x, current.y) > 34) return current;
|
|||
|
|
let ax = Math.cos(this.wanderAngle || 0);
|
|||
|
|
let ay = Math.sin(this.wanderAngle || 0);
|
|||
|
|
if (threat && Number.isFinite(threat.x) && Number.isFinite(threat.y)) {
|
|||
|
|
const dx = this.x - threat.x;
|
|||
|
|
const dy = this.y - threat.y;
|
|||
|
|
const d = Math.hypot(dx, dy) || 1;
|
|||
|
|
ax = dx / d;
|
|||
|
|
ay = dy / d;
|
|||
|
|
}
|
|||
|
|
const turn = rand(-0.75, 0.75);
|
|||
|
|
const cos = Math.cos(turn);
|
|||
|
|
const sin = Math.sin(turn);
|
|||
|
|
const dirX = ax * cos - ay * sin;
|
|||
|
|
const dirY = ax * sin + ay * cos;
|
|||
|
|
const distance = rand(150, 250);
|
|||
|
|
const p = CONFIG.worldPadding + this.radius;
|
|||
|
|
let x = clamp(this.x + dirX * distance, p, this.world.w - p);
|
|||
|
|
let y = clamp(this.y + dirY * distance, p, this.world.h - p);
|
|||
|
|
if (distXY(this.x, this.y, x, y) < 70) {
|
|||
|
|
x = clamp(this.x - dirY * distance, p, this.world.w - p);
|
|||
|
|
y = clamp(this.y + dirX * distance, p, this.world.h - p);
|
|||
|
|
}
|
|||
|
|
this.panicTarget = { x, y, dead: false, detour: true, panic: true };
|
|||
|
|
this.panicTargetUntil = now + rand(1.6, 3.0);
|
|||
|
|
this.panicStuckTimer = 0;
|
|||
|
|
this.wanderAngle = Math.atan2(y - this.y, x - this.x);
|
|||
|
|
return this.panicTarget;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
lowHealthSprite() {
|
|||
|
|
const critical = this.lack > 96 || this.hunger > 112 || this.energy < 7 || this.stress > 118;
|
|||
|
|
if (critical) return this.stretchTimer > 0 ? "stretch" : "weak";
|
|||
|
|
const weak = this.lack > 88 || this.hunger > 102 || this.energy < 18 || this.stress > 104;
|
|||
|
|
if (weak) return "weak";
|
|||
|
|
return null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
variantSprite(category) {
|
|||
|
|
const pools = {
|
|||
|
|
hurt: ["hurt", "hurt2"],
|
|||
|
|
fear: ["teary", "fear", "flee_fear2", "fear_blue", "fear_cry"],
|
|||
|
|
flee: ["flee", "flee_fear2"],
|
|||
|
|
sleep: ["sleep", "sleep2"],
|
|||
|
|
stress: ["stress_dizzy", "stress_sweat"],
|
|||
|
|
low_stress: lowStressSprites(),
|
|||
|
|
intimidate: ["intimidate"],
|
|||
|
|
normal: displayNormalSprites(),
|
|||
|
|
};
|
|||
|
|
const options = (pools[category] || []).filter(id => SPRITES.some(s => s.id === id));
|
|||
|
|
return stableChoice(this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile";
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
rawSpriteId() {
|
|||
|
|
if (this.dead) return this.variantSprite("fear");
|
|||
|
|
if (this.type === "cry") return "cry";
|
|||
|
|
if (this.type === "stretch") return "stretch";
|
|||
|
|
if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat";
|
|||
|
|
if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat";
|
|||
|
|
if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt");
|
|||
|
|
if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual";
|
|||
|
|
const low = this.lowHealthSprite();
|
|||
|
|
if (low) 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 === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear");
|
|||
|
|
if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep");
|
|||
|
|
if (this.state === "fight" || this.fightTimer > 0.04) return "angry";
|
|||
|
|
if (this.surpriseTimer > 0.08) return "pokan";
|
|||
|
|
if (this.state === "cursor_friend") return "smile";
|
|||
|
|
if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode;
|
|||
|
|
if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back";
|
|||
|
|
if (this.state === "seek_food") return "drool";
|
|||
|
|
if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress");
|
|||
|
|
if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress");
|
|||
|
|
if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode;
|
|||
|
|
if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry";
|
|||
|
|
return this.goodMode || this.variantSprite("normal");
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
spriteId() {
|
|||
|
|
const next = this.rawSpriteId();
|
|||
|
|
const now = this.world?.time || 0;
|
|||
|
|
if (!this.visibleSpriteId) {
|
|||
|
|
this.visibleSpriteId = next;
|
|||
|
|
this.spriteLockUntil = now + 1.0;
|
|||
|
|
return next;
|
|||
|
|
}
|
|||
|
|
if (next !== this.visibleSpriteId && now >= this.spriteLockUntil) {
|
|||
|
|
this.visibleSpriteId = next;
|
|||
|
|
this.spriteLockUntil = now + 1.0;
|
|||
|
|
}
|
|||
|
|
return this.visibleSpriteId;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
update(dt) {
|
|||
|
|
if (this.dead) return;
|
|||
|
|
|
|||
|
|
const minute = dt / 60;
|
|||
|
|
this.age += dt;
|
|||
|
|
this.reproductionTimer -= dt;
|
|||
|
|
this.blink += dt;
|
|||
|
|
this.eatTimer = Math.max(0, this.eatTimer - dt);
|
|||
|
|
this.eatCooldown = Math.max(0, this.eatCooldown - dt);
|
|||
|
|
this.foodReactTimer = Math.max(0, this.foodReactTimer - dt);
|
|||
|
|
this.surpriseTimer = Math.max(0, this.surpriseTimer - dt);
|
|||
|
|
this.fearTimer = Math.max(0, this.fearTimer - dt);
|
|||
|
|
this.intimidateTimer = Math.max(0, this.intimidateTimer - dt);
|
|||
|
|
this.intimidatedTimer = Math.max(0, this.intimidatedTimer - dt);
|
|||
|
|
this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt);
|
|||
|
|
this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt);
|
|||
|
|
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
|
|||
|
|
if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null;
|
|||
|
|
if (this.birthRitualTimer > 0) {
|
|||
|
|
const beforeBirthTimer = this.birthRitualTimer;
|
|||
|
|
this.birthRitualTimer = Math.max(0, this.birthRitualTimer - dt);
|
|||
|
|
if (beforeBirthTimer > 0 && this.birthRitualTimer <= 0.04 && this.birthRitualLeader) {
|
|||
|
|
const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
|
|||
|
|
if (partner) this.world.finishBirthRitual(this, partner);
|
|||
|
|
this.birthRitualLeader = false;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt);
|
|||
|
|
const wasFighting = this.fightTimer > 0.04;
|
|||
|
|
this.fightTimer = Math.max(0, this.fightTimer - dt);
|
|||
|
|
this.fightCooldown = Math.max(0, this.fightCooldown - dt);
|
|||
|
|
this.defeatedTimer = Math.max(0, this.defeatedTimer - dt);
|
|||
|
|
if (wasFighting && this.fightTimer <= 0.04 && this.defeatedById) {
|
|||
|
|
const winner = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead) || null;
|
|||
|
|
this.defeatedTimer = Math.max(this.defeatedTimer, rand(3.4, 5.2));
|
|||
|
|
this.fearTimer = Math.max(this.fearTimer, 1.0 * this.personalityProfile().fear);
|
|||
|
|
this.hurtTimer = Math.max(this.hurtTimer, 1.8);
|
|||
|
|
this.fallTimer = Math.max(this.fallTimer, 1.15);
|
|||
|
|
this.fallMax = Math.max(this.fallMax || 1.15, this.fallTimer);
|
|||
|
|
this.fallDir = winner ? (this.x < winner.x ? -1 : 1) : this.fallDir;
|
|||
|
|
this.world.spawnFallEffect(this.x, this.y + this.radius * 0.45);
|
|||
|
|
if (winner) this.world.resolveFightOutcome(winner, this);
|
|||
|
|
this.fightTargetIds = [];
|
|||
|
|
this.fightTargetId = null;
|
|||
|
|
}
|
|||
|
|
this.stretchTimer = Math.max(0, this.stretchTimer - dt);
|
|||
|
|
this.grassEatTimer = Math.max(0, this.grassEatTimer - dt);
|
|||
|
|
this.hurtTimer = Math.max(0, this.hurtTimer - dt);
|
|||
|
|
this.fallTimer = Math.max(0, this.fallTimer - dt);
|
|||
|
|
this.zunchiStain = 0;
|
|||
|
|
if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) {
|
|||
|
|
this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)");
|
|||
|
|
}
|
|||
|
|
if (this.state === "idle" && this.stress < 78 && Math.random() < dt * 0.018) {
|
|||
|
|
this.bubble(pick(["\u306f\u3046", "\u306f\u3046\u3045", "\u306f\u3046\u3063", "\u306f\u3045"]), 4.2, "rgba(62,84,45,0.76)");
|
|||
|
|
}
|
|||
|
|
if (this.hurtTimer > 0 && Math.random() < dt * 5.2) {
|
|||
|
|
const side = this.facingDir();
|
|||
|
|
this.world.spawnBleedEffect(this.x - side * this.radius * rand(0.15, 0.42), this.y - this.radius * rand(0.08, 0.34));
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.tempTimer += dt;
|
|||
|
|
if (this.tempTimer >= 0.55) {
|
|||
|
|
this.tempComfort = this.world.temperatureAt(this.x, this.y);
|
|||
|
|
this.tempTimer = 0;
|
|||
|
|
}
|
|||
|
|
const tempComfort = this.tempComfort ?? 0.62;
|
|||
|
|
const tooCold = Math.max(0, 0.48 - tempComfort);
|
|||
|
|
this.hunger += CONFIG.hungerPerMinute * minute * this.trait.hunger * (1 + tooCold * 0.8);
|
|||
|
|
this.loneliness += CONFIG.lonelinessPerMinute * minute * (2 - this.trait.social);
|
|||
|
|
this.energy -= CONFIG.energyPerMinute * minute * this.trait.sleep * (this.state === "sleep" ? 0.025 : 1);
|
|||
|
|
this.stress -= CONFIG.stressDecayPerMinute * minute / this.trait.stress;
|
|||
|
|
this.stress += tooCold * dt * 0.25;
|
|||
|
|
|
|||
|
|
const phase = this.world.lightLevel();
|
|||
|
|
if (phase < 0.25) this.energy += dt * 0.07;
|
|||
|
|
if (phase > 0.75) this.stress += dt * 0.04;
|
|||
|
|
if (this.world.weather === "cloudy") {
|
|||
|
|
this.stress -= dt * 0.018;
|
|||
|
|
this.vx *= Math.pow(0.996, dt * 60);
|
|||
|
|
this.vy *= Math.pow(0.996, dt * 60);
|
|||
|
|
} else if (this.world.weather === "light_rain") {
|
|||
|
|
this.energy += dt * 0.018;
|
|||
|
|
this.stress -= dt * 0.025;
|
|||
|
|
} else if (this.world.weather === "sunny") {
|
|||
|
|
this.vx *= Math.pow(1.001, dt * 60);
|
|||
|
|
this.vy *= Math.pow(1.001, dt * 60);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.juvenile) {
|
|||
|
|
const adultScale = 0.25 + (makeTrait(this.type).speed % 0.08);
|
|||
|
|
this.scale = lerp(this.scale, adultScale, dt * 0.018 + 0.002);
|
|||
|
|
this.radius = 56 * this.scale;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.hunger = clamp(this.hunger, 0, 115);
|
|||
|
|
this.loneliness = clamp(this.loneliness, 0, 110);
|
|||
|
|
this.energy = clamp(this.energy, 0, 100);
|
|||
|
|
this.stress = clamp(this.stress, 0, 130);
|
|||
|
|
|
|||
|
|
this.aiTimer += dt;
|
|||
|
|
const urgentAi = this.fightTimer > 0.04 || this.intimidateTimer > 0.04 || this.intimidatedTimer > 0.04 || this.birthRitualTimer > 0.04 || this.defeatedTimer > 0.04 || this.eatTimer > 0.02 || this.hurtTimer > 0.02;
|
|||
|
|
const popPerf = this.world.performanceLevel ? this.world.performanceLevel() : 0;
|
|||
|
|
const aiInterval = urgentAi ? (popPerf >= 2 ? 0.105 : 0.075) : (0.18 + stableUnit(this.id, "ai-interval") * 0.09 + popPerf * 0.055);
|
|||
|
|
if (this.aiTimer >= aiInterval) {
|
|||
|
|
const aiDt = Math.min(this.aiTimer, 0.48);
|
|||
|
|
this.aiTimer = 0;
|
|||
|
|
this.resolveNeeds(aiDt);
|
|||
|
|
const sleepingNow = this.state === "sleep";
|
|||
|
|
if (!sleepingNow) {
|
|||
|
|
this.reactToFreshZunchi(aiDt);
|
|||
|
|
this.updateFacing();
|
|||
|
|
this.interactWithItems(aiDt);
|
|||
|
|
this.interactWithOthers(aiDt);
|
|||
|
|
} else {
|
|||
|
|
this.updateFacing();
|
|||
|
|
}
|
|||
|
|
} else {
|
|||
|
|
this.updateFacing();
|
|||
|
|
}
|
|||
|
|
this.envTimer += dt;
|
|||
|
|
const envInterval = (this.world.performanceLevel && this.world.performanceLevel() >= 2) ? 0.82 : 0.48;
|
|||
|
|
if (this.envTimer >= envInterval) {
|
|||
|
|
const envDt = Math.min(this.envTimer, 1.2);
|
|||
|
|
this.envTimer = 0;
|
|||
|
|
this.applyEnvironment(envDt);
|
|||
|
|
}
|
|||
|
|
this.maintainTargetProgress(dt);
|
|||
|
|
this.move(dt);
|
|||
|
|
if (this.world.resolveFenceCollision) this.world.resolveFenceCollision(this);
|
|||
|
|
this.checkLife();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
applyEnvironment(dt) {
|
|||
|
|
const parent = this.parentToFollow ? this.parentToFollow() : null;
|
|||
|
|
if (parent && dist(this, parent) < 96) {
|
|||
|
|
this.stress = clamp(this.stress - dt * 0.42, 0, 130);
|
|||
|
|
this.loneliness = clamp(this.loneliness - dt * 0.32, 0, 110);
|
|||
|
|
}
|
|||
|
|
const friend = this.bestFriendLive ? this.bestFriendLive(12, 92) : null;
|
|||
|
|
if (friend) this.stress = clamp(this.stress - dt * 0.36, 0, 130);
|
|||
|
|
let grassComfort = 0;
|
|||
|
|
for (const it of this.world.nearbyItems(this.x, this.y, 105)) {
|
|||
|
|
if (it.type !== "grass" || it.dead) continue;
|
|||
|
|
const d = distXY(this.x, this.y, it.x, it.y);
|
|||
|
|
const lush = clamp((it.growth ?? it.amount / 120) * (it.health ?? 1), 0, 1.2);
|
|||
|
|
grassComfort += clamp(1 - d / 105, 0, 1) * lush;
|
|||
|
|
}
|
|||
|
|
if (grassComfort > 0) {
|
|||
|
|
this.stress = clamp(this.stress - dt * Math.min(0.32, grassComfort * 0.08), 0, 130);
|
|||
|
|
if (this.state === "panic") this.fearTimer = Math.max(0, this.fearTimer - dt * Math.min(0.22, grassComfort * 0.06));
|
|||
|
|
if (this.need === "\u9759\u3051\u3055" || this.personality === "timid") this.affection += dt * Math.min(0.06, grassComfort * 0.012);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
bubble(text, cooldown = 2.4, color = "rgba(42,36,29,0.78)") {
|
|||
|
|
if (this.world.time < this.nextBubbleAt) return;
|
|||
|
|
this.nextBubbleAt = this.world.time + cooldown;
|
|||
|
|
this.world.spawnBubble(this.x, this.y - this.radius * 1.35, text, color);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
mouthPosition(target = null) {
|
|||
|
|
const dir = target ? Math.sign((target.x ?? this.x) - this.x) || this.facingDir() : this.facingDir();
|
|||
|
|
return {
|
|||
|
|
x: this.x + dir * this.radius * 0.40 - this.radius * 0.34,
|
|||
|
|
y: this.y - this.radius * 0.28,
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
sleepSpotFor(bed) {
|
|||
|
|
if (!bed) return { x: this.x, y: this.y };
|
|||
|
|
const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2;
|
|||
|
|
const baseRing = bed.r * 0.52;
|
|||
|
|
const ring = baseRing + stableUnit(this.id, `bed-ring-${bed.id || bed.seed || "bed"}`) * bed.r * 0.95;
|
|||
|
|
return {
|
|||
|
|
x: clamp(bed.x + Math.cos(angle) * ring, CONFIG.worldPadding, this.world.w - CONFIG.worldPadding),
|
|||
|
|
y: clamp(bed.y + Math.sin(angle) * ring * 0.56 - this.radius * 0.10, CONFIG.worldPadding, this.world.h - CONFIG.worldPadding),
|
|||
|
|
};
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
desiredFacingDir() {
|
|||
|
|
if (this.state === "sleep") return this.sleepFacing || this.facing || 1;
|
|||
|
|
if (this.target && Number.isFinite(this.target.x)) return Math.sign(this.target.x - this.x) || this.facing || 1;
|
|||
|
|
if (Math.abs(this.vx) > 2.2) return Math.sign(this.vx);
|
|||
|
|
return this.facing || 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
facingDir() {
|
|||
|
|
return this.visualFacing || this.facing || 1;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
updateFacing() {
|
|||
|
|
const desired = this.desiredFacingDir();
|
|||
|
|
const now = this.world?.time || 0;
|
|||
|
|
this.facing = desired;
|
|||
|
|
if (!this.visualFacing) {
|
|||
|
|
this.visualFacing = desired;
|
|||
|
|
this.facingLockUntil = now + 1.0;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (Math.sign(desired) !== Math.sign(this.visualFacing) && now >= (this.facingLockUntil || 0)) {
|
|||
|
|
this.visualFacing = Math.sign(desired) || this.visualFacing || 1;
|
|||
|
|
this.facingLockUntil = now + 1.0;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
reactToFreshZunchi(dt) {
|
|||
|
|
if (this.state === "panic" || this.fearTimer > 0.2) return;
|
|||
|
|
const z = this.world.nearest(this, ["zunchi"], 90);
|
|||
|
|
if (!z || z.stage !== "fresh") return;
|
|||
|
|
const d = dist(this, z);
|
|||
|
|
const push = clamp(1 - d / 90, 0, 1);
|
|||
|
|
this.stress = clamp(this.stress + push * dt * 1.2 * this.personalityProfile().fear, 0, 130);
|
|||
|
|
this.vx += (this.x - z.x) / Math.max(d, 1) * push * dt * 40;
|
|||
|
|
this.vy += (this.y - z.y) / Math.max(d, 1) * push * dt * 40;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
resolveNeeds(dt) {
|
|||
|
|
const wasSleeping = this.state === "sleep";
|
|||
|
|
const previousTarget = this.target && !this.target.dead ? this.target : null;
|
|||
|
|
this.target = null;
|
|||
|
|
this.sleeping = false;
|
|||
|
|
|
|||
|
|
if (this.birthRitualTimer > 0.04) {
|
|||
|
|
this.state = "birth_ritual";
|
|||
|
|
this.target = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead) || null;
|
|||
|
|
this.thought = "\u8fd1\u304f\u3067\u4f53\u3092\u3086\u3059\u3063\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.intimidateTimer > 0.04) {
|
|||
|
|
this.state = "intimidate";
|
|||
|
|
this.target = this.world.tarinai.find(o => o.id === this.intimidateTargetId && !o.dead) || null;
|
|||
|
|
this.bubble("!", 1.2);
|
|||
|
|
this.thought = "\u76f8\u624b\u3092\u5a01\u5687\u3057\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.fightTimer > 0.04) {
|
|||
|
|
this.state = "fight";
|
|||
|
|
this.fightTargetIds = (this.fightTargetIds || []).filter(id => this.world.tarinai.some(t => t.id === id && !t.dead));
|
|||
|
|
this.fightTargetId = this.fightTargetIds[0] || this.fightTargetId;
|
|||
|
|
let best = null, bestD = Infinity;
|
|||
|
|
for (const id of this.fightTargetIds) {
|
|||
|
|
const t = this.world.tarinai.find(o => o.id === id && !o.dead);
|
|||
|
|
if (!t) continue;
|
|||
|
|
const d = dist(this, t);
|
|||
|
|
if (d < bestD) { best = t; bestD = d; }
|
|||
|
|
}
|
|||
|
|
this.target = best;
|
|||
|
|
this.bubble("!", 1.6);
|
|||
|
|
this.thought = "\u55a7\u5629\u3057\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.defeatedTimer > 0.04) {
|
|||
|
|
const opponent = this.world.tarinai.find(o => o.id === this.defeatedById && !o.dead);
|
|||
|
|
this.state = "panic";
|
|||
|
|
this.target = this.panicDestination(opponent);
|
|||
|
|
this.thought = "\u55a7\u5629\u306b\u8ca0\u3051\u3066\u9003\u3052\u3066\u3044\u308b";
|
|||
|
|
if (!opponent && this.defeatedTimer < 0.12) this.defeatedById = null;
|
|||
|
|
return;
|
|||
|
|
} else if (this.defeatedById && this.fightTimer <= 0.04) {
|
|||
|
|
this.defeatedById = null;
|
|||
|
|
this.fightWinnerId = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const friendNearby = this.bestFriendLive ? this.bestFriendLive(12, 170) : null;
|
|||
|
|
if (friendNearby && friendNearby.state === "panic" && friendNearby.target) {
|
|||
|
|
this.state = "panic";
|
|||
|
|
this.target = this.panicDestination(friendNearby.target);
|
|||
|
|
this.fearTimer = Math.max(this.fearTimer, 0.32);
|
|||
|
|
this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u9003\u3052\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const dayP = this.world.dayProgress();
|
|||
|
|
const personalSleep = this.sleepStart < this.sleepEnd
|
|||
|
|
? dayP >= this.sleepStart && dayP < this.sleepEnd
|
|||
|
|
: dayP >= this.sleepStart || dayP < this.sleepEnd;
|
|||
|
|
|
|||
|
|
const recentRival = this.recentFightRival ? this.recentFightRival(60, 170) : null;
|
|||
|
|
const feared = this.strongestRelation("fear");
|
|||
|
|
if (!wasSleeping && (recentRival || (feared && feared.score > 12))) {
|
|||
|
|
const rival = recentRival || this.world.tarinai.find(o => o.id === feared.id && !o.dead);
|
|||
|
|
if (rival && dist(this, rival) < 150) {
|
|||
|
|
this.state = "panic";
|
|||
|
|
this.target = this.panicDestination(rival);
|
|||
|
|
this.fearTimer = Math.max(this.fearTimer, 0.35 * this.personalityProfile().fear);
|
|||
|
|
this.thought = `${rival.name}\u3092\u907f\u3051\u3066\u9060\u56de\u308a\u3057\u3066\u3044\u308b`;
|
|||
|
|
if (this.world.relationNotice(this.id, rival.id, "avoid", 22)) this.world.log(`${this.name}\u306f\u4ee5\u524d\u306e\u55a7\u5629\u5f8c\u3001${rival.name}\u3092\u907f\u3051\u305f\u3002`, "relation");
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda;
|
|||
|
|
const zunda = this.world.nearest(this, ["sweet"], zundaRange);
|
|||
|
|
const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1));
|
|||
|
|
const zundaReward = this.affection > 8 && this.hunger > 7;
|
|||
|
|
if (zunda && zunda.amount > 1.2 && ((wasSleeping && this.hunger > 7) || zundaHungry || zundaReward)) {
|
|||
|
|
this.state = "seek_food";
|
|||
|
|
this.target = zunda;
|
|||
|
|
this.foodReactTimer = Math.max(this.foodReactTimer, 0.36);
|
|||
|
|
this.thought = "\u305a\u3093\u3060\u9905\u3092\u63a2\u3057\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.fearTimer > 0.22 || this.stress > 86 / this.personalityProfile().fear) {
|
|||
|
|
this.state = "panic";
|
|||
|
|
this.target = this.panicDestination();
|
|||
|
|
this.thought = "\u9003\u3052\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const sleepBias = this.personalityProfile().sleep;
|
|||
|
|
const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias);
|
|||
|
|
if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) {
|
|||
|
|
let bed = previousTarget && previousTarget.type === "bed" ? previousTarget : null;
|
|||
|
|
if (!bed && friendNearby && friendNearby.target?.type === "bed" && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target;
|
|||
|
|
if (!bed && !wasSleeping) bed = this.world.bestBedFor(this, 360);
|
|||
|
|
if (bed && !wasSleeping && this.energy > 18) {
|
|||
|
|
const spot = this.sleepSpotFor(bed);
|
|||
|
|
if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) {
|
|||
|
|
this.state = "seek_bed";
|
|||
|
|
this.target = bed;
|
|||
|
|
this.thought = "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
this.state = "sleep";
|
|||
|
|
this.target = bed;
|
|||
|
|
this.sleeping = true;
|
|||
|
|
if (this.world.time > this.nextSleepBubbleAt) {
|
|||
|
|
this.nextSleepBubbleAt = this.world.time + 4.5;
|
|||
|
|
this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
|
|||
|
|
}
|
|||
|
|
this.thought = bed ? "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const pointer = this.world.pointer;
|
|||
|
|
if (pointer?.inside) {
|
|||
|
|
const pd = distXY(this.x, this.y, pointer.x, pointer.y);
|
|||
|
|
if (pd < 170) {
|
|||
|
|
const hostile = this.mood < 38 || this.stress > 58 || this.type === "angry";
|
|||
|
|
const friendly = !hostile && (this.mood > 52 || this.affection > 12 || this.goodMode === "smile");
|
|||
|
|
if (hostile) {
|
|||
|
|
this.state = "cursor_enemy";
|
|||
|
|
this.target = { x: pointer.x, y: pointer.y, dead: false };
|
|||
|
|
this.fearTimer = Math.max(this.fearTimer, 0.18);
|
|||
|
|
if (this.world.time > this.nextFearBubbleAt) {
|
|||
|
|
this.nextFearBubbleAt = this.world.time + 2.4;
|
|||
|
|
this.world.spawnBubble(this.x, this.y - this.radius * 1.2, "!", "rgba(120,62,62,0.78)");
|
|||
|
|
}
|
|||
|
|
this.thought = "\u30ab\u30fc\u30bd\u30eb\u3092\u907f\u3051\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
if (friendly) {
|
|||
|
|
this.state = "cursor_friend";
|
|||
|
|
this.target = { x: pointer.x, y: pointer.y, dead: false };
|
|||
|
|
this.goodMode = "smile";
|
|||
|
|
this.thought = "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const parent = this.parentToFollow();
|
|||
|
|
if (parent && !this.shouldAvoidTarget(parent)) {
|
|||
|
|
const d = dist(this, parent);
|
|||
|
|
if (d > 34 || (this.hunger < 78 && this.energy > 24)) {
|
|||
|
|
this.state = "follow_parent";
|
|||
|
|
this.target = parent;
|
|||
|
|
this.bubble("?", 4.0);
|
|||
|
|
this.thought = "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (friendNearby && this.hunger > 44 && friendNearby.target && ["sweet", "grass"].includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
|
|||
|
|
this.state = "seek_food";
|
|||
|
|
this.target = friendNearby.target;
|
|||
|
|
this.foodReactTimer = Math.max(this.foodReactTimer, 0.24);
|
|||
|
|
this.thought = "\u4ef2\u826f\u3057\u3068\u4e00\u7dd2\u306b\u98df\u3079\u306b\u884c\u304f";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.hunger > 62) {
|
|||
|
|
const itemTypes = ["sweet", "grass"];
|
|||
|
|
const item = this.world.nearest(this, itemTypes, 420);
|
|||
|
|
if (item) {
|
|||
|
|
this.state = "seek_food";
|
|||
|
|
this.target = item;
|
|||
|
|
this.foodReactTimer = Math.max(this.foodReactTimer, 0.30);
|
|||
|
|
this.thought = "\u98df\u3079\u7269\u3092\u63a2\u3057\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.loneliness > 63 - (this.personality === "lonely" ? 16 : 0)) {
|
|||
|
|
const other = this.world.nearestOther(this, 300);
|
|||
|
|
if (other) {
|
|||
|
|
this.state = "seek_friend";
|
|||
|
|
this.target = other;
|
|||
|
|
this.thought = "\u4ef2\u9593\u3092\u63a2\u3057\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.stress > 74 / this.personalityProfile().fear || this.fearTimer > 0.22) {
|
|||
|
|
this.state = "panic";
|
|||
|
|
this.thought = "\u30d1\u30cb\u30c3\u30af\u306b\u306a\u3063\u3066\u3044\u308b";
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (Math.random() < dt * 0.018) this.wanderAngle += rand(-1.7, 1.7);
|
|||
|
|
this.state = "idle";
|
|||
|
|
this.thought = `${this.need}\u304c\u305f\u308a\u306a\u3044`;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
makePoop(force = 1) {
|
|||
|
|
this.digest += force;
|
|||
|
|
if (this.digest < 12.4) return;
|
|||
|
|
this.digest = rand(0.4, 1.6);
|
|||
|
|
this.poopCount += 1;
|
|||
|
|
const side = this.facingDir();
|
|||
|
|
const backX = this.x - side * this.radius * rand(0.72, 0.96);
|
|||
|
|
const backY = this.y + this.radius * rand(0.25, 0.48);
|
|||
|
|
this.world.spawnZunchi(backX, backY);
|
|||
|
|
this.bubble("\u3076\u308a\u3085\u3063", 3.2, "rgba(62,84,45,0.78)");
|
|||
|
|
if (Math.random() < 0.35) this.world.log(`${this.name}\u304c\u305a\u3093\u3061\u3092\u843d\u3068\u3057\u305f\u3002`);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interactWithItems(dt) {
|
|||
|
|
const canBite = this.eatCooldown <= 0 && this.hunger > 18;
|
|||
|
|
const canSweetBite = this.eatCooldown <= 0 && this.hunger > 7;
|
|||
|
|
for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) {
|
|||
|
|
const d = dist(this, it);
|
|||
|
|
if (d > this.radius + it.r + 12) continue;
|
|||
|
|
|
|||
|
|
if (((it.type === "sweet" && canSweetBite) || (it.type === "food" && canBite)) && it.amount > 1.2) {
|
|||
|
|
const eating = Math.min(it.amount, it.type === "sweet" ? 7.2 : 8.5);
|
|||
|
|
if (eating > 0) {
|
|||
|
|
this.state = "eat";
|
|||
|
|
this.target = it;
|
|||
|
|
this.vx *= Math.pow(0.25, dt * 60);
|
|||
|
|
this.vy *= Math.pow(0.25, dt * 60);
|
|||
|
|
it.amount -= eating;
|
|||
|
|
this.hunger -= eating * (it.type === "sweet" ? 0.75 : 0.95);
|
|||
|
|
this.recoverHealth(eating * (it.type === "sweet" ? 0.28 : 0.46));
|
|||
|
|
this.affection += it.type === "sweet" ? eating * 0.03 : eating * 0.012;
|
|||
|
|
if (it.type === "sweet") {
|
|||
|
|
this.stress = clamp(this.stress - eating * 0.42, 0, 130);
|
|||
|
|
this.fearTimer = Math.max(0, this.fearTimer - eating * 0.035);
|
|||
|
|
}
|
|||
|
|
this.eatTimer = Math.max(this.eatTimer, 0.32);
|
|||
|
|
this.eatCooldown = it.type === "sweet" ? rand(0.34, 0.62) : rand(0.44, 0.78);
|
|||
|
|
this.bubble("\u3082\u3050", 2.0, "rgba(88,70,42,0.78)");
|
|||
|
|
this.foodReactTimer = Math.max(this.foodReactTimer, 0.16);
|
|||
|
|
if (Math.random() < dt * 2.2) {
|
|||
|
|
const mouth = this.mouthPosition(it);
|
|||
|
|
this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "sweet" ? "#78b957" : "#f1dfb7");
|
|||
|
|
}
|
|||
|
|
this.lastMealColor = it.type === "sweet" ? "#78b957" : "#f1dfb7";
|
|||
|
|
this.makePoop(eating * (it.type === "sweet" ? 0.18 : 0.22));
|
|||
|
|
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; }
|
|||
|
|
if (this.world.time - this.lastLog > 9 && Math.random() < 0.014) {
|
|||
|
|
this.world.log(`${this.name}\u306f${it.type === "sweet" ? "\u305a\u3093\u3060\u9905" : "\u98df\u3079\u7269"}\u3092\u3057\u3070\u3089\u304f\u5473\u308f\u3063\u305f\u3002`);
|
|||
|
|
this.lastLog = this.world.time;
|
|||
|
|
}
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (canBite && it.type === "grass") {
|
|||
|
|
const bite = Math.min(it.amount, rand(4.2, 7.2));
|
|||
|
|
this.state = "eat";
|
|||
|
|
this.target = it;
|
|||
|
|
this.grassEatTimer = Math.max(this.grassEatTimer, 0.22);
|
|||
|
|
this.vx *= Math.pow(0.20, dt * 60);
|
|||
|
|
this.vy *= Math.pow(0.20, dt * 60);
|
|||
|
|
it.amount -= bite;
|
|||
|
|
it.eatenAmount = (it.eatenAmount || 0) + bite;
|
|||
|
|
it.growth = clamp((it.growth ?? it.amount / 120) - bite / 150, 0.04, 1.2);
|
|||
|
|
this.hunger -= bite * 0.46;
|
|||
|
|
this.recoverHealth(bite * 0.18);
|
|||
|
|
this.stress -= dt * 0.5;
|
|||
|
|
this.eatTimer = Math.max(this.eatTimer, 0.22);
|
|||
|
|
this.eatCooldown = rand(0.68, 1.15);
|
|||
|
|
this.bubble("\u3082\u3050", 2.4, "rgba(72,96,48,0.78)");
|
|||
|
|
if (Math.random() < dt * 2.0) {
|
|||
|
|
const mouth = this.mouthPosition(it);
|
|||
|
|
this.world.spawnEatEffect(mouth.x, mouth.y, "#5b9d39");
|
|||
|
|
}
|
|||
|
|
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.55; }
|
|||
|
|
this.makePoop(bite * 0.10);
|
|||
|
|
if (this.type === "jito") this.affection += dt * 0.25;
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (it.type === "zunchi") {
|
|||
|
|
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1);
|
|||
|
|
this.stress = clamp(this.stress + touch * dt * 3.4, 0, 130);
|
|||
|
|
this.zunchiStain = 0;
|
|||
|
|
if (canBite && this.hunger > 104) {
|
|||
|
|
const bite = Math.min(it.amount, rand(3.4, 5.8));
|
|||
|
|
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.18;
|
|||
|
|
this.stress = clamp(this.stress + bite * 0.52, 0, 130);
|
|||
|
|
this.zunchiStain = 0;
|
|||
|
|
this.eatTimer = Math.max(this.eatTimer, 0.24);
|
|||
|
|
this.eatCooldown = rand(0.9, 1.4);
|
|||
|
|
this.bubble("...", 2.2, "rgba(74,91,50,0.78)");
|
|||
|
|
const mouth = this.mouthPosition(it);
|
|||
|
|
this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f");
|
|||
|
|
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; }
|
|||
|
|
break;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (it.type === "water") {
|
|||
|
|
this.stress -= dt * 2.4;
|
|||
|
|
this.energy += dt * 0.5;
|
|||
|
|
this.hunger -= dt * 0.2;
|
|||
|
|
it.amount -= dt * 2.4;
|
|||
|
|
if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (it.type === "stone") {
|
|||
|
|
this.stress -= dt * 0.85;
|
|||
|
|
this.energy += dt * 0.18;
|
|||
|
|
if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (it.type === "bed") {
|
|||
|
|
if (this.energy < 74 + 6 * this.personalityProfile().sleep) {
|
|||
|
|
const occ = this.world.bedOccupancy(it);
|
|||
|
|
const comfort = this.world.bedComfort(it);
|
|||
|
|
this.state = "sleep";
|
|||
|
|
this.sleeping = true;
|
|||
|
|
if (this.world.time > this.nextSleepBubbleAt) {
|
|||
|
|
this.nextSleepBubbleAt = this.world.time + 4.5;
|
|||
|
|
this.world.spawnBubble(this.x, this.y - this.radius * 1.18, occ > 5 ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)");
|
|||
|
|
}
|
|||
|
|
this.energy += dt * (1.9 + comfort * 1.4);
|
|||
|
|
this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort);
|
|||
|
|
it.wear = clamp((it.wear || 0) + dt * 0.0008, 0, 1);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (it.type === "trace") {
|
|||
|
|
this.loneliness -= dt * 0.25;
|
|||
|
|
this.stress += dt * 0.06;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
this.hunger = clamp(this.hunger, 0, 115);
|
|||
|
|
this.energy = clamp(this.energy, 0, 100);
|
|||
|
|
this.stress = clamp(this.stress, 0, 130);
|
|||
|
|
this.loneliness = clamp(this.loneliness, 0, 110);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
interactWithOthers(dt) {
|
|||
|
|
let closeCount = 0;
|
|||
|
|
for (const o of this.world.nearbyTarinai(this.x, this.y, 78)) {
|
|||
|
|
if (o === this || o.dead) continue;
|
|||
|
|
const dx = o.x - this.x, dy = o.y - this.y;
|
|||
|
|
const d = Math.hypot(dx, dy);
|
|||
|
|
if (d < 0.01 || d > 74) continue;
|
|||
|
|
closeCount += 1;
|
|||
|
|
|
|||
|
|
const overlap = this.radius + o.radius - d;
|
|||
|
|
if (overlap > -2) {
|
|||
|
|
const push = (overlap + 6) * 0.17;
|
|||
|
|
this.vx -= (dx / d) * push;
|
|||
|
|
this.vy -= (dy / d) * push;
|
|||
|
|
if (Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (d < 60) {
|
|||
|
|
const rel = this.relationTo(o.id);
|
|||
|
|
const pdef = this.personalityProfile();
|
|||
|
|
this.loneliness -= dt * 1.65 * this.trait.social;
|
|||
|
|
this.affection += dt * 0.28 * this.trait.social;
|
|||
|
|
this.adjustRelation(o, dt * 0.18 * pdef.relation * (rel.fear > 10 ? 0.35 : 1), -dt * 0.018, "nearby");
|
|||
|
|
if ((rel.affinity || 0) > 10) {
|
|||
|
|
this.stress = clamp(this.stress - dt * 0.62 * this.trait.social, 0, 130);
|
|||
|
|
this.affection = clamp(this.affection + dt * 0.18, 0, 80);
|
|||
|
|
if (this.world.relationNotice(this.id, o.id, "friend-calm", 55)) this.world.log(`${this.name}\u306f${o.name}\u3068\u3044\u308b\u3068\u5c11\u3057\u843d\u3061\u7740\u304f\u3002`, "relation");
|
|||
|
|
}
|
|||
|
|
if ((rel.affinity || 0) > 8 && this.world.relationNotice(this.id, o.id, "friend", 32)) {
|
|||
|
|
this.world.log(`${this.name}\u306f\u3088\u304f${o.name}\u306e\u8fd1\u304f\u306b\u3044\u308b\u3002`, "relation");
|
|||
|
|
}
|
|||
|
|
if (this.type === "angry") this.stress += dt * 0.08;
|
|||
|
|
if (this.type === "teary" && o.type === "teary") this.stress -= dt * 0.12;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const fightingThis = this.fightTimer > 0 && (this.fightTargetId === o.id || (this.fightTargetIds || []).includes(o.id));
|
|||
|
|
if (fightingThis && d < 36 && this.id < o.id) {
|
|||
|
|
const pulse = Math.sin(this.world.time * 18 + this.age * 0.37) > 0.35;
|
|||
|
|
if (pulse && this.world.time > this.nextHeadbutt) {
|
|||
|
|
const nx = dx / d, ny = dy / d;
|
|||
|
|
this.vx -= nx * rand(24, 44);
|
|||
|
|
this.vy -= ny * rand(24, 44);
|
|||
|
|
o.vx += nx * rand(34, 64);
|
|||
|
|
o.vy += ny * rand(34, 64);
|
|||
|
|
this.stress = clamp(this.stress + 1.6, 0, 130);
|
|||
|
|
o.stress = clamp(o.stress + 2.8, 0, 130);
|
|||
|
|
this.damage(1.2);
|
|||
|
|
o.damage(1.6);
|
|||
|
|
this.hurtTimer = Math.max(this.hurtTimer, 1.4);
|
|||
|
|
o.hurtTimer = Math.max(o.hurtTimer, 1.8);
|
|||
|
|
this.adjustRelation(o, -0.16, 0.12, "fight");
|
|||
|
|
o.adjustRelation(this, -0.22, 0.28 * o.personalityProfile().fear, "fight");
|
|||
|
|
if (Math.random() < 0.22 || o.energy < 24) {
|
|||
|
|
o.fallTimer = Math.max(o.fallTimer, 0.58);
|
|||
|
|
o.fallMax = Math.max(o.fallMax || 0.58, o.fallTimer);
|
|||
|
|
o.fallDir = nx > 0 ? 1 : -1;
|
|||
|
|
this.world.spawnFallEffect(o.x, o.y + o.radius * 0.45, 0.55);
|
|||
|
|
}
|
|||
|
|
if (o.energy + o.mood * 0.25 < this.energy + this.mood * 0.25 - 8) {
|
|||
|
|
o.defeatedById = this.id;
|
|||
|
|
o.fightWinnerId = this.id;
|
|||
|
|
}
|
|||
|
|
this.world.spawnHeadbuttEffect((this.x + o.x) / 2, (this.y + o.y) / 2 - 5);
|
|||
|
|
this.nextHeadbutt = this.world.time + rand(0.34, 0.55);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const relA = this.relationTo(o.id);
|
|||
|
|
const relB = o.relationTo(this.id);
|
|||
|
|
const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || this.personality === "irritable" || o.personality === "irritable");
|
|||
|
|
const fearBrake = clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1);
|
|||
|
|
const friendBrake = clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1);
|
|||
|
|
const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight;
|
|||
|
|
const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
|
|||
|
|
if (d < 56 && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake) {
|
|||
|
|
this.world.startConflict(this, o);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (d < 36 && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) {
|
|||
|
|
const enough = this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44;
|
|||
|
|
const lonelyBond = this.loneliness > 24 || o.loneliness > 24;
|
|||
|
|
if (enough && lonelyBond && Math.random() < dt * 0.25) {
|
|||
|
|
this.world.startBirthRitual(this, o);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (closeCount > 8) {
|
|||
|
|
this.stress += dt * 0.9;
|
|||
|
|
this.energy -= dt * 0.25;
|
|||
|
|
this.fearTimer = Math.max(this.fearTimer, 0.18);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.loneliness < 12 && Math.random() < dt * 0.002) {
|
|||
|
|
this.world.log(`${this.name}\u306f\u8ab0\u304b\u306e\u8fd1\u304f\u3067\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u5c11\u3057\u5fd8\u308c\u305f\u3002`);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
move(dt) {
|
|||
|
|
let ax = 0, ay = 0;
|
|||
|
|
const baseSpeed = 31 * this.trait.speed * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1);
|
|||
|
|
|
|||
|
|
if (this.state === "sleep") {
|
|||
|
|
const bedComfort = this.target?.type === "bed" ? this.world.bedComfort(this.target) : 0.72;
|
|||
|
|
const bedCrowd = this.target?.type === "bed" ? this.world.bedOccupancy(this.target) : 0;
|
|||
|
|
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100);
|
|||
|
|
this.stress = clamp(this.stress + dt * (bedCrowd > 5 ? 0.18 * (bedCrowd - 5) : -0.12 * bedComfort), 0, 130);
|
|||
|
|
this.hunger = clamp(this.hunger + dt * 0.018, 0, 115);
|
|||
|
|
if (this.target && !this.target.dead && this.target.type === "bed") {
|
|||
|
|
const spot = this.sleepSpotFor(this.target);
|
|||
|
|
const dx = spot.x - this.x;
|
|||
|
|
const dy = spot.y - this.y;
|
|||
|
|
const d = Math.hypot(dx, dy);
|
|||
|
|
if (d > 1.6) {
|
|||
|
|
const step = Math.min(d, dt * (12 + bedComfort * 14));
|
|||
|
|
this.x += dx / d * step;
|
|||
|
|
this.y += dy / d * step;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
this.vx *= Math.pow(0.55, dt * 60);
|
|||
|
|
this.vy *= Math.pow(0.55, dt * 60);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") {
|
|||
|
|
const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
|
|||
|
|
if (partner) {
|
|||
|
|
const midX = (this.x + partner.x) / 2;
|
|||
|
|
const midY = (this.y + partner.y) / 2;
|
|||
|
|
const offset = (this.birthRitualRole || 1) * this.radius * 0.44;
|
|||
|
|
const dx = midX - this.x;
|
|||
|
|
const dy = (midY + offset) - this.y;
|
|||
|
|
this.vx += dx * dt * 2.8;
|
|||
|
|
this.vy += dy * dt * 2.8;
|
|||
|
|
}
|
|||
|
|
this.vx *= Math.pow(0.56, dt * 60);
|
|||
|
|
this.vy *= Math.pow(0.56, dt * 60);
|
|||
|
|
this.x += Math.sin(this.world.time * 26 + this.age) * 0.32;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.intimidateTimer > 0.04 || this.state === "intimidate") {
|
|||
|
|
this.vx *= Math.pow(0.22, dt * 60);
|
|||
|
|
this.vy *= Math.pow(0.22, dt * 60);
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.eatTimer > 0.04 || this.state === "eat") {
|
|||
|
|
this.vx = 0;
|
|||
|
|
this.vy = 0;
|
|||
|
|
return;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.state === "panic") {
|
|||
|
|
const targetDist = this.target && !this.target.dead ? distXY(this.x, this.y, this.target.x, this.target.y) : Infinity;
|
|||
|
|
const speed = Math.hypot(this.vx, this.vy);
|
|||
|
|
this.panicStuckTimer = speed < 8 ? (this.panicStuckTimer || 0) + dt : 0;
|
|||
|
|
if (!this.target || this.target.dead || targetDist < 28 || this.panicStuckTimer > 0.75) {
|
|||
|
|
this.target = this.panicDestination(null, true);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.target && !this.target.dead) {
|
|||
|
|
const dx = this.target.x - this.x;
|
|||
|
|
const dy = this.target.y - this.y;
|
|||
|
|
const d = Math.hypot(dx, dy) || 1;
|
|||
|
|
let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1;
|
|||
|
|
if (this.target?.detour) sign = 1;
|
|||
|
|
let strength = 1;
|
|||
|
|
if (this.state === "follow_parent") {
|
|||
|
|
strength = d < 42 ? 0.18 : 1.22;
|
|||
|
|
sign = 1;
|
|||
|
|
} else if (this.state === "cursor_friend") {
|
|||
|
|
strength = d < 30 ? 0.12 : 0.95;
|
|||
|
|
sign = 1;
|
|||
|
|
} else if (this.state === "cursor_enemy") {
|
|||
|
|
strength = d < 150 ? 1.35 : 0.35;
|
|||
|
|
sign = -1;
|
|||
|
|
} else if (this.state === "fight") {
|
|||
|
|
const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37);
|
|||
|
|
if (d < 18) {
|
|||
|
|
strength = 1.05;
|
|||
|
|
sign = -1;
|
|||
|
|
} else {
|
|||
|
|
strength = headbutt > -0.15 ? 2.65 : 0.65;
|
|||
|
|
sign = headbutt > -0.15 ? 1 : -1;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
ax += (dx / d) * baseSpeed * sign * strength;
|
|||
|
|
ay += (dy / d) * baseSpeed * sign * strength;
|
|||
|
|
} else if (this.state === "panic") {
|
|||
|
|
this.wanderAngle += rand(-3.8, 3.8) * dt;
|
|||
|
|
ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5;
|
|||
|
|
ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5;
|
|||
|
|
} else {
|
|||
|
|
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34;
|
|||
|
|
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.vx += ax * dt;
|
|||
|
|
this.vy += ay * dt;
|
|||
|
|
this.vx *= Math.pow(0.84, dt * 8);
|
|||
|
|
this.vy *= Math.pow(0.84, dt * 8);
|
|||
|
|
|
|||
|
|
const maxV = this.state === "panic" ? 116 : 76;
|
|||
|
|
const v = Math.hypot(this.vx, this.vy);
|
|||
|
|
if (v > maxV) {
|
|||
|
|
this.vx = this.vx / v * maxV;
|
|||
|
|
this.vy = this.vy / v * maxV;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
this.x += this.vx * dt;
|
|||
|
|
this.y += this.vy * dt;
|
|||
|
|
|
|||
|
|
const p = CONFIG.worldPadding;
|
|||
|
|
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; }
|
|||
|
|
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; }
|
|||
|
|
if (this.x > this.world.w - p) { this.x = this.world.w - p; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; }
|
|||
|
|
if (this.y > this.world.h - p) { this.y = this.world.h - p; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; }
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
checkLife() {
|
|||
|
|
let reason = "";
|
|||
|
|
if (this.hunger >= 108) reason = "\u98df\u3079\u7269\u304c\u305f\u308a\u306a\u3044";
|
|||
|
|
else if (this.age >= this.lifeSpan) reason = "\u5bff\u547d";
|
|||
|
|
else if (this.stress >= 124) reason = "\u30b9\u30c8\u30ec\u30b9\u304c\u591a\u3059\u304e\u308b";
|
|||
|
|
else if (this.energy <= 0 && this.hunger > 90) reason = "\u75b2\u308c\u3068\u7a7a\u8179\u304c\u91cd\u306a\u3063\u305f";
|
|||
|
|
if (reason) this.die(reason);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
die(reason) {
|
|||
|
|
if (this.dead) return;
|
|||
|
|
this.dead = true;
|
|||
|
|
this.deathReason = reason;
|
|||
|
|
this.world.deadCount += 1;
|
|||
|
|
this.world.items.push(new Item("trace", this.x, this.y));
|
|||
|
|
this.world.items.push(new Item("splat", this.x, this.y));
|
|||
|
|
if (Math.random() < 0.55) {
|
|||
|
|
const spot = this.world.findGrassPlantingSpot(this.x, this.y, { allowOriginal: false, minRadius: 26, maxRadius: 76 });
|
|||
|
|
if (spot) this.world.items.push(new Item("grass", spot.x, spot.y));
|
|||
|
|
}
|
|||
|
|
this.world.markDead(this, reason);
|
|||
|
|
audio.death();
|
|||
|
|
this.world.log(`${this.name}\u306f\u9759\u304b\u306a\u75d5\u8de1\u3092\u6b8b\u3057\u305f\u3002\u7406\u7531: ${reason}`, "death");
|
|||
|
|
if (this.world.selected === this) this.world.selected = null;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
draw(ctx, t, lighting = null) {
|
|||
|
|
if (this.dead) return;
|
|||
|
|
const lightState = lighting || getLightingState(this.world);
|
|||
|
|
const sprite = this.spriteId();
|
|||
|
|
const img = images.get(sprite) || images.get(this.type);
|
|||
|
|
const metrics = getImageMetrics(sprite) || getImageMetrics(this.type);
|
|||
|
|
const w = metrics?.w || 512;
|
|||
|
|
const h = metrics?.h || 512;
|
|||
|
|
const moveSpeed = Math.hypot(this.vx, this.vy);
|
|||
|
|
const gait = this.state === "sleep" ? 0.8 : clamp(moveSpeed / 42, 0.2, 1.8);
|
|||
|
|
const breathing = Math.sin(t * (this.state === "sleep" ? 1.1 : 2.3) + this.age * 0.18);
|
|||
|
|
const headbuttPulse = this.state === "fight" ? Math.max(0, Math.sin(t * 18 + this.age * 0.37)) : 0;
|
|||
|
|
const eatingShake = (this.eatTimer > 0.02 || this.grassEatTimer > 0) ? Math.sin(t * 54 + this.age) * clamp(Math.max(this.eatTimer / 0.32, this.grassEatTimer / 0.22), 0, 1) : 0;
|
|||
|
|
const fallPose = this.fallTimer > 0 ? clamp(this.fallTimer / Math.max(this.fallMax || 1, 0.01), 0, 1) : 0;
|
|||
|
|
const fallLift = Math.sin(fallPose * Math.PI) * this.radius * 0.20;
|
|||
|
|
const intimidateShake = this.intimidateTimer > 0.04 ? Math.sin(t * 44 + this.age) * this.radius * 0.09 : 0;
|
|||
|
|
const fearShakeY = this.intimidatedTimer > 0.04 ? Math.sin(t * 52 + this.age * 0.7) * this.radius * 0.11 : 0;
|
|||
|
|
const birthShake = this.birthRitualTimer > 0.04 ? Math.sin(t * 34 + this.age) * this.radius * 0.07 : 0;
|
|||
|
|
const bob = breathing * (this.state === "sleep" ? 0.9 : 1.2 + gait * 1.6) + headbuttPulse * 0.9 + fallLift + birthShake * 0.25;
|
|||
|
|
const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0;
|
|||
|
|
const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0);
|
|||
|
|
const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin;
|
|||
|
|
let drawW = w * this.scale * 0.30;
|
|||
|
|
let drawH = h * this.scale * 0.30;
|
|||
|
|
if (sprite === "stretch") {
|
|||
|
|
drawW *= 1.16;
|
|||
|
|
drawH *= 1.24;
|
|||
|
|
}
|
|||
|
|
if (sprite === "intimidate") {
|
|||
|
|
drawW *= 1.32;
|
|||
|
|
drawH *= 1.32;
|
|||
|
|
}
|
|||
|
|
if (sprite === "birth_ritual") {
|
|||
|
|
drawW *= 0.64;
|
|||
|
|
drawH *= 0.64;
|
|||
|
|
}
|
|||
|
|
if (fallPose > 0) {
|
|||
|
|
drawW *= 1 + fallPose * 0.08;
|
|||
|
|
drawH *= 1 - fallPose * 0.07;
|
|||
|
|
}
|
|||
|
|
const squish = this.state === "sleep"
|
|||
|
|
? 1 + breathing * 0.018
|
|||
|
|
: 1 + Math.sin(t * (4.5 + gait * 1.6) + this.age) * (0.012 + gait * 0.018) + headbuttPulse * 0.03;
|
|||
|
|
const faceRight = this.state === "sleep"
|
|||
|
|
? this.sleepFacing > 0
|
|||
|
|
: this.facingDir() > 0;
|
|||
|
|
const angle = (faceRight ? 1 : -1) * (Math.PI / 12) + motionTilt;
|
|||
|
|
|
|||
|
|
const shadow = projectedShadowParams(lightState, 0.95 + this.scale * 0.8);
|
|||
|
|
const shadowContactY = this.y + bob + (drawH / squish) * 0.30;
|
|||
|
|
drawImageProjectedShadow(ctx, img, this.x, shadowContactY, drawW * squish, drawH / squish, shadow, {
|
|||
|
|
faceRight,
|
|||
|
|
alpha: shadow.alpha * (this.state === "sleep" ? 1.10 : 0.92),
|
|||
|
|
widthScale: this.state === "sleep" ? 1.12 : 1,
|
|||
|
|
heightScale: this.state === "sleep" ? 0.88 : 1,
|
|||
|
|
});
|
|||
|
|
drawCreatureGlow(ctx, this, drawW, drawH, lightState);
|
|||
|
|
if (this.birthRitualTimer > 0.04 && this.birthRitualLeader) {
|
|||
|
|
const partner = this.world.tarinai.find(o => o.id === this.birthPartnerId && !o.dead);
|
|||
|
|
const hx = partner ? (this.x + partner.x) / 2 : this.x;
|
|||
|
|
const hy = partner ? Math.min(this.y, partner.y) - Math.max(drawH, partner.radius * 2.6) * 0.50 : this.y - drawH * 0.60;
|
|||
|
|
const pulse = 1 + Math.sin(t * 5.5 + this.age) * 0.10;
|
|||
|
|
const size = Math.max(10, this.radius * 0.62) * pulse;
|
|||
|
|
drawHeartShape(ctx, hx, hy, size, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)" });
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY);
|
|||
|
|
ctx.rotate(angle);
|
|||
|
|
|
|||
|
|
if (this.world.selected === this) {
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.globalAlpha = 0.85;
|
|||
|
|
ctx.strokeStyle = "rgba(165, 220, 98, 0.92)";
|
|||
|
|
ctx.lineWidth = 2;
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.ellipse(0, drawH * 0.14, drawW * 0.62, drawH * 0.48, 0, 0, Math.PI * 2);
|
|||
|
|
ctx.stroke();
|
|||
|
|
ctx.setLineDash([5, 4]);
|
|||
|
|
ctx.lineWidth = 1.2;
|
|||
|
|
ctx.strokeStyle = "rgba(255,255,255,0.86)";
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.ellipse(0, drawH * 0.14, drawW * 0.72, drawH * 0.56, 0, 0, Math.PI * 2);
|
|||
|
|
ctx.stroke();
|
|||
|
|
ctx.setLineDash([]);
|
|||
|
|
ctx.restore();
|
|||
|
|
} else if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && this.world.pointer?.inside && distXY(this.x, this.y, this.world.pointer.x, this.world.pointer.y) < this.radius * 2.0) {
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.globalAlpha = 0.28;
|
|||
|
|
ctx.strokeStyle = this.state === "cursor_enemy" ? "rgba(180, 92, 92, 0.70)" : "rgba(255, 250, 220, 0.78)";
|
|||
|
|
ctx.lineWidth = 1.4;
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.ellipse(0, drawH * 0.14, drawW * 0.58, drawH * 0.43, 0, 0, Math.PI * 2);
|
|||
|
|
ctx.stroke();
|
|||
|
|
ctx.restore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.eatTimer > 0.02) {
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.globalAlpha = 0.46 * (this.eatTimer / 0.32);
|
|||
|
|
ctx.strokeStyle = this.lastMealColor || "#f7cf67";
|
|||
|
|
ctx.lineWidth = 1.6;
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.arc(0, -drawH * 0.02, drawW * 0.22 + (1 - this.eatTimer / 0.32) * 5, 0, Math.PI * 2);
|
|||
|
|
ctx.stroke();
|
|||
|
|
ctx.restore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (this.pokeFlashTimer > 0.03) {
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.globalAlpha = this.pokeFlashTimer * 0.9;
|
|||
|
|
ctx.fillStyle = "rgba(255, 120, 120, 0.22)";
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.ellipse(0, 0, drawW * 0.60, drawH * 0.42, 0, 0, Math.PI * 2);
|
|||
|
|
ctx.fill();
|
|||
|
|
ctx.restore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.globalAlpha = this.state === "cursor_friend" ? 0.82 : 0.34;
|
|||
|
|
ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9"));
|
|||
|
|
for (let i = 0; i < 2; i++) {
|
|||
|
|
const px = Math.cos(t * 2.2 + this.age + i * 2.4) * drawW * 0.34;
|
|||
|
|
const py = -drawH * 0.24 + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * 0.11;
|
|||
|
|
if (this.state === "cursor_friend") {
|
|||
|
|
const s = Math.max(5, drawW * 0.075);
|
|||
|
|
drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 });
|
|||
|
|
} else {
|
|||
|
|
ctx.beginPath();
|
|||
|
|
ctx.arc(px, py, drawW * 0.045, 0, Math.PI * 2);
|
|||
|
|
ctx.fill();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
ctx.restore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
if (faceRight) ctx.scale(-1, 1);
|
|||
|
|
const dw = drawW * squish;
|
|||
|
|
const dh = drawH / squish;
|
|||
|
|
const brightness = clamp(0.82 + lightState.light * 0.30 + lightState.dawnStrength * 0.08 + lightState.goldenStrength * 0.06 + lightState.noonStrength * 0.04, 0.80, 1.17);
|
|||
|
|
const saturation = clamp(0.86 + lightState.light * 0.22 + lightState.goldenStrength * 0.10 - lightState.nightStrength * 0.04, 0.82, 1.14);
|
|||
|
|
const renderScale = typeof SPRITE_CACHE_SCALE === "number" ? SPRITE_CACHE_SCALE : 2.0;
|
|||
|
|
const cacheW = Math.max(1, Math.round((dw * renderScale) / 8) * 8);
|
|||
|
|
const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8);
|
|||
|
|
const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img;
|
|||
|
|
ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh);
|
|||
|
|
this.zunchiStain = 0;
|
|||
|
|
drawDawnRimLight(ctx, dw, dh, lightState);
|
|||
|
|
if (faceRight) ctx.scale(-1, 1);
|
|||
|
|
|
|||
|
|
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 3 && (this.hunger > 90 || this.loneliness > 88 || this.energy < 16)) {
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.globalAlpha = 0.80;
|
|||
|
|
ctx.font = `${Math.max(10, drawW * 0.14)}px ui-rounded, sans-serif`;
|
|||
|
|
ctx.textAlign = "center";
|
|||
|
|
ctx.fillStyle = "rgba(42,36,29,0.72)";
|
|||
|
|
const mark = this.hunger > 90 ? "..." : (this.energy < 16 ? "Z" : "?");
|
|||
|
|
ctx.fillText(mark, 0, -drawH * 0.72);
|
|||
|
|
ctx.restore();
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
ctx.restore();
|
|||
|
|
|
|||
|
|
if ((this.hpBarTimer || 0) > 0) {
|
|||
|
|
const bw = Math.max(28, this.radius * 2.35);
|
|||
|
|
const bh = 5;
|
|||
|
|
const x = this.x - bw / 2;
|
|||
|
|
const y = this.y - drawH * 0.55 - 16;
|
|||
|
|
const pct = clamp(this.energy / 100, 0, 1);
|
|||
|
|
ctx.save();
|
|||
|
|
ctx.globalAlpha = clamp((this.hpBarTimer || 0) / 0.7, 0.35, 1);
|
|||
|
|
ctx.fillStyle = "rgba(255,252,242,0.86)";
|
|||
|
|
roundedRect(ctx, x - 2, y - 2, bw + 4, bh + 4, 5);
|
|||
|
|
ctx.fill();
|
|||
|
|
ctx.fillStyle = "rgba(72,58,45,0.22)";
|
|||
|
|
roundedRect(ctx, x, y, bw, bh, 4);
|
|||
|
|
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, x, y, bw * pct, bh, 4);
|
|||
|
|
ctx.fill();
|
|||
|
|
ctx.restore();
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
contains(x, y) {
|
|||
|
|
const hit = this.world.screenSizeToWorld ? this.world.screenSizeToWorld(this.radius * 1.9) : this.radius * 1.9;
|
|||
|
|
return distXY(this.x, this.y, x, y) < hit;
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|