Spaces:
Running
Running
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; | |
} | |
}, | |
}; | |