victor's picture
victor HF Staff
ok
0ee2f3f
import * as THREE from "three";
import { findMultipleInRangeOrdered } from "../common/targeting.js";
const ELECTRIC_BALL_LIFT_PER_LEVEL = 0.08;
const ELECTRIC_BALL_LIFT_CAP = 0.35;
export default {
key: "electric",
buildHead(tower) {
const headGroup = new THREE.Group();
const barHeight = 0.9;
const barGeo = new THREE.CylinderGeometry(0.08, 0.08, barHeight, 16);
const barMat = new THREE.MeshStandardMaterial({
color: 0x1b1f24,
metalness: 0.4,
roughness: 0.6,
});
const bar = new THREE.Mesh(barGeo, barMat);
bar.castShadow = true;
bar.receiveShadow = true;
bar.position.set(0, 0.5 + barHeight * 0.5, 0);
const headGeo = new THREE.SphereGeometry(0.45, 20, 16);
const headMat = new THREE.MeshStandardMaterial({
color: 0x9ad6ff,
metalness: 0.18,
roughness: 0.45,
emissive: 0x153a6b,
emissiveIntensity: 0.85,
side: THREE.DoubleSide,
});
const head = new THREE.Mesh(headGeo, headMat);
head.castShadow = true;
head.position.set(0, 0.5 + barHeight + 0.25, 0);
head.scale.set(1.0, 1.0, 1.0);
headGroup.add(bar);
headGroup.add(head);
const haloGeo = new THREE.TorusGeometry(0.38, 0.02, 8, 24);
const haloMat = new THREE.MeshStandardMaterial({
color: 0x80e1ff,
emissive: 0x206a99,
emissiveIntensity: 0.4,
metalness: 0.2,
roughness: 0.6,
});
const halo = new THREE.Mesh(haloGeo, haloMat);
halo.rotation.x = Math.PI / 2;
halo.position.set(0, head.position.y - 0.22, 0);
halo.castShadow = false;
halo.receiveShadow = false;
headGroup.add(halo);
tower.baseMesh.add(headGroup);
tower.headMesh = head;
tower.head = head;
tower.headTopY = tower.mesh.position.y + head.position.y + 0.45;
// Remember base head Y so applyVisualLevel can position absolutely per level
tower._electricBaseHeadY = head.position.y;
tower.trackedTargets = new Map();
tower.arcPool = [];
tower._visualAccumulator = 0;
tower.activeArcs = [];
},
tryFire(tower, dt, enemies) {
this.updateElectric(tower, dt, enemies);
this.updateElectricArcs(tower);
},
updateElectric(tower, dt, enemies) {
const current = this._refreshElectricTargets(tower, enemies);
if (!current.length) {
return;
}
const primary = current[0];
if (primary?.mesh?.position) {
const dir = new THREE.Vector3().subVectors(
primary.mesh.position,
tower.position
);
const yaw = Math.atan2(dir.x, dir.z);
tower.mesh.rotation.y = yaw;
}
const dps = tower.damagePerSecond ?? 1;
for (const enemy of current) {
if (!enemy || enemy.isDead?.()) continue;
enemy.takeDamage?.(dps * dt);
}
tower._visualAccumulator += dt;
const doRefresh = tower._visualAccumulator >= tower.visualRefreshInterval;
if (doRefresh) tower._visualAccumulator = 0;
const start = tower.position
.clone()
.add(new THREE.Vector3(0, tower.headTopY ?? 0.9, 0));
for (const enemy of current) {
const info = tower.trackedTargets.get(enemy);
const end = (enemy.mesh?.position || enemy.position)?.clone?.();
if (!end) continue;
if (!info.arc) {
info.arc = this._getArcInstance(tower, start, end);
if (info.arc?.lineOuter?.material)
info.arc.lineOuter.material.opacity = 0.0;
if (info.arc?.lineInner?.material)
info.arc.lineInner.material.opacity = 0.0;
info.fadeInTimer = tower.arcFadeDuration ?? 0.2;
}
if (doRefresh) this._updateArcGeometry(tower, info.arc, start, end, true);
if (typeof info.fadeInTimer === "number" && info.fadeInTimer > 0) {
info.fadeInTimer = Math.max(0, info.fadeInTimer - dt);
const denom = tower.arcFadeDuration || 0.2;
const t = denom > 0 ? 1 - info.fadeInTimer / denom : 1;
const outer = info.arc.lineOuter ? info.arc.lineOuter.material : null;
const inner = info.arc.lineInner ? info.arc.lineInner.material : null;
if (outer) {
outer.opacity = 0.5 * Math.min(1, t);
outer.needsUpdate = true;
}
if (inner) {
inner.opacity = 0.95 * Math.min(1, t);
inner.needsUpdate = true;
}
}
info.visible = true;
info.lastEnd = end;
}
},
_refreshElectricTargets(tower, enemies) {
const desired = findMultipleInRangeOrdered(
tower,
enemies,
tower.targetPriority || "closestToExit"
).slice(0, tower.maxTargets || 4);
const prev = tower.trackedTargets || new Map();
const desiredSet = new Set(desired);
const currentSet = new Set(prev.keys());
for (const e of currentSet) {
if (!desiredSet.has(e)) {
const info = prev.get(e);
if (info?.arc) {
const now = performance.now();
const durMs = Math.max(1, (tower.arcFadeDuration || 0.2) * 1000);
tower.activeArcs ||= [];
tower.activeArcs.push({
lineOuter: info.arc.lineOuter,
lineInner: info.arc.lineInner,
createdAt: now,
duration: durMs,
});
}
prev.delete(e);
}
}
let hasNewTargets = false;
for (const e of desired) {
if (!prev.has(e)) {
prev.set(e, {
arc: null,
fadeInTimer: 0,
lastEnd: null,
visible: false,
});
hasNewTargets = true;
}
}
if (hasNewTargets && desired.length > 0) {
tower.playShootSound();
}
tower.trackedTargets = prev;
return desired;
},
_getArcInstance(tower, start, end) {
if (tower.arcPool && tower.arcPool.length > 0) {
const pooled = tower.arcPool.pop();
this._updateArcGeometry(tower, pooled, start, end, true);
if (pooled.lineOuter) pooled.lineOuter.visible = true;
if (pooled.lineInner) pooled.lineInner.visible = true;
return {
lineOuter: pooled.lineOuter,
lineInner: pooled.lineInner,
createdAt: performance.now(),
duration: (tower.arcFadeDuration || 0.2) * 1000,
};
}
return this.createElectricArc(tower, start, end);
},
createElectricArc(tower, start, end) {
const style = tower.arcStyle || {};
const segs = Math.max(2, style.segments ?? 10);
const jitter = style.jitter ?? 0.25;
const dir = new THREE.Vector3().subVectors(end, start);
const len = dir.length();
if (len < 1e-4) dir.set(0, 0, 1);
else dir.normalize();
const up = new THREE.Vector3(0, 1, 0);
let right = new THREE.Vector3().crossVectors(dir, up);
if (right.lengthSq() < 1e-6) {
right = new THREE.Vector3(1, 0, 0);
} else {
right.normalize();
}
const binorm = new THREE.Vector3().crossVectors(dir, right).normalize();
const points = [];
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const base = new THREE.Vector3()
.copy(start)
.addScaledVector(dir, len * t);
const amp = jitter * (1 - Math.abs(0.5 - t) * 2);
const offR = (Math.random() * 2 - 1) * amp;
const offB = (Math.random() * 2 - 1) * amp;
base.addScaledVector(right, offR).addScaledVector(binorm, offB);
base.y += 0.01;
points.push(base);
}
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const matOuter = new THREE.LineBasicMaterial({
color: style.color ?? 0x9ad6ff,
transparent: true,
opacity: 0.5,
linewidth: (style.thickness ?? 2) * 1.8,
depthWrite: false,
});
const matInner = new THREE.LineBasicMaterial({
color: style.coreColor ?? 0xe6fbff,
transparent: true,
opacity: 0.95,
linewidth: style.thickness ?? 2,
depthWrite: false,
});
const lineOuter = new THREE.Line(geometry, matOuter);
const lineInner = new THREE.Line(geometry.clone(), matInner);
tower.scene.add(lineOuter);
tower.scene.add(lineInner);
lineOuter.visible = true;
lineInner.visible = true;
return {
lineOuter,
lineInner,
createdAt: performance.now(),
duration: tower.arcDurationMs ?? 120,
};
},
_updateArcGeometry(tower, arc, start, end, rebuild = true) {
if (!arc?.lineOuter || !arc?.lineInner) return;
if (rebuild) {
const style = tower.arcStyle || {};
const segs = Math.max(2, style.segments ?? 10);
const jitter = style.jitter ?? 0.25;
const dir = new THREE.Vector3().subVectors(end, start);
const len = dir.length();
if (len < 1e-4) dir.set(0, 0, 1);
else dir.normalize();
const up = new THREE.Vector3(0, 1, 0);
let right = new THREE.Vector3().crossVectors(dir, up);
if (right.lengthSq() < 1e-6) right = new THREE.Vector3(1, 0, 0);
else right.normalize();
const binorm = new THREE.Vector3().crossVectors(dir, right).normalize();
const points = [];
for (let i = 0; i <= segs; i++) {
const t = i / segs;
const base = new THREE.Vector3()
.copy(start)
.addScaledVector(dir, len * t);
const amp = jitter * (1 - Math.abs(0.5 - t) * 2);
const offR = (Math.random() * 2 - 1) * amp;
const offB = (Math.random() * 2 - 1) * amp;
base.addScaledVector(right, offR).addScaledVector(binorm, offB);
base.y += 0.01;
points.push(base);
}
const newGeo = new THREE.BufferGeometry().setFromPoints(points);
const oldOuter = arc.lineOuter.geometry;
const oldInner = arc.lineInner.geometry;
arc.lineOuter.geometry = newGeo;
arc.lineInner.geometry = newGeo.clone();
oldOuter?.dispose?.();
oldInner?.dispose?.();
} else {
const positions = arc.lineOuter.geometry.attributes.position;
positions.setXYZ(0, start.x, start.y, start.z);
positions.setXYZ(positions.count - 1, end.x, end.y, end.z);
positions.needsUpdate = true;
const positions2 = arc.lineInner.geometry.attributes.position;
positions2.setXYZ(0, start.x, start.y, start.z);
positions2.setXYZ(positions2.count - 1, end.x, end.y, end.z);
positions2.needsUpdate = true;
}
},
updateElectricArcs(tower, nowMs = performance.now()) {
if (!tower.activeArcs || tower.activeArcs.length === 0) return;
const remain = [];
for (const arc of tower.activeArcs) {
const t = Math.max(
0,
Math.min(1, (nowMs - arc.createdAt) / arc.duration)
);
const fade = 1.0 - t;
if (arc.lineOuter?.material) {
arc.lineOuter.material.opacity = 0.5 * fade;
arc.lineOuter.material.needsUpdate = true;
}
if (arc.lineInner?.material) {
arc.lineInner.material.opacity = 0.95 * fade;
arc.lineInner.material.needsUpdate = true;
}
if (t < 1) {
remain.push(arc);
} else {
if (arc.lineOuter || arc.lineInner) {
tower.arcPool ||= [];
if (arc.lineOuter) arc.lineOuter.visible = false;
if (arc.lineInner) arc.lineInner.visible = false;
tower.arcPool.push({
lineOuter: arc.lineOuter,
lineInner: arc.lineInner,
});
}
}
}
tower.activeArcs = remain;
},
applyVisualLevel(tower) {
const lvl = tower.level;
const head = tower.headMesh;
if (!head) return;
// Do not raise the head on level gain — height remains constant
const baseY = typeof tower._electricBaseHeadY === 'number' ? tower._electricBaseHeadY : head.position.y;
head.position.y = baseY;
tower.headTopY =
(tower.mesh?.position.y ?? 0.25) + head.position.y + 0.45;
// Remove any existing base level ring before creating a new one
if (tower.levelRing) {
tower.scene.remove(tower.levelRing);
tower.levelRing.geometry?.dispose?.();
if (tower.levelRing.material?.dispose) tower.levelRing.material.dispose();
tower.levelRing = null;
}
if (lvl > 1) {
head.material.color?.set?.(0xa5d6ff);
head.material.emissive?.set?.(0x9a135a);
head.material.emissiveIntensity = 0.35;
const ringGeom = new THREE.TorusGeometry(0.45, 0.035, 8, 24);
const ringMat = new THREE.MeshStandardMaterial({
color: 0x3aa6ff,
emissive: 0xe01a6b,
emissiveIntensity: 0.55,
metalness: 0.3,
roughness: 0.45,
});
const ring = new THREE.Mesh(ringGeom, ringMat);
ring.castShadow = false;
ring.receiveShadow = false;
const topY = tower.headTopY ?? head.position.y + 0.8;
ring.position.set(
tower.mesh.position.x,
topY + 0.02,
tower.mesh.position.z
);
ring.rotation.x = Math.PI / 2;
ring.name = "tower_level_ring";
tower.levelRing = ring;
tower.scene.add(ring);
}
},
onDestroy(tower) {
const disposeArc = (arcObj) => {
if (!arcObj) return;
const { lineOuter, lineInner } = arcObj;
if (lineOuter) {
tower.scene.remove(lineOuter);
lineOuter.geometry?.dispose?.();
lineOuter.material?.dispose?.();
}
if (lineInner) {
tower.scene.remove(lineInner);
lineInner.geometry?.dispose?.();
lineInner.material?.dispose?.();
}
};
if (tower.activeArcs && tower.activeArcs.length) {
for (const arc of tower.activeArcs) disposeArc(arc);
tower.activeArcs = [];
}
if (tower.trackedTargets && tower.trackedTargets.size) {
for (const info of tower.trackedTargets.values()) {
if (info.arc) disposeArc(info.arc);
}
tower.trackedTargets.clear();
}
if (tower.arcPool && tower.arcPool.length) {
for (const pooled of tower.arcPool) disposeArc(pooled);
tower.arcPool.length = 0;
}
},
};