import { UpdateController } from "@24tools/playable_template"; import { Color, Material, Mesh, Object3D, Vector3 } from "three"; import { ResourceScreenFly } from "../Resources/ResourceScreenFly"; import { findInteractiveZoneFromMap, getInteractiveZoneCenter, INTERACTIVE_ZONE_NAME, } from "./MapInteractiveZone"; const FILL_TARGET = 10; const FILL_COLOR = new Color(0x2db83a); /** Амплітуда «поглинаючого» імпульсу (частка від базового масштабу). 0.05 = 5%. */ const PULSE_AMPLITUDE = 0.05; /** Тривалість одного імпульсу, сек: базовий розмір → пік → базовий. */ const PULSE_DURATION = 0.18; /** Значення таймера, коли імпульс неактивний. */ const PULSE_IDLE = Infinity; export class InteractiveZoneC { private static zoneRoot: Object3D | null = null; private static deposited = 0; private static baseColors = new WeakMap(); private static baseScale = new Vector3(1, 1, 1); private static pulseTime = PULSE_IDLE; static init(mapObject: Object3D) { const zone = findInteractiveZoneFromMap(mapObject); if (!zone) { console.warn(`Interactive zone not found: ${INTERACTIVE_ZONE_NAME}`); return; } this.zoneRoot = zone; this.baseScale.copy(zone.scale); this.setupMaterials(zone); UpdateController.Instance.onUpdate.addDelegate((delta: number) => { this.updatePulse(delta); }); } /** Один «поглинаючий» імпульс зони — викликати на приліт кожного ресурсу. */ static pulse() { this.pulseTime = 0; } /** Повертає зону до базового розміру і гасить імпульс (teardown/зупинка). */ static resetPulse() { this.pulseTime = PULSE_IDLE; if (this.zoneRoot) this.zoneRoot.scale.copy(this.baseScale); } private static updatePulse(delta: number) { if (!this.zoneRoot || this.pulseTime === PULSE_IDLE) return; this.pulseTime += delta; if (this.pulseTime >= PULSE_DURATION) { this.resetPulse(); return; } const env = Math.sin(Math.PI * (this.pulseTime / PULSE_DURATION)); const k = 1 + PULSE_AMPLITUDE * env; this.zoneRoot.scale.set( this.baseScale.x * k, this.baseScale.y * k, this.baseScale.z * k, ); } static getWorldCenter(out = new Vector3()) { if (!this.zoneRoot) return out.set(0, 0, 0); return getInteractiveZoneCenter(this.zoneRoot, out); } static getScreenCenter() { return ResourceScreenFly.worldToScreen(this.getWorldCenter()); } static addDeposit(amount = 1) { this.deposited += amount; this.refreshFillVisual(); } /** Звільняє клоновані матеріали зони (виклик на teardown playable). */ static dispose() { if (!this.zoneRoot) return; this.resetPulse(); this.zoneRoot.traverse((child) => { const mesh = child as Mesh; if (!mesh.isMesh) return; const material = mesh.material; if (Array.isArray(material)) { material.forEach((entry) => entry.dispose()); } else if (material) { material.dispose(); } }); this.baseColors = new WeakMap(); } static getFillRatio() { return Math.min(this.deposited / FILL_TARGET, 1); } private static setupMaterials(zone: Object3D) { zone.traverse((child) => { if (!(child as Mesh).isMesh) return; const mesh = child as Mesh; const sourceMaterial = mesh.material; if (Array.isArray(sourceMaterial)) return; if (!sourceMaterial) return; const cloned = sourceMaterial.clone(); if ("color" in cloned) { this.baseColors.set(cloned, (cloned.color as Color).clone()); } cloned.depthWrite = true; cloned.polygonOffset = true; cloned.polygonOffsetFactor = 2; cloned.polygonOffsetUnits = 2; mesh.material = cloned; mesh.renderOrder = 0; }); this.refreshFillVisual(); } private static refreshFillVisual() { if (!this.zoneRoot) return; const ratio = this.getFillRatio(); this.zoneRoot.traverse((child) => { if (!(child as Mesh).isMesh) return; const material = (child as Mesh).material; if (Array.isArray(material) || !material) return; const baseColor = this.baseColors.get(material); if (!baseColor || !("color" in material)) return; (material.color as Color).copy(baseColor).lerp(FILL_COLOR, ratio); }); } }