// ============================================================================== // Copyright (C) 2023 // // This program is free software: you can redistribute it and/or modify it under // the terms of the GNU Affero General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) any // later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS // FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more // details. // // You should have received a copy of the GNU Affero General Public License along // with this program. If not, see . // ============================================================================== import * as THREE from 'three'; /** * AnimationEffects class for creating and managing visual effects for animations */ export class AnimationEffects { /** * Initialize animation effects * @param {THREE.Scene} scene - The Three.js scene */ constructor(scene) { this.scene = scene; this.activeEffects = []; this.arrowHelpers = new Map(); this.effects = {}; // Initialize common materials this._initMaterials(); } /** * Initialize commonly used materials * @private */ _initMaterials() { // Movement indicator materials this.materials = { // Regular movement move: new THREE.MeshBasicMaterial({ color: 0x4CAF50, // Green transparent: true, opacity: 0.7 }), // Hold order hold: new THREE.MeshBasicMaterial({ color: 0x2196F3, // Blue transparent: true, opacity: 0.7 }), // Support order support: new THREE.MeshBasicMaterial({ color: 0xFFC107, // Amber transparent: true, opacity: 0.7 }), // Convoy order convoy: new THREE.MeshBasicMaterial({ color: 0x9C27B0, // Purple transparent: true, opacity: 0.7 }), // Failed order failed: new THREE.MeshBasicMaterial({ color: 0xF44336, // Red transparent: true, opacity: 0.7 }), // Cut support cut: new THREE.MeshBasicMaterial({ color: 0xFF9800, // Orange transparent: true, opacity: 0.7 }) }; } /** * Create a movement path for unit animation * @param {Array} path - Array of points defining the path * @param {string} orderType - Type of order ('move', 'support', 'convoy', etc.) * @param {boolean} success - Whether the order was successful * @returns {THREE.Object3D} The path object */ createMovementPath(path, orderType = 'move', success = true) { // Validate path input if (!path || !Array.isArray(path) || path.length < 2) { console.error('[AnimationEffects] Invalid path provided to createMovementPath:', path); return null; } // Ensure all path points are Vector3 objects const vector3Path = path.map(point => { if (point instanceof THREE.Vector3) { return point; } else if (point && typeof point === 'object' && 'x' in point && 'y' in point && 'z' in point) { return new THREE.Vector3(point.x, point.y, point.z); } else { console.error('[AnimationEffects] Invalid point in path:', point); return null; } }).filter(p => p !== null); if (vector3Path.length < 2) { console.error('[AnimationEffects] Not enough valid points in path after filtering'); return null; } // Use appropriate material based on order type and success let material; if (!success) { material = this.materials.failed; } else { material = this.materials[orderType] || this.materials.move; } // Create a smooth curve from the path points const curve = new THREE.CatmullRomCurve3(vector3Path); try { const points = curve.getPoints(50); // Higher number = smoother curve const geometry = new THREE.BufferGeometry().setFromPoints(points); // Create the path line const line = new THREE.Line(geometry, material); line.userData.type = 'movementPath'; line.userData.createdAt = Date.now(); line.userData.duration = 3000; // 3 seconds default lifetime // Add to scene this.scene.add(line); this.activeEffects.push(line); return line; } catch (error) { console.error('[AnimationEffects] Error creating movement path:', error); return null; } } /** * Create an arrow showing the direction of movement * @param {THREE.Vector3} from - Starting position * @param {THREE.Vector3} to - Target position * @param {string} orderType - Type of order ('move', 'support', 'convoy', etc.) * @param {boolean} success - Whether the order was successful * @returns {THREE.Object3D} The arrow helper object */ createMovementArrow(from, to, orderType = 'move', success = true) { // Use appropriate color based on order type and success let color; if (!success) { color = 0xF44336; // Red for failed } else { switch (orderType) { case 'support': color = 0xFFC107; break; // Amber case 'convoy': color = 0x9C27B0; break; // Purple case 'hold': color = 0x2196F3; break; // Blue default: color = 0x4CAF50; break; // Green } } // Calculate direction and length const direction = new THREE.Vector3().subVectors(to, from).normalize(); const length = from.distanceTo(to) * 0.8; // Make arrow slightly shorter than full path // Create arrow helper const arrowHelper = new THREE.ArrowHelper( direction, from, length, color, length * 0.2, // Head length as 20% of total length length * 0.1 // Head width as 10% of total length ); arrowHelper.userData.type = 'movementArrow'; arrowHelper.userData.createdAt = Date.now(); arrowHelper.userData.duration = 3000; // 3 seconds default lifetime // Add to scene this.scene.add(arrowHelper); this.activeEffects.push(arrowHelper); return arrowHelper; } /** * Create a bounce effect at the specified position * @param {THREE.Vector3} position - Position for the bounce effect * @returns {THREE.Object3D} The bounce effect object */ createBounceEffect(position) { // Create a sphere that will expand and fade out const geometry = new THREE.SphereGeometry(2, 16, 16); const material = new THREE.MeshBasicMaterial({ color: 0xFF5252, transparent: true, opacity: 0.8 }); const sphere = new THREE.Mesh(geometry, material); sphere.position.copy(position); sphere.userData.type = 'bounceEffect'; sphere.userData.createdAt = Date.now(); sphere.userData.duration = 1000; // 1 second effect sphere.userData.animation = { initialScale: 1, targetScale: 15, initialOpacity: 0.8, targetOpacity: 0 }; // Add to scene this.scene.add(sphere); this.activeEffects.push(sphere); return sphere; } /** * Create a dislodge effect at the specified position * @param {THREE.Vector3} position - Position for the dislodge effect * @returns {THREE.Object3D} The dislodge effect object */ createDislodgeEffect(position) { // Create particles radiating outward const particleCount = 30; const particles = new THREE.Group(); particles.position.copy(position); particles.userData.type = 'dislodgeEffect'; particles.userData.createdAt = Date.now(); particles.userData.duration = 1500; // 1.5 seconds effect // Create particles for (let i = 0; i < particleCount; i++) { const size = Math.random() * 2 + 1; const geometry = new THREE.BoxGeometry(size, size, size); const material = new THREE.MeshBasicMaterial({ color: 0xFF0000, transparent: true, opacity: 0.8 }); const particle = new THREE.Mesh(geometry, material); // Set random direction const phi = Math.random() * Math.PI * 2; const theta = Math.random() * Math.PI; const speed = Math.random() * 5 + 5; particle.userData.velocity = new THREE.Vector3( Math.sin(theta) * Math.cos(phi) * speed, Math.sin(theta) * Math.sin(phi) * speed, Math.cos(theta) * speed ); particles.add(particle); } // Add to scene this.scene.add(particles); this.activeEffects.push(particles); return particles; } /** * Create a highlight effect for a territory * @param {THREE.Vector3} position - Position for the highlight * @param {number} color - Color of the highlight * @returns {THREE.Object3D} The highlight effect object */ createTerritoryHighlight(position, color = 0xFFEB3B) { // Create a ring at ground level const geometry = new THREE.RingGeometry(30, 40, 32); const material = new THREE.MeshBasicMaterial({ color: color, transparent: true, opacity: 0.4, side: THREE.DoubleSide }); const ring = new THREE.Mesh(geometry, material); ring.position.copy(position); ring.position.y = 1; // Just above ground ring.rotation.x = -Math.PI / 2; // Lay flat ring.userData.type = 'territoryHighlight'; ring.userData.createdAt = Date.now(); ring.userData.duration = 5000; // 5 seconds highlight ring.userData.animation = { pulseMin: 0.3, pulseMax: 0.6, pulseSpeed: 2 }; // Add to scene this.scene.add(ring); this.activeEffects.push(ring); return ring; } /** * Create a hold order indicator * @param {THREE.Vector3} position - Position for the hold indicator * @param {boolean} success - Whether the hold was successful * @returns {THREE.Object3D} The hold indicator object */ createHoldIndicator(position, success = true) { // Create a circular pattern const geometry = new THREE.TorusGeometry(15, 2, 16, 32); const material = new THREE.MeshBasicMaterial({ color: success ? 0x2196F3 : 0xF44336, transparent: true, opacity: 0.7 }); const torus = new THREE.Mesh(geometry, material); torus.position.copy(position); torus.position.y = 5; // Above ground torus.rotation.x = -Math.PI / 2; // Lay flat torus.userData.type = 'holdIndicator'; torus.userData.createdAt = Date.now(); torus.userData.duration = 3000; // 3 seconds display torus.userData.animation = { rotation: 0.01, pulseMin: 0.7, pulseMax: 1.0, pulseSpeed: 3 }; // Add to scene this.scene.add(torus); this.activeEffects.push(torus); return torus; } /** * Update all active effects * @param {number} deltaTime - Time elapsed since last update in seconds */ update(deltaTime) { const now = Date.now(); const expiredEffects = []; // Update each effect for (const effect of this.activeEffects) { const age = now - effect.userData.createdAt; const progress = Math.min(age / effect.userData.duration, 1); // Check if effect has expired if (progress >= 1) { expiredEffects.push(effect); continue; } // Update based on effect type switch (effect.userData.type) { case 'bounceEffect': this._updateBounceEffect(effect, progress); break; case 'dislodgeEffect': this._updateDislodgeEffect(effect, deltaTime); break; case 'territoryHighlight': this._updateTerritoryHighlight(effect, deltaTime); break; case 'holdIndicator': this._updateHoldIndicator(effect, deltaTime); break; case 'movementPath': case 'movementArrow': this._updateMovementIndicator(effect, progress); break; } } // Remove expired effects for (const effect of expiredEffects) { this.scene.remove(effect); const index = this.activeEffects.indexOf(effect); if (index !== -1) { this.activeEffects.splice(index, 1); } } } /** * Update bounce effect animation * @param {THREE.Object3D} effect - The effect to update * @param {number} progress - Animation progress (0-1) * @private */ _updateBounceEffect(effect, progress) { const { initialScale, targetScale, initialOpacity, targetOpacity } = effect.userData.animation; const scale = initialScale + (targetScale - initialScale) * progress; const opacity = initialOpacity + (targetOpacity - initialOpacity) * progress; effect.scale.set(scale, scale, scale); effect.material.opacity = opacity; } /** * Update dislodge effect animation * @param {THREE.Object3D} effect - The effect to update * @param {number} deltaTime - Time elapsed since last update in seconds * @private */ _updateDislodgeEffect(effect, deltaTime) { // Move each particle outward effect.children.forEach(particle => { particle.position.add(particle.userData.velocity.clone().multiplyScalar(deltaTime)); particle.material.opacity -= deltaTime * 0.5; // Fade out }); } /** * Update territory highlight animation * @param {THREE.Object3D} effect - The effect to update * @param {number} deltaTime - Time elapsed since last update in seconds * @private */ _updateTerritoryHighlight(effect, deltaTime) { const { pulseMin, pulseMax, pulseSpeed } = effect.userData.animation; // Calculate pulsing opacity const time = Date.now() / 1000; const pulse = pulseMin + (pulseMax - pulseMin) * (0.5 + 0.5 * Math.sin(time * pulseSpeed)); effect.material.opacity = pulse; } /** * Update hold indicator animation * @param {THREE.Object3D} effect - The effect to update * @param {number} deltaTime - Time elapsed since last update in seconds * @private */ _updateHoldIndicator(effect, deltaTime) { const { rotation, pulseMin, pulseMax, pulseSpeed } = effect.userData.animation; // Rotate the torus effect.rotation.z += rotation; // Calculate pulsing opacity const time = Date.now() / 1000; const pulse = pulseMin + (pulseMax - pulseMin) * (0.5 + 0.5 * Math.sin(time * pulseSpeed)); effect.material.opacity = pulse; } /** * Update movement indicator animation * @param {THREE.Object3D} effect - The effect to update * @param {number} progress - Animation progress (0-1) * @private */ _updateMovementIndicator(effect, progress) { // Fade out as animation nears completion const fadeStart = 0.8; if (progress > fadeStart) { const fadeProgress = (progress - fadeStart) / (1 - fadeStart); if (effect.material) { effect.material.opacity = 0.7 * (1 - fadeProgress); } else if (effect.line && effect.line.material) { effect.line.material.opacity = 0.7 * (1 - fadeProgress); } } } /** * Clear all active effects */ clearAllEffects() { for (const effect of this.activeEffects) { this.scene.remove(effect); } this.activeEffects = []; } }