421 lines
15 KiB
JavaScript
421 lines
15 KiB
JavaScript
"use strict";
|
|
|
|
(function (global) {
|
|
const World = global.World;
|
|
if (!World) throw new Error("World is not available for mixin: world_view.js");
|
|
const BASE_ZOOM_MIN = 0.42;
|
|
const BASE_ZOOM_MAX = 2.45;
|
|
|
|
function terrainSignature(worldRef) {
|
|
const counts = worldRef.itemCounts || {};
|
|
return `${worldRef.fieldType || "garden"}:${worldRef.groundType || "soil"}:${counts.grass || 0}:${counts.trace || 0}:${counts.splat || 0}:${counts.zunchi || 0}:${worldRef.weather || "sunny"}`;
|
|
}
|
|
|
|
function maxCameraX(worldRef) {
|
|
return Math.max(0, worldRef.w - worldRef.visibleWorldW());
|
|
}
|
|
|
|
function maxCameraY(worldRef) {
|
|
return Math.max(0, worldRef.h - worldRef.visibleWorldH());
|
|
}
|
|
|
|
function fieldWorldScale(worldRef) {
|
|
const field = worldRef.fieldDefinition();
|
|
return Math.max(0.22, field.worldScale || 1);
|
|
}
|
|
|
|
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 = {}) {
|
|
const events = this.events;
|
|
if (!events?.emit) return null;
|
|
if (typeof events.hasListeners === "function" && !events.hasListeners(type)) return null;
|
|
return events.emit(type, { ...detail, world: this }) || null;
|
|
},
|
|
|
|
markSpatialDirty(reason = "manual") {
|
|
this.spatialDirty = true;
|
|
this.spatialDirtyReason = reason;
|
|
if (!this.spatialDirtyReasonCountsThisFrame) this.spatialDirtyReasonCountsThisFrame = {};
|
|
this.spatialDirtyReasonCountsThisFrame[String(reason || "manual")] = (this.spatialDirtyReasonCountsThisFrame[String(reason || "manual")] || 0) + 1;
|
|
this.spatialDirtyMarksThisFrame = (this.spatialDirtyMarksThisFrame || 0) + 1;
|
|
this.spatialDirtyMarksTotal = (this.spatialDirtyMarksTotal || 0) + 1;
|
|
this.lastSpatialDirtyReason = reason;
|
|
const r = String(reason || "manual");
|
|
if (/tarinai-moved|post-tarinai|creature/i.test(r)) {
|
|
this.spatialTarinaiDirty = true;
|
|
} else if (/ants?-moved|post-ant|ant/i.test(r) && !/ant_nest|ant-nest/i.test(r)) {
|
|
this.spatialAntDirty = true;
|
|
} else if (/items?-moved|ball-ball|mobile|kinematic|pushpin|oshibyo|firecracker|genkotsu|zunchi|mechanical|rotator|reciprocator|poison-block|constraint|rod-/i.test(r)) {
|
|
this.spatialDynamicItemsDirty = true;
|
|
} else {
|
|
this.spatialStaticItemsDirty = true;
|
|
this.spatialDynamicItemsDirty = true;
|
|
this.spatialTarinaiDirty = true;
|
|
this.spatialAntDirty = true;
|
|
}
|
|
},
|
|
|
|
markTerrainDirty(reason = "manual") {
|
|
const reasonKey = String(reason || "manual");
|
|
if (!this.terrainDirtyStatsThisFrame) this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
|
|
this.terrainDirtyStatsThisFrame.raw += 1;
|
|
this.terrainDirtyStatsThisFrame.global += 1;
|
|
this.terrainDirtyStatsThisFrame.reasons[reasonKey] = (this.terrainDirtyStatsThisFrame.reasons[reasonKey] || 0) + 1;
|
|
if (this.terrainDirtyGlobal && this.terrainDirtyReason === reasonKey) {
|
|
this.terrainDirtyStatsThisFrame.coalesced += 1;
|
|
this.terrainDirty = true;
|
|
this.terrainLastDirtyAt = this.time || 0;
|
|
return;
|
|
}
|
|
this.terrainDirty = true;
|
|
this.terrainDirtyGlobal = true;
|
|
this.terrainDirtyReason = reasonKey;
|
|
this.terrainVersion = (this.terrainVersion || 0) + 1;
|
|
this.terrainLastDirtyAt = this.time || 0;
|
|
},
|
|
|
|
markTerrainDirtyAt(x, y, radius = 48, reason = "manual") {
|
|
const reasonKey = String(reason || "manual");
|
|
const cx = Math.floor((Number(x) || 0) / 256);
|
|
const cy = Math.floor((Number(y) || 0) / 256);
|
|
const cr = Math.max(0, Math.ceil((Number(radius) || 0) / 256));
|
|
if (!this.terrainDirtyStatsThisFrame) this.terrainDirtyStatsThisFrame = { raw: 0, global: 0, chunk: 0, coalesced: 0, reasons: {} };
|
|
this.terrainDirtyStatsThisFrame.raw += 1;
|
|
this.terrainDirtyStatsThisFrame.chunk += 1;
|
|
this.terrainDirtyStatsThisFrame.reasons[reasonKey] = (this.terrainDirtyStatsThisFrame.reasons[reasonKey] || 0) + 1;
|
|
if (!this.terrainDirtyChunks) this.terrainDirtyChunks = new Set();
|
|
let added = 0;
|
|
for (let yy = cy - cr; yy <= cy + cr; yy++) {
|
|
for (let xx = cx - cr; xx <= cx + cr; xx++) {
|
|
const key = `${xx}:${yy}`;
|
|
if (!this.terrainDirtyChunks.has(key)) added += 1;
|
|
this.terrainDirtyChunks.add(key);
|
|
}
|
|
}
|
|
this.terrainDirty = true;
|
|
this.terrainDirtyReason = reasonKey;
|
|
this.terrainLastDirtyAt = this.time || 0;
|
|
if (added <= 0) {
|
|
this.terrainDirtyStatsThisFrame.coalesced += 1;
|
|
return true;
|
|
}
|
|
this.terrainVersion = (this.terrainVersion || 0) + 1;
|
|
return true;
|
|
},
|
|
|
|
|
|
updateTerrainDirtyState(dt = 0) {
|
|
const signature = terrainSignature(this);
|
|
if (signature !== this.lastTerrainSignature) {
|
|
this.lastTerrainSignature = signature;
|
|
this.markTerrainDirty("terrain-signature");
|
|
return;
|
|
}
|
|
const interval = 3.0;
|
|
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;
|
|
},
|
|
|
|
groundDefinition() {
|
|
return window.TarinaiGround.definition(this.groundType || "soil");
|
|
},
|
|
|
|
groundLabel() {
|
|
return window.TarinaiGround.label(this.groundType || "soil");
|
|
},
|
|
|
|
groundStressMultiplier() {
|
|
return window.TarinaiGround.stressMultiplier(this.groundType || "soil");
|
|
},
|
|
|
|
groundGrassMultiplier() {
|
|
return window.TarinaiGround.grassMultiplier(this.groundType || "soil");
|
|
},
|
|
|
|
|
|
setGroundType(id = "soil", opts = {}) {
|
|
const next = window.TarinaiGround.exists(id) ? id : "soil";
|
|
const previous = this.groundType || "soil";
|
|
this.groundType = next;
|
|
if (next !== previous || opts.force) {
|
|
if (next !== previous && !opts.silent) {
|
|
window.TarinaiAchievements?.recordGroundChange?.({ world: this, previous, next, silent: false });
|
|
}
|
|
window.TarinaiWeatherSystem.enforceFixedWeather(this);
|
|
this.markTerrainDirty?.("ground-type");
|
|
this.enforceGrassLimit?.("ground-type");
|
|
this.updateItemCounts?.("ground-type");
|
|
this.drawListDirty = true;
|
|
window.TarinaiRender.invalidateRenderCaches("ground-type");
|
|
window.TarinaiGroundUI.update(this);
|
|
if (window.uiCache) window.uiCache.selectedSnapshot = "";
|
|
renderSelected();
|
|
if (typeof document !== "undefined") {
|
|
const groundTextEl = document.getElementById("statGroundType");
|
|
if (groundTextEl) groundTextEl.textContent = this.groundLabel?.() || "\u5730\u9762";
|
|
}
|
|
if (!opts.silent) {
|
|
const label = this.groundLabel?.() || "\u5730\u9762";
|
|
this.log?.(`\u5730\u9762: ${label}`);
|
|
showToast(`\u5730\u9762\u3092 ${label} \u306b\u3057\u307e\u3057\u305f\u3002`);
|
|
}
|
|
}
|
|
return this.groundDefinition?.();
|
|
},
|
|
|
|
cycleGroundType() {
|
|
return this.setGroundType(window.TarinaiGround.nextId(this.groundType || "soil"));
|
|
},
|
|
|
|
|
|
isFenceType(type = "") {
|
|
return isFenceItemType(type);
|
|
},
|
|
|
|
toolAngleFor(type = "") {
|
|
const key = String(type || "");
|
|
const fallback = defaultItemAngle(key);
|
|
const value = this.toolAngles?.[key];
|
|
return normalizedItemAngle(value, fallback);
|
|
},
|
|
|
|
setToolAngle(type = "", angle = 0, { silent = false } = {}) {
|
|
const key = String(type || "");
|
|
if (!key || !isRotatableItemType(key)) return false;
|
|
if (!this.toolAngles) this.toolAngles = {};
|
|
const fallback = defaultItemAngle(key);
|
|
const next = normalizedItemAngle(angle, fallback);
|
|
this.toolAngles[key] = next;
|
|
this.drawListDirty = true;
|
|
if (!silent) showToast(`\u5411\u304d: ${Math.round(next * 180 / Math.PI)}\u00b0`);
|
|
return true;
|
|
},
|
|
|
|
rotateToolAngle(type = "", delta = 0, options = {}) {
|
|
const key = String(type || "");
|
|
const current = this.toolAngleFor(key);
|
|
return this.setToolAngle?.(key, current + Number(delta || 0), options) || false;
|
|
},
|
|
|
|
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(BASE_ZOOM_MIN, 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(BASE_ZOOM_MAX, min * BASE_ZOOM_MAX);
|
|
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) {
|
|
// Hit tests use logical world units here, so this adapter intentionally
|
|
// returns the supplied size unchanged.
|
|
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),
|
|
};
|
|
},
|
|
|
|
|
|
clampCamera() {
|
|
this.cameraX = clamp(this.cameraX || 0, 0, maxCameraX(this));
|
|
this.cameraY = clamp(this.cameraY || 0, 0, maxCameraY(this));
|
|
},
|
|
|
|
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 = fieldWorldScale(this);
|
|
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 normalizedToolSizeFor(this, type);
|
|
},
|
|
|
|
toolSizeScale(type = "") {
|
|
return toolSizeScaleFor(this, type);
|
|
},
|
|
|
|
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 (isServingFoodType(item.type)) {
|
|
const servings = foodServingsForSize(size);
|
|
item.foodServingsMax = servings;
|
|
item.foodServingsRemaining = servings;
|
|
item.amount = servings;
|
|
(typeof updateServingFoodVisualSize === "function" ? updateServingFoodVisualSize(item) : false);
|
|
} 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);
|