tarinai/js/world_view.js
2026-06-22 02:03:19 +09:00

316 lines
11 KiB
JavaScript

"use strict";
(function (global) {
const World = global.World;
if (!World) throw new Error("World is not available for mixin: world_view.js");
Object.defineProperties(World.prototype, Object.getOwnPropertyDescriptors({
dayProgress() {
return (this.time % CONFIG.dayLength) / CONFIG.dayLength;
},
lightLevel() {
return (Math.sin(this.dayProgress() * Math.PI * 2 - Math.PI / 2) + 1) / 2;
},
triggerGroundShake(duration = 0.4, strength = 6) {
this.shakeTimer = Math.max(this.shakeTimer || 0, duration);
this.shakeDuration = Math.max(this.shakeDuration || 0, duration);
this.shakeStrength = Math.max(this.shakeStrength || 0, strength);
},
currentShakeOffset() {
const timeLeft = Math.max(0, this.shakeTimer || 0);
if (timeLeft <= 0) return { x: 0, y: 0 };
const duration = Math.max(0.001, this.shakeDuration || timeLeft);
const strength = Math.max(0, this.shakeStrength || 0);
const fade = clamp(timeLeft / duration, 0.18, 1);
const t = this.time || 0;
return {
x: Math.sin(t * 48) * strength * 0.34 * fade + Math.sin(t * 91) * strength * 0.12 * fade,
y: Math.cos(t * 60) * strength * 0.18 * fade,
};
},
emit(type, detail = {}) {
return this.events?.emit?.(type, { ...detail, world: this }) || null;
},
on(type, handler) {
return this.events?.on?.(type, handler) || (() => {});
},
markSpatialDirty(reason = "manual") {
this.spatialDirty = true;
this.spatialDirtyReason = reason;
},
markTerrainDirty(reason = "manual") {
this.terrainDirty = true;
this.terrainDirtyReason = reason;
this.terrainVersion = (this.terrainVersion || 0) + 1;
this.terrainLastDirtyAt = this.time || 0;
this.emit("terrain:dirty", { reason, terrainVersion: this.terrainVersion });
},
terrainSignature() {
const counts = this.itemCounts || {};
return `${this.fieldType || "garden"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${this.weather || "sunny"}`;
},
updateTerrainDirtyState(dt = 0) {
const signature = this.terrainSignature();
if (signature !== this.lastTerrainSignature) {
this.lastTerrainSignature = signature;
this.markTerrainDirty("terrain-signature");
return;
}
const q = window.TarinaiPerformance?.quality?.(this) || {};
const interval = q.terrainRedrawInterval || (this.performanceLevel() >= 2 ? 1.05 : 0.62);
if ((this.time || 0) - (this.terrainLastDirtyAt || 0) >= interval) this.markTerrainDirty("terrain-periodic");
},
phaseName() {
const hour = this.dayProgress() * 24;
if (hour >= 5 && hour < 10) return "\u671d";
if (hour >= 10 && hour < 17) return "\u663c";
if (hour >= 17 && hour < 20) return "\u5915\u65b9";
return "\u591c";
},
clockString() {
const totalMinutes = Math.floor(this.dayProgress() * 24 * 60);
const hh = String(Math.floor(totalMinutes / 60)).padStart(2, "0");
const mm = String(totalMinutes % 60).padStart(2, "0");
return `${hh}:${mm}`;
},
fieldDefinition() {
return FIELD_TYPES?.[this.fieldType] || FIELD_TYPES.garden;
},
fieldWorldScale() {
const field = this.fieldDefinition();
// Field type defines the logical simulation area. Wheel zoom must not
// rewrite this size; it is a camera/display transform only.
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;
// \u4e2d\u30b5\u30a4\u30ba\u3092 CONFIG.grassLimit \u306e\u57fa\u6e96\u306b\u3057\u3001\u30d5\u30a3\u30fc\u30eb\u30c9\u306e\u8ad6\u7406\u30b5\u30a4\u30ba\u306b\u5fdc\u3058\u3066\u5897\u6e1b\u3059\u308b\u3002
return Math.max(12, Math.round(base * ratio));
},
fenceLimit() {
const base = CONFIG.fenceLimit ?? 72;
const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1);
const ratio = Math.max(0.24, this.fieldWorldScale() / mediumScale);
return clamp(Math.round(base * Math.sqrt(ratio)), 28, 140);
},
isFenceType(type = "") {
return type === "fence_v" || type === "fence_h";
},
liveFenceCount() {
let count = 0;
for (const it of this.items || []) if (!it.dead && this.isFenceType(it.type)) count += 1;
return count;
},
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 this.normalizedFieldZoom(this.fieldZoom);
},
visibleWorldW() {
return (this.viewportW || this.w) / this.viewScale();
},
visibleWorldH() {
return (this.viewportH || this.h) / this.viewScale();
},
screenSizeToWorld(size) {
// Kept as a compatibility shim for older hit-test call sites. The values
// currently passed here are already logical world units, not CSS pixels.
return size;
},
fieldScreenOffset() {
const scale = this.viewScale();
return {
x: Math.max(0, ((this.viewportW || this.w) - this.w * scale) / 2),
y: Math.max(0, ((this.viewportH || this.h) - this.h * scale) / 2),
};
},
maxCameraX() {
return Math.max(0, this.w - this.visibleWorldW());
},
maxCameraY() {
return Math.max(0, this.h - this.visibleWorldH());
},
clampCamera() {
this.cameraX = clamp(this.cameraX || 0, 0, this.maxCameraX());
this.cameraY = clamp(this.cameraY || 0, 0, this.maxCameraY());
},
panCamera(dx, dy) {
const scale = this.viewScale();
this.cameraX = (this.cameraX || 0) + dx / scale;
this.cameraY = (this.cameraY || 0) + dy / scale;
this.clampCamera();
},
screenToWorld(x, y) {
const scale = this.viewScale();
const off = this.fieldScreenOffset();
const rawX = (x - off.x) / scale + (this.cameraX || 0);
const rawY = (y - off.y) / scale + (this.cameraY || 0);
return {
x: clamp(rawX, 0, this.w),
y: clamp(rawY, 0, this.h),
inside: rawX >= 0 && rawY >= 0 && rawX <= this.w && rawY <= this.h,
};
},
worldToScreen(x, y) {
const scale = this.viewScale();
const off = this.fieldScreenOffset();
return {
x: (x - (this.cameraX || 0)) * scale + off.x,
y: (y - (this.cameraY || 0)) * scale + off.y,
};
},
setViewportSize(width, height, opts = {}) {
const oldW = this.w || width;
const oldH = this.h || height;
this.viewportW = Math.max(1, width || this.viewportW || 1000);
this.viewportH = Math.max(1, height || this.viewportH || 720);
const requestedScale = this.fieldWorldScale();
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;
const sy = this.h / oldH;
const scalePoint = (p) => {
if (!p) return;
if (Number.isFinite(p.x)) p.x *= sx;
if (Number.isFinite(p.y)) p.y *= sy;
};
for (const t of this.tarinai || []) scalePoint(t);
for (const it of this.items || []) scalePoint(it);
for (const ef of this.effects || []) scalePoint(ef);
scalePoint(this.pointer);
this.cameraX *= sx;
this.cameraY *= sy;
this.clampCamera();
this.markTerrainDirty?.("viewport-scale");
this.rebuildSpatial(true);
this.drawListDirty = true;
}
},
setFieldZoom(nextZoom) {
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.markTerrainDirty?.("field-zoom");
this.drawListDirty = true;
return true;
},
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;
if (Number.isFinite(targetX) && Number.isFinite(targetY)) {
const distances = [targetY, w - targetX, h - targetY, targetX];
side = distances.indexOf(Math.min(...distances));
} else {
side = Math.floor(Math.random() * 4);
}
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 = "") {
return typeof normalizedToolSizeFor === "function" ? normalizedToolSizeFor(this, type) : ((this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium");
},
toolSizeScale(type = "") {
return typeof toolSizeScaleFor === "function" ? toolSizeScaleFor(this, type) : ({ small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1);
},
applyToolSize(item) {
if (!item) return item;
const scale = this.toolSizeScale(item.type);
const size = this.toolSizeFor(item.type);
item.toolSize = size;
this.toolSize = size;
item.r *= scale;
if (typeof isServingFoodType === "function" && isServingFoodType(item.type)) {
const servings = typeof foodServingsForSize === "function" ? foodServingsForSize(size) : 5;
item.foodServingScale = scale;
item.foodServingsMax = servings;
item.foodServingsRemaining = servings;
item.amount = servings;
} else if (["grass", "zunchi"].includes(item.type)) {
item.amount *= scale * scale;
item.foodServingScale = scale;
}
if (item.type === "firecracker") item.blastScale = scale;
return item;
}
}));
})(typeof window !== "undefined" ? window : globalThis);