Spaces:
Running
Running
import * as THREE from "three"; | |
import { Projectile } from "../../Projectile.js"; | |
import { findClosestToExitInRange } from "../common/targeting.js"; | |
const VISUAL_TOP_INCREMENT = 0.08; | |
const VISUAL_TOP_CAP = 0.4; | |
export default { | |
key: "sniper", | |
buildHead(tower) { | |
const headGeo = new THREE.ConeGeometry(0.7, 0.9, 3); | |
const headMat = new THREE.MeshStandardMaterial({ | |
color: 0xb0bec5, | |
metalness: 0.35, | |
roughness: 0.4, | |
emissive: 0x330000, | |
emissiveIntensity: 0.4, | |
side: THREE.DoubleSide, | |
}); | |
const head = new THREE.Mesh(headGeo, headMat); | |
head.castShadow = true; | |
head.position.set(0, 0.95, 0); | |
head.rotation.x = 0; | |
tower.baseMesh.add(head); | |
tower.headMesh = head; | |
tower.head = head; | |
const headTopOffset = 0.55; | |
tower.headTopY = tower.mesh.position.y + head.position.y + headTopOffset; | |
}, | |
tryFire(tower, dt, enemies, projectiles, projectileSpeed) { | |
tower.fireCooldown -= dt; | |
if (tower.aiming) { | |
const t = tower.aimedTarget; | |
const targetPos = t?.mesh?.position || t?.position; | |
const alive = t && !t.isDead(); | |
const within = | |
alive && | |
targetPos?.distanceToSquared(tower.position) <= | |
tower.cancelThreshold * tower.cancelThreshold; | |
if (!alive || !within) { | |
this.removeLaser(tower); | |
tower.stopAimingTone(); | |
tower.aiming = false; | |
tower.aimedTarget = null; | |
} else { | |
const dir = new THREE.Vector3().subVectors(targetPos, tower.position); | |
const yaw = Math.atan2(dir.x, dir.z); | |
tower.mesh.rotation.y = yaw; | |
const start = tower.position | |
.clone() | |
.add(new THREE.Vector3(0, tower.headTopY, 0)); | |
const end = targetPos.clone(); | |
if (tower.laserLine) this.updateLaser(tower.laserLine, start, end); | |
tower.aimingTimer -= dt; | |
if (tower.aimingTimer <= 0) { | |
const spawnY = | |
typeof tower.headTopY === "number" ? tower.headTopY - 0.1 : 0.9; | |
const proj = new Projectile( | |
tower.position.clone().add(new THREE.Vector3(0, spawnY, 0)), | |
t, | |
tower.sniperProjectileSpeed ?? projectileSpeed, | |
tower.scene, | |
null | |
); | |
proj.damage = tower.damage; | |
projectiles.push(proj); | |
this.removeLaser(tower); | |
tower.stopAimingTone(); | |
tower.playFireCrack(); | |
tower.fireCooldown = 1 / tower.rate; | |
tower.aiming = false; | |
tower.aimedTarget = null; | |
} | |
} | |
return; | |
} | |
if (tower.fireCooldown > 0) return; | |
const target = findClosestToExitInRange(tower, enemies); | |
if (!target) return; | |
const dir = new THREE.Vector3().subVectors( | |
target.mesh.position, | |
tower.position | |
); | |
const yaw = Math.atan2(dir.x, dir.z); | |
tower.mesh.rotation.y = yaw; | |
tower.aimedTarget = target; | |
tower.aiming = true; | |
tower.aimingTimer = Math.max(0.01, tower.aimTime || 0.01); | |
const start = tower.position | |
.clone() | |
.add(new THREE.Vector3(0, tower.headTopY, 0)); | |
const end = target.mesh.position.clone(); | |
tower.laserLine = this.createLaser(tower, start, end); | |
tower.playAimingTone(); | |
}, | |
createLaser(tower, start, end) { | |
const points = [start.clone(), end.clone()]; | |
const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
const material = new THREE.LineBasicMaterial({ | |
color: 0xff3b30, | |
transparent: true, | |
opacity: 0.9, | |
linewidth: 2, | |
}); | |
const line = new THREE.Line(geometry, material); | |
line.position.y += 0.01; | |
tower.scene.add(line); | |
return line; | |
}, | |
updateLaser(line, start, end) { | |
const positions = line.geometry.attributes.position; | |
positions.setXYZ(0, start.x, start.y, start.z); | |
positions.setXYZ(1, end.x, end.y, end.z); | |
positions.needsUpdate = true; | |
}, | |
removeLaser(tower) { | |
if (tower.laserLine) { | |
tower.scene.remove(tower.laserLine); | |
if (tower.laserLine.geometry) tower.laserLine.geometry.dispose(); | |
if (tower.laserLine.material?.dispose) tower.laserLine.material.dispose(); | |
tower.laserLine = null; | |
} | |
}, | |
applyVisualLevel(tower) { | |
const lvl = tower.level; | |
const head = tower.headMesh; | |
if (!head) return; | |
const baseMat = tower.baseMesh?.material; | |
const headMat = head.material; | |
// Remove previous ring if any | |
if (tower.levelRing) { | |
tower.scene.remove(tower.levelRing); | |
tower.levelRing.geometry.dispose(); | |
if (tower.levelRing.material?.dispose) tower.levelRing.material.dispose(); | |
tower.levelRing = null; | |
} | |
// Compute visual-only extra height based on level (starts at level 2) | |
const extraRaw = Math.max(0, (lvl - 1) * VISUAL_TOP_INCREMENT); | |
const visualExtra = Math.min(VISUAL_TOP_CAP, extraRaw); | |
if (lvl <= 1) { | |
// Default look for sniper | |
if (baseMat) { | |
baseMat.color?.set?.(0x6d6f73); | |
baseMat.emissive?.set?.(0x000000); | |
baseMat.emissiveIntensity = 0.0; | |
baseMat.metalness = 0.5; | |
baseMat.roughness = 0.35; | |
} | |
// Keep original head height/position; recompute top using current head position | |
tower.headTopY = (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.55; | |
headMat.color?.set?.(0xb0bec5); | |
headMat.emissive?.set?.(0x330000); | |
headMat.emissiveIntensity = 0.4; | |
headMat.metalness = 0.35; | |
headMat.roughness = 0.4; | |
} else { | |
// Level 2+ look for sniper | |
if (baseMat) { | |
baseMat.color?.set?.(0x5d5f63); | |
baseMat.emissive?.set?.(0x2a0a1a); | |
baseMat.emissiveIntensity = 0.08; | |
baseMat.metalness = 0.55; | |
baseMat.roughness = 0.3; | |
} | |
// Keep original head height/position; recompute top using current head position | |
tower.headTopY = (tower.mesh?.position.y ?? 0.25) + head.position.y + 0.55; | |
headMat.color?.set?.(0x90a4ae); | |
headMat.emissive?.set?.(0x550000); | |
headMat.emissiveIntensity = 0.5; | |
headMat.metalness = 0.4; | |
headMat.roughness = 0.35; | |
// Optional thin ring on top (same as basic tower) | |
const ringGeom = new THREE.TorusGeometry(0.45, 0.035, 8, 24); | |
const ringMat = new THREE.MeshStandardMaterial({ | |
color: 0x00ffff, // Cyan for sniper | |
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); | |
} | |
// Recompute headTopY using current head position | |
const headTopOffset = 0.55; | |
tower.headTopY = tower.mesh.position.y + head.position.y + headTopOffset; | |
// Sniper-specific upgrades | |
if (lvl > 1) { | |
const minAimTime = (tower.aimTime ?? 0) * 0.4; | |
tower.aimTime = Math.max(minAimTime, (tower.aimTime ?? 0) * 0.9); | |
tower.pierceChance = Math.min(0.15, (tower.pierceChance ?? 0) + 0.03); | |
} | |
}, | |
onDestroy(tower) { | |
this.removeLaser(tower); | |
}, | |
}; | |