import * as THREE from "three";
import gsap from "gsap";
import Experience from "../Experience.js";
export default class PrinceGreen {
constructor({ attackRange = 30, x = 0 }) {
this.experience = new Experience();
this.scene = this.experience.scene;
this.resources = this.experience.resources;
this.time = this.experience.time;
this.debug = this.experience.debug;
// Debug
if (this.debug.active) {
this.debugFolder = this.debug.ui.addFolder("PrinceGreen");
}
// Resource
this.resource = this.resources.items.prince_green;
// Array of meshes meteor can collide with
this.targets = [];
this.x = x
// Dynamic attack range
this.attackRange = attackRange;
this.setModel();
this.setAnimation();
}
setModel() {
this.model = this.resource.scene;
this.model.position.set(this.x, -0.1, 0);
this.scene.add(this.model);
this.model.traverse((child) => {
if (child instanceof THREE.Mesh) {
child.castShadow = true;
}
});
}
setParticles() {
this.particles = {};
// Meteor mesh
this.meteor = new THREE.Mesh(
new THREE.IcosahedronGeometry(0.25),
new THREE.MeshPhysicalMaterial({
color: "silver",
roughness: 0,
metalness: 0.6,
flatShading: true,
})
);
// spawn in front of character
const forward = new THREE.Vector3(0, 0, 1)
.applyQuaternion(this.model.quaternion)
.normalize();
const spawnPos = this.model.position.clone()
.add(forward.clone().multiplyScalar(1.0)) // 1 unit forward
.add(new THREE.Vector3(0, 1.0, 0)); // slightly above
this.meteor.position.copy(spawnPos);
this.scene.add(this.meteor);
this.meteorBox = new THREE.Box3().setFromObject(this.meteor);
// Create particle trail
const canvas = document.createElement("CANVAS");
canvas.width = 128;
canvas.height = 128;
const context = canvas.getContext("2d");
context.globalAlpha = 0.3;
context.filter = "blur(16px)";
context.fillStyle = "white";
context.beginPath();
context.arc(64, 64, 40, 0, 2 * Math.PI);
context.fill();
context.globalAlpha = 1;
context.filter = "blur(5px)";
context.fillStyle = "white";
context.beginPath();
context.arc(64, 64, 16, 0, 2 * Math.PI);
context.fill();
const texture = new THREE.CanvasTexture(canvas);
const N = 200,
M = 3;
const position = new THREE.BufferAttribute(new Float32Array(3 * N), 3);
const color = new THREE.BufferAttribute(new Float32Array(3 * N), 3);
const v = new THREE.Vector3();
for (let i = 0; i < N; i++) {
v.randomDirection().setLength(3 + 2 * Math.pow(Math.random(), 1 / 3));
position.setXYZ(
i,
this.meteor.position.x,
this.meteor.position.y,
this.meteor.position.z
);
color.setXYZ(i, Math.random(), Math.random(), Math.random());
}
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", position);
geometry.setAttribute("color", color);
const material = new THREE.PointsMaterial({
color: "white",
vertexColors: true,
size: 2,
sizeAttenuation: true,
map: texture,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
});
const cloud = new THREE.Points(geometry, material);
this.scene.add(cloud);
let idx = 0;
this.particles.burstBall = this.meteor;
this.particles.cloud = cloud;
this.particles.update = () => {
if (!this.meteor) return; // <---- safeguard
for (let j = 0; j < M; j++) {
v.randomDirection().divideScalar(4).add(this.meteor.position);
position.setXYZ(idx, v.x, v.y, v.z);
color.setXYZ(idx, 1, 1, 2);
idx = (idx + 1) % N;
}
// recolor all the rest particles
let k = 1;
for (let j = idx + N; j > idx - M; j--) {
color.setXYZ(j % N, k, k ** 1.5, 5 * k ** 3);
k = 0.98 * k;
}
position.needsUpdate = true;
color.needsUpdate = true;
};
}
findNearestTarget(maxRange = this.attackRange) { // use dynamic range
let nearest = null;
let minDist = Infinity;
for (let target of this.targets) {
if (!target) continue;
const dist = this.model.position.distanceTo(target.position);
if (dist < minDist && dist <= maxRange) {
minDist = dist;
nearest = target;
}
}
return nearest;
}
throwBall(target) {
if (!this.meteor) return;
const meteor = this.meteor;
// direction character is facing
const forward = new THREE.Vector3(0, 0, 1)
.applyQuaternion(this.model.quaternion)
.normalize();
// rise up position: a bit forward & up from current meteor
const risePos = meteor.position.clone()
.add(forward.clone().multiplyScalar(1.0)) // 1 unit forward
.add(new THREE.Vector3(0, 1.5, 0)); // 1.5 units upward
// Save timeline reference
this.meteorTimeline = gsap.timeline({
onUpdate: () => {
if (meteor && this.meteorBox) {
this.meteorBox.setFromObject(meteor);
}
this.particles?.update();
},
onComplete: () => {
this.destroyMeteor(target);
},
});
// Phase 1: rise up
this.meteorTimeline.to(meteor.position, {
x: risePos.x,
y: risePos.y,
z: risePos.z,
duration: 0.75,
delay: 0.25,
});
// Phase 2: chase toward *current* target position
const speed = 25; // units per second
this.meteorTimeline.to(meteor.position, {
duration: 3, // just a max duration; GSAP will overwrite each frame
ease: "none",
delay: 0.25,
onUpdate: () => {
if (!target) return;
const targetPos = target.position.clone();
const direction = targetPos.clone().sub(meteor.position);
const distance = direction.length();
// move step toward current target position
if (distance > 0.01) {
direction.normalize().multiplyScalar(speed * (gsap.ticker.deltaRatio() / 60));
meteor.position.add(direction);
}
},
});
}
destroyMeteor(target = null) {
if (this.meteorTimeline) {
this.meteorTimeline.kill();
this.meteorTimeline = null;
}
if (this.meteor) {
const splashPos = this.meteor.position.clone();
this.scene.remove(this.meteor);
this.meteor.geometry.dispose();
this.meteor.material.dispose();
this.meteor = null;
this.meteorBox = null;
if (this.particles?.cloud) {
const cloud = this.particles.cloud;
const geometry = cloud.geometry;
const positions = geometry.getAttribute("position");
const colors = geometry.getAttribute("color");
const N = positions.count;
// get flight direction (use target if available)
let normal = new THREE.Vector3(0, 0, 1);
if (target) {
normal = new THREE.Vector3()
.subVectors(target.position, splashPos)
.normalize();
}
// find a vector perpendicular to normal (to form ring plane)
const tangent = new THREE.Vector3(0, 1, 0);
if (Math.abs(normal.dot(tangent)) > 0.9) tangent.set(1, 0, 0);
const bitangent = new THREE.Vector3().crossVectors(normal, tangent).normalize();
tangent.crossVectors(bitangent, normal).normalize();
for (let i = 0; i < N; i++) {
const angle = (i / N) * Math.PI * 2;
const distortion = (Math.random() - 0.5) * 0.1;
const r = 0.05 + distortion;
// parametric circle in tangent/bitangent plane
const x = splashPos.x + r * (tangent.x * Math.cos(angle) + bitangent.x * Math.sin(angle));
const y = splashPos.y + r * (tangent.y * Math.cos(angle) + bitangent.y * Math.sin(angle));
const z = splashPos.z + r * (tangent.z * Math.cos(angle) + bitangent.z * Math.sin(angle));
positions.setXYZ(i, x, y, z);
const t = i / N;
colors.setXYZ(i, 1, 1 - t, 1 - t);
}
positions.needsUpdate = true;
colors.needsUpdate = true;
const radiusObj = { r: 0.05, opacity: 1 };
gsap.to(radiusObj, {
r: 1.2,
opacity: 0,
duration: 0.4,
ease: "power2.out",
onUpdate: () => {
cloud.material.opacity = radiusObj.opacity;
for (let i = 0; i < N; i++) {
const angle = (i / N) * Math.PI * 2;
const distortion = (Math.random() - 0.5) * 0.1;
const r = radiusObj.r + distortion;
const x = splashPos.x + r * (tangent.x * Math.cos(angle) + bitangent.x * Math.sin(angle));
const y = splashPos.y + r * (tangent.y * Math.cos(angle) + bitangent.y * Math.sin(angle));
const z = splashPos.z + r * (tangent.z * Math.cos(angle) + bitangent.z * Math.sin(angle));
positions.setXYZ(i, x, y, z);
}
positions.needsUpdate = true;
},
onComplete: () => {
if (cloud.parent) this.scene.remove(cloud);
geometry.dispose();
cloud.material.dispose();
this.particles.cloud = null;
this.particles.update = () => { };
},
});
}
}
}
setAnimation() {
this.animation = {};
// Mixer
this.animation.mixer = new THREE.AnimationMixer(this.model);
this.animation.mixer.addEventListener('finished', () => {
this.triggered = false;
})
// Actions
this.animation.actions = {};
this.animation.actions.idle = this.animation.mixer.clipAction(
this.resource.animations[0]
);
this.animation.actions.fire = this.animation.mixer.clipAction(
this.resource.animations[1]
);
this.animation.actions.current = this.animation.actions.idle;
this.animation.actions.current.play();
this.triggerTime = 1.1;
this.triggered = false;
this.animation.play = (name) => {
const newAction = this.animation.actions[name];
const oldAction = this.animation.actions.current;
if (newAction === oldAction) return; // prevent re-triggering same anim
newAction.reset();
newAction.play();
newAction.crossFadeFrom(oldAction, 0.5);
this.animation.actions.current = newAction;
this.triggered = false; // reset meteor spawn control
this.prevActionTime = 0; // reset animation cycle tracking
};
if (this.debug.active) {
const debugObject = {
playIdle: () => this.animation.play("idle"),
playFire: () => this.animation.play("fire"),
};
this.debugFolder.add(debugObject, "playIdle");
this.debugFolder.add(debugObject, "playFire");
}
}
update() {
// Update animation mixer
this.animation.mixer.update(this.time.delta * 0.001);
const action = this.animation.actions.current;
if (action) {
// 🔄 Detect animation restart (looped back to start)
if (action.time < this.prevActionTime) {
this.triggered = false; // reset for new cycle
}
this.prevActionTime = action.time;
// 🎯 Find nearest target within dynamic range
let nearestTarget = null;
let minDist = Infinity;
for (let target of this.targets) {
if (!target) continue;
const dist = this.model.position.distanceTo(target.position);
if (dist < minDist && dist <= this.attackRange) { // ✅ use dynamic range
minDist = dist;
nearestTarget = target;
}
}
// Rotate toward nearest target if any
if (nearestTarget) {
this.model.lookAt(nearestTarget.position);
if (this.animation.actions.current !== this.animation.actions.fire) {
this.animation.play('fire');
}
} else {
if (this.animation.actions.current !== this.animation.actions.idle) {
this.animation.play('idle');
}
}
// 🚀 Spawn meteor ONLY once per fire cycle
if (
action === this.animation.actions.fire &&
!this.triggered &&
action.time >= this.triggerTime
) {
if (nearestTarget) {
this.setParticles();
this.throwBall(nearestTarget);
}
this.triggered = true;
}
}
// ✨ Update particles
this.particles?.update();
// 💥 Collision check
if (this.meteor && this.meteorBox) {
for (let target of this.targets) {
const targetBox = new THREE.Box3().setFromObject(target);
if (this.meteorBox.intersectsBox(targetBox)) {
this.destroyMeteor(target);
break;
}
}
}
}
}
Above is my code , Am using this model,
When Tries creating multiple classes of this class, It is causing issue of model stuck with no animation playing. It’s happening with other skinned mesh models too. Can someone please help with this one ?