mirror of
https://github.com/PlayableIntelligence/game-creator.git
synced 2026-09-19 07:34:10 +08:00
feat(effects): add 3D spectacle effects — particles, floating text, screen juice
Add three new visual effects systems for the GigaChad Gym Simulator: - ParticleManager: GPU particle pool (200 pre-allocated via THREE.Points), burst emissions on catch/miss/powerup/combo/streak/entrance events, ambient floating dust motes (30 always-active), expanding shockwave rings - FloatingText: CSS-positioned score popups that project 3D catch positions to screen coords, scale with combo level, color-coded by weight type - ScreenEffects: flash overlays (white/red/green/gold), camera FOV pulse on catch, directional light pulse, 60ms hit freeze, combo-scaled shake All effects are non-blocking, degrade gracefully, and use Constants.js for all magic numbers (35+ new EFFECTS config values). Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -38,7 +38,7 @@ GigaChad Gym Simulator - endless gym workout simulator where GigaChad catches fa
|
||||
|
||||
### TODOs for next steps
|
||||
- [x] Step 1.5: Replace primitives with Meshy AI GLB models
|
||||
- [ ] Step 3: Add particles (catch sparks, miss impact, combo fire), transitions, screen effects
|
||||
- [x] Step 2: Visual design — particles, transitions, screen effects, juice
|
||||
- [ ] Step 4: Record promo video
|
||||
- [ ] Step 5: Add BGM (gym beats) + SFX (catch clank, miss thud, powerup chime, flex grunt)
|
||||
- [ ] Step 6: Deploy to here.now
|
||||
@@ -73,3 +73,44 @@ GigaChad Gym Simulator - endless gym workout simulator where GigaChad catches fa
|
||||
- Walk/run clips loaded from separate GLB files (Meshy exports animations as separate files)
|
||||
- All model loads have `.catch()` fallback to original primitive geometries — game fully playable even if all GLBs fail to load
|
||||
- Materials cloned per instance for weight fade-out animation (opacity changes must be independent)
|
||||
|
||||
## Step 2: Design (Complete)
|
||||
|
||||
### What was added
|
||||
Three new effect systems under `src/effects/`, plus Constants and Game.js integration.
|
||||
|
||||
### New files
|
||||
- **`src/effects/ParticleManager.js`** — GPU particle system using `THREE.Points` with a pre-allocated pool of 200 particles. Supports burst emissions at arbitrary positions with configurable count, color, and speed. Also manages ambient floating dust/chalk particles (30 always-active motes drifting through the gym) and expanding shockwave rings on the floor.
|
||||
- **`src/effects/FloatingText.js`** — CSS-based floating score text. Projects 3D catch positions to screen coordinates and shows "+N" text that rises upward and fades over 1 second. Font size scales with combo level (28px base + 4px per combo, capped at 56px). Colors match weight types: blue for dumbbell, red for barbell, gold for kettlebell. Also shows "2x POWER!" for powerup collection and streak milestone labels ("ON FIRE!", "UNSTOPPABLE!", "LEGENDARY!", "GODLIKE!", "GIGACHAD!").
|
||||
- **`src/effects/ScreenEffects.js`** — Full-screen visual effects manager. Handles flash overlays (white on entrance, red on miss, green on powerup, gold on streak), camera FOV pulse (60 to 55 degrees on catch, 0.1s in / 0.2s out), directional light intensity pulse (+0.3 for 0.2s on catch/powerup/streak), hit freeze (60ms gameplay pause on damage), and combo-scaled screen shake (base 0.15 + combo * 0.03, capped at 0.5).
|
||||
|
||||
### Modified files
|
||||
- **`src/core/Constants.js`** — Added `EFFECTS` configuration object with 35+ tuning values: particle pool size, burst counts per event type (catch: 15, miss: 10, powerup: 20, streak: 40, entrance: 20), particle physics (size, speed, lifetime, gravity), ambient particle settings (count: 30, drift speed, size, opacity), floating text parameters (duration, rise, font sizes), flash overlay durations/alphas, hit freeze duration (60ms), FOV pulse amounts/timing, light pulse amount/duration, shockwave ring settings, and per-weight-type particle colors.
|
||||
- **`src/core/Game.js`** — Integrated all three effects systems. Creates `ParticleManager`, `FloatingText`, and `ScreenEffects` instances during construction. The animate loop now: (1) updates screen effects first to get freeze/shake state, (2) updates particles and floating text every frame (even during freeze for visual continuity), (3) skips gameplay updates during hit freeze, (4) applies screen shake offsets to camera position. Removed the old manual screen shake implementation (`_shakeTimer`, `_shakeIntensity`, `_triggerScreenShake()`, `PLAYER_HIT` listener) in favor of the ScreenEffects system.
|
||||
- **`src/level/LevelBuilder.js`** — Exposed the directional light as `this.dirLight` (was a local variable) so Game.js can pass it to ScreenEffects for the light pulse effect.
|
||||
|
||||
### Event-to-effect mapping
|
||||
| Event | Particles | Floating Text | Screen Effect |
|
||||
|-------|-----------|---------------|---------------|
|
||||
| `WEIGHT_CAUGHT` | 15-40 particles (color by type, count scales with combo) | "+N" score popup (color by type, size by combo) | FOV pulse + light pulse. White flash at combo >= 3 |
|
||||
| `WEIGHT_MISSED` | 10 red particles at floor + shockwave ring | - | Red flash + screen shake |
|
||||
| `POWERUP_COLLECTED` | 20 green spiral particles | "2x POWER!" centered text | Green flash + light pulse |
|
||||
| `SPECTACLE_COMBO` | 10 + combo*3 gold particles | - | Combo-scaled micro-shake |
|
||||
| `SPECTACLE_STREAK` | 40 orange particles + gold shockwave ring | Milestone label (ON FIRE!/UNSTOPPABLE!/etc.) | Gold flash + strong shake + light pulse |
|
||||
| `SPECTACLE_ENTRANCE` | 20 white particles (delayed 1s for landing) | - | White flash + small shake (delayed 1s) |
|
||||
| `PLAYER_HIT` | - | - | 60ms hit freeze |
|
||||
|
||||
### Ambient effects (always active)
|
||||
- 30 floating dust/chalk particles drifting slowly through the gym volume with sinusoidal motion
|
||||
- Particles wrap around gym boundaries for seamless loop
|
||||
- Opacity gently pulses over time
|
||||
|
||||
### Design decisions
|
||||
- All effects are non-blocking and degrade gracefully via try/catch
|
||||
- Particle pool is pre-allocated (zero GC during gameplay)
|
||||
- Ambient particles use separate THREE.Points instance (always rendered, not pooled)
|
||||
- Floating text uses CSS positioned divs (not Three.js sprites) for crisp rendering at all resolutions
|
||||
- Shockwave rings are individual meshes created and disposed per instance (low frequency events)
|
||||
- Hit freeze skips gameplay updates but continues rendering particles/effects for visual continuity
|
||||
- Screen shake is managed entirely by ScreenEffects (old manual shake removed from Game.js)
|
||||
- All magic numbers in Constants.js under the EFFECTS object
|
||||
|
||||
@@ -125,7 +125,7 @@ export const MODELS = {
|
||||
path: '/assets/models/gigachad.glb',
|
||||
walkPath: '/assets/models/gigachad-walk.glb',
|
||||
runPath: '/assets/models/gigachad-run.glb',
|
||||
scale: 2.0, // fills ~40% of screen height
|
||||
scale: 1.0, // model native 2u → 3u tall at PLAYER.HEIGHT
|
||||
rotationY: Math.PI, // Meshy models face +Z, flip to face camera
|
||||
},
|
||||
WEIGHTS: {
|
||||
@@ -156,3 +156,73 @@ export const SPECTACLE = {
|
||||
CATCH_SCALE_POP: 1.3, // scale player up briefly on catch
|
||||
CATCH_POP_DURATION: 0.2,
|
||||
};
|
||||
|
||||
// Visual effects configuration
|
||||
export const EFFECTS = {
|
||||
// Particle pool
|
||||
PARTICLE_POOL_SIZE: 200,
|
||||
|
||||
// Burst counts per event
|
||||
CATCH_PARTICLES: 15,
|
||||
MISS_PARTICLES: 10,
|
||||
POWERUP_PARTICLES: 20,
|
||||
STREAK_PARTICLES: 40,
|
||||
ENTRANCE_PARTICLES: 20,
|
||||
COMBO_BASE_PARTICLES: 10,
|
||||
COMBO_PARTICLE_GROWTH: 3, // extra particles per combo level
|
||||
|
||||
// Particle physics
|
||||
PARTICLE_SIZE: 0.15,
|
||||
PARTICLE_SPEED_MIN: 2,
|
||||
PARTICLE_SPEED_MAX: 6,
|
||||
PARTICLE_LIFETIME: 0.8,
|
||||
PARTICLE_GRAVITY: -8,
|
||||
|
||||
// Ambient particles (gym chalk / dust motes)
|
||||
AMBIENT_PARTICLE_COUNT: 30,
|
||||
AMBIENT_DRIFT_SPEED: 0.3,
|
||||
AMBIENT_SIZE: 0.06,
|
||||
AMBIENT_OPACITY: 0.25,
|
||||
|
||||
// Floating score text
|
||||
FLOAT_TEXT_DURATION: 1.0,
|
||||
FLOAT_TEXT_RISE: 2.0, // world units to rise
|
||||
FLOAT_TEXT_BASE_SIZE: 28, // px
|
||||
FLOAT_TEXT_COMBO_GROWTH: 4, // px per combo level
|
||||
FLOAT_TEXT_MAX_SIZE: 56, // px
|
||||
|
||||
// Flash overlay
|
||||
FLASH_DURATION: 0.3,
|
||||
FLASH_WHITE_ALPHA: 0.6,
|
||||
FLASH_RED_ALPHA: 0.4,
|
||||
FLASH_GREEN_ALPHA: 0.35,
|
||||
|
||||
// Hit freeze (brief pause for impact)
|
||||
FREEZE_DURATION: 0.06,
|
||||
|
||||
// Camera FOV pulse
|
||||
FOV_PULSE_AMOUNT: 5, // degrees to decrease
|
||||
FOV_PULSE_IN: 0.1, // seconds to zoom in
|
||||
FOV_PULSE_OUT: 0.2, // seconds to zoom out
|
||||
|
||||
// Light pulse
|
||||
LIGHT_PULSE_AMOUNT: 0.3,
|
||||
LIGHT_PULSE_DURATION: 0.2,
|
||||
|
||||
// Shockwave ring (expanding ring on floor)
|
||||
SHOCKWAVE_RADIUS_START: 0.5,
|
||||
SHOCKWAVE_RADIUS_END: 3.0,
|
||||
SHOCKWAVE_DURATION: 0.5,
|
||||
SHOCKWAVE_COLOR: 0xff4444,
|
||||
SHOCKWAVE_STREAK_COLOR: 0xffdd44,
|
||||
|
||||
// Weight type colors for particles
|
||||
WEIGHT_COLORS: {
|
||||
dumbbell: 0x4488ff,
|
||||
barbell: 0xff4444,
|
||||
kettlebell: 0xffd700,
|
||||
},
|
||||
POWERUP_COLOR: 0x44ff44,
|
||||
MISS_COLOR: 0xff2222,
|
||||
ENTRANCE_COLOR: 0xffffff,
|
||||
};
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
// Initializes all systems, manages the render loop, wires up EventBus.
|
||||
// Preloads all GLB models before starting gameplay.
|
||||
// Third-person camera behind and above GigaChad, fixed position.
|
||||
// Integrates visual effects: particles, floating text, screen effects.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
@@ -16,6 +17,9 @@ import { PowerupManager } from '../gameplay/PowerupManager.js';
|
||||
import { LevelBuilder } from '../level/LevelBuilder.js';
|
||||
import { Menu } from '../ui/Menu.js';
|
||||
import { preloadAll } from '../level/AssetLoader.js';
|
||||
import { ParticleManager } from '../effects/ParticleManager.js';
|
||||
import { FloatingText } from '../effects/FloatingText.js';
|
||||
import { ScreenEffects } from '../effects/ScreenEffects.js';
|
||||
|
||||
export class Game {
|
||||
constructor() {
|
||||
@@ -57,14 +61,21 @@ export class Game {
|
||||
this.weightManager = null;
|
||||
this.powerupManager = null;
|
||||
|
||||
// Screen shake state
|
||||
this._shakeTimer = 0;
|
||||
this._shakeIntensity = 0;
|
||||
// Camera base position for shake
|
||||
this._baseCameraPos = this.camera.position.clone();
|
||||
|
||||
// --- Visual effects systems ---
|
||||
// ParticleManager handles burst particles, ambient dust, and shockwaves
|
||||
this.particleManager = new ParticleManager(this.scene);
|
||||
|
||||
// FloatingText handles CSS-based "+N" score popups
|
||||
this.floatingText = new FloatingText(this.camera, this.renderer);
|
||||
|
||||
// ScreenEffects handles flash overlay, FOV pulse, light pulse, freeze, shake
|
||||
this.screenEffects = new ScreenEffects(this.camera, this.level.dirLight);
|
||||
|
||||
// Events
|
||||
eventBus.on(Events.GAME_RESTART, () => this.restart());
|
||||
eventBus.on(Events.PLAYER_HIT, () => this._triggerScreenShake());
|
||||
eventBus.on(Events.WEIGHT_CAUGHT, () => {
|
||||
if (this.player) this.player.triggerLift();
|
||||
});
|
||||
@@ -142,42 +153,39 @@ export class Game {
|
||||
animate() {
|
||||
const delta = Math.min(this.clock.getDelta(), GAME.MAX_DELTA);
|
||||
|
||||
this.input.update();
|
||||
// --- Update screen effects (flash, FOV pulse, light pulse, freeze, shake) ---
|
||||
const fx = this.screenEffects.update(delta);
|
||||
|
||||
if (gameState.started && !gameState.gameOver) {
|
||||
if (this.player) {
|
||||
this.player.update(delta, this.input);
|
||||
}
|
||||
// --- Update particles and floating text (always, even during freeze) ---
|
||||
this.particleManager.update(delta);
|
||||
this.floatingText.update(delta);
|
||||
|
||||
if (this.weightManager && this.player) {
|
||||
this.weightManager.update(delta, this.player.getPosition());
|
||||
}
|
||||
// --- Gameplay updates (skipped during hit freeze) ---
|
||||
if (!fx.frozen) {
|
||||
this.input.update();
|
||||
|
||||
if (this.powerupManager && this.player) {
|
||||
this.powerupManager.update(delta, this.player.getPosition());
|
||||
if (gameState.started && !gameState.gameOver) {
|
||||
if (this.player) {
|
||||
this.player.update(delta, this.input);
|
||||
}
|
||||
|
||||
if (this.weightManager && this.player) {
|
||||
this.weightManager.update(delta, this.player.getPosition());
|
||||
}
|
||||
|
||||
if (this.powerupManager && this.player) {
|
||||
this.powerupManager.update(delta, this.player.getPosition());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Screen shake
|
||||
if (this._shakeTimer > 0) {
|
||||
this._shakeTimer -= delta;
|
||||
const shakeX = (Math.random() - 0.5) * this._shakeIntensity * 2;
|
||||
const shakeY = (Math.random() - 0.5) * this._shakeIntensity * 2;
|
||||
this.camera.position.x = this._baseCameraPos.x + shakeX;
|
||||
this.camera.position.y = this._baseCameraPos.y + shakeY;
|
||||
} else {
|
||||
this.camera.position.x = this._baseCameraPos.x;
|
||||
this.camera.position.y = this._baseCameraPos.y;
|
||||
}
|
||||
// --- Apply screen shake from ScreenEffects ---
|
||||
this.camera.position.x = this._baseCameraPos.x + fx.shakeX;
|
||||
this.camera.position.y = this._baseCameraPos.y + fx.shakeY;
|
||||
|
||||
this.renderer.render(this.scene, this.camera);
|
||||
}
|
||||
|
||||
_triggerScreenShake() {
|
||||
this._shakeTimer = 0.2;
|
||||
this._shakeIntensity = 0.15;
|
||||
}
|
||||
|
||||
onResize() {
|
||||
this.camera.aspect = window.innerWidth / window.innerHeight;
|
||||
this.camera.updateProjectionMatrix();
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||
// =============================================================================
|
||||
// FloatingText.js — CSS-based floating score text
|
||||
// Projects 3D catch positions to screen coordinates and shows "+N" text
|
||||
// that floats upward and fades. Uses CSS transitions for smooth animation.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { EFFECTS } from '../core/Constants.js';
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
|
||||
const _vec3 = new THREE.Vector3();
|
||||
|
||||
// Text color per weight type
|
||||
const TEXT_COLORS = {
|
||||
dumbbell: '#4488ff',
|
||||
barbell: '#ff4444',
|
||||
kettlebell: '#ffd700',
|
||||
};
|
||||
|
||||
export class FloatingText {
|
||||
constructor(camera, renderer) {
|
||||
this._camera = camera;
|
||||
this._renderer = renderer;
|
||||
|
||||
// Container for floating text elements
|
||||
this._container = document.createElement('div');
|
||||
this._container.id = 'floating-text-container';
|
||||
this._container.style.cssText = `
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 12;
|
||||
overflow: hidden;
|
||||
`;
|
||||
document.body.appendChild(this._container);
|
||||
|
||||
// Active floating texts
|
||||
this._activeTexts = [];
|
||||
|
||||
// Wire events
|
||||
eventBus.on(Events.WEIGHT_CAUGHT, (data) => {
|
||||
try {
|
||||
this._spawnScoreText(data);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
|
||||
eventBus.on(Events.POWERUP_COLLECTED, (data) => {
|
||||
try {
|
||||
this._spawnPowerupText();
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
|
||||
eventBus.on(Events.SPECTACLE_STREAK, ({ combo }) => {
|
||||
try {
|
||||
this._spawnStreakText(combo);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
}
|
||||
|
||||
_spawnScoreText(data) {
|
||||
const { type, points, combo, x } = data;
|
||||
|
||||
// Project 3D position to screen coordinates
|
||||
_vec3.set(x, 3.0, 0);
|
||||
_vec3.project(this._camera);
|
||||
|
||||
const canvas = this._renderer.domElement;
|
||||
const screenX = ((_vec3.x + 1) / 2) * canvas.clientWidth;
|
||||
const screenY = ((1 - _vec3.y) / 2) * canvas.clientHeight;
|
||||
|
||||
// Determine font size based on combo
|
||||
const comboLevel = combo || 0;
|
||||
const fontSize = Math.min(
|
||||
EFFECTS.FLOAT_TEXT_BASE_SIZE + comboLevel * EFFECTS.FLOAT_TEXT_COMBO_GROWTH,
|
||||
EFFECTS.FLOAT_TEXT_MAX_SIZE
|
||||
);
|
||||
|
||||
const color = TEXT_COLORS[type] || '#ffffff';
|
||||
|
||||
// Build text content
|
||||
let text = `+${points}`;
|
||||
if (comboLevel > 2) {
|
||||
text += ` ${comboLevel}x`;
|
||||
}
|
||||
|
||||
this._createFloatingElement(text, screenX, screenY, fontSize, color);
|
||||
}
|
||||
|
||||
_spawnPowerupText() {
|
||||
const canvas = this._renderer.domElement;
|
||||
const screenX = canvas.clientWidth / 2;
|
||||
const screenY = canvas.clientHeight * 0.35;
|
||||
|
||||
this._createFloatingElement('2x POWER!', screenX, screenY, 36, '#44ff44');
|
||||
}
|
||||
|
||||
_spawnStreakText(combo) {
|
||||
const canvas = this._renderer.domElement;
|
||||
const screenX = canvas.clientWidth / 2;
|
||||
const screenY = canvas.clientHeight * 0.3;
|
||||
|
||||
const labels = { 5: 'ON FIRE!', 10: 'UNSTOPPABLE!', 25: 'LEGENDARY!', 50: 'GODLIKE!', 100: 'GIGACHAD!' };
|
||||
const label = labels[combo] || `${combo}x STREAK`;
|
||||
|
||||
this._createFloatingElement(label, screenX, screenY, 48, '#ff8844');
|
||||
}
|
||||
|
||||
_createFloatingElement(text, x, y, fontSize, color) {
|
||||
const el = document.createElement('div');
|
||||
el.textContent = text;
|
||||
el.style.cssText = `
|
||||
position: absolute;
|
||||
left: ${x}px;
|
||||
top: ${y}px;
|
||||
transform: translate(-50%, -50%) scale(1.4);
|
||||
font-family: 'Arial Black', 'Impact', system-ui, sans-serif;
|
||||
font-size: ${fontSize}px;
|
||||
font-weight: 900;
|
||||
color: ${color};
|
||||
text-shadow:
|
||||
0 0 8px ${color},
|
||||
2px 2px 0 rgba(0,0,0,0.8),
|
||||
-1px -1px 0 rgba(0,0,0,0.5);
|
||||
pointer-events: none;
|
||||
opacity: 1;
|
||||
transition:
|
||||
transform ${EFFECTS.FLOAT_TEXT_DURATION}s ease-out,
|
||||
opacity ${EFFECTS.FLOAT_TEXT_DURATION * 0.6}s ease-in ${EFFECTS.FLOAT_TEXT_DURATION * 0.4}s;
|
||||
white-space: nowrap;
|
||||
letter-spacing: 1px;
|
||||
`;
|
||||
|
||||
this._container.appendChild(el);
|
||||
|
||||
// Trigger animation on next frame
|
||||
requestAnimationFrame(() => {
|
||||
// Convert world-space rise to approximate screen pixels
|
||||
const risePixels = EFFECTS.FLOAT_TEXT_RISE * 40;
|
||||
el.style.transform = `translate(-50%, -50%) translateY(-${risePixels}px) scale(1)`;
|
||||
el.style.opacity = '0';
|
||||
});
|
||||
|
||||
// Remove after animation completes
|
||||
const entry = { el, timer: EFFECTS.FLOAT_TEXT_DURATION + 0.1 };
|
||||
this._activeTexts.push(entry);
|
||||
}
|
||||
|
||||
update(delta) {
|
||||
for (let i = this._activeTexts.length - 1; i >= 0; i--) {
|
||||
this._activeTexts[i].timer -= delta;
|
||||
if (this._activeTexts[i].timer <= 0) {
|
||||
const el = this._activeTexts[i].el;
|
||||
if (el.parentNode) el.parentNode.removeChild(el);
|
||||
this._activeTexts.splice(i, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
destroy() {
|
||||
for (const entry of this._activeTexts) {
|
||||
if (entry.el.parentNode) entry.el.parentNode.removeChild(entry.el);
|
||||
}
|
||||
this._activeTexts = [];
|
||||
if (this._container.parentNode) {
|
||||
this._container.parentNode.removeChild(this._container);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,437 @@
|
||||
// =============================================================================
|
||||
// ParticleManager.js — GPU particle system using THREE.Points
|
||||
// Pre-allocated pool of particles for zero-GC bursts on game events.
|
||||
// Also manages ambient floating dust/chalk particles.
|
||||
// =============================================================================
|
||||
|
||||
import * as THREE from 'three';
|
||||
import { EFFECTS, ARENA } from '../core/Constants.js';
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
|
||||
// Particle states
|
||||
const DEAD = 0;
|
||||
const ALIVE = 1;
|
||||
|
||||
export class ParticleManager {
|
||||
constructor(scene) {
|
||||
this.scene = scene;
|
||||
|
||||
// --- Burst particle pool (THREE.Points) ---
|
||||
this._poolSize = EFFECTS.PARTICLE_POOL_SIZE;
|
||||
|
||||
// Per-particle data arrays
|
||||
this._states = new Float32Array(this._poolSize); // 0=dead, 1=alive
|
||||
this._lifetimes = new Float32Array(this._poolSize); // remaining life
|
||||
this._maxLifetimes = new Float32Array(this._poolSize); // total lifetime
|
||||
this._velocities = new Float32Array(this._poolSize * 3);
|
||||
|
||||
// Geometry
|
||||
const positions = new Float32Array(this._poolSize * 3);
|
||||
const colors = new Float32Array(this._poolSize * 3);
|
||||
const sizes = new Float32Array(this._poolSize);
|
||||
|
||||
// Hide all particles off-screen initially
|
||||
for (let i = 0; i < this._poolSize; i++) {
|
||||
positions[i * 3] = 0;
|
||||
positions[i * 3 + 1] = -100;
|
||||
positions[i * 3 + 2] = 0;
|
||||
colors[i * 3] = 1;
|
||||
colors[i * 3 + 1] = 1;
|
||||
colors[i * 3 + 2] = 1;
|
||||
sizes[i] = EFFECTS.PARTICLE_SIZE;
|
||||
this._states[i] = DEAD;
|
||||
}
|
||||
|
||||
this._geometry = new THREE.BufferGeometry();
|
||||
this._geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
this._geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3));
|
||||
this._geometry.setAttribute('size', new THREE.BufferAttribute(sizes, 1));
|
||||
|
||||
// Material: additive blending for glow effect
|
||||
this._material = new THREE.PointsMaterial({
|
||||
size: EFFECTS.PARTICLE_SIZE,
|
||||
vertexColors: true,
|
||||
transparent: true,
|
||||
opacity: 1.0,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
this._points = new THREE.Points(this._geometry, this._material);
|
||||
this._points.frustumCulled = false;
|
||||
this.scene.add(this._points);
|
||||
|
||||
// Next available index for pool allocation
|
||||
this._nextIndex = 0;
|
||||
|
||||
// --- Shockwave rings ---
|
||||
this._shockwaves = [];
|
||||
|
||||
// --- Ambient particles (dust/chalk motes) ---
|
||||
this._setupAmbientParticles();
|
||||
|
||||
// --- Wire events ---
|
||||
this._wireEvents();
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Ambient particles — always-on drifting dust motes
|
||||
// =========================================================================
|
||||
|
||||
_setupAmbientParticles() {
|
||||
const count = EFFECTS.AMBIENT_PARTICLE_COUNT;
|
||||
const positions = new Float32Array(count * 3);
|
||||
const opacities = new Float32Array(count);
|
||||
this._ambientVelocities = new Float32Array(count * 3);
|
||||
this._ambientPhases = new Float32Array(count);
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
// Random position within the gym volume
|
||||
positions[i * 3] = (Math.random() - 0.5) * ARENA.WIDTH;
|
||||
positions[i * 3 + 1] = Math.random() * 14 + 1;
|
||||
positions[i * 3 + 2] = (Math.random() - 0.5) * ARENA.DEPTH;
|
||||
|
||||
// Slow drift velocity
|
||||
this._ambientVelocities[i * 3] = (Math.random() - 0.5) * EFFECTS.AMBIENT_DRIFT_SPEED;
|
||||
this._ambientVelocities[i * 3 + 1] = (Math.random() - 0.5) * EFFECTS.AMBIENT_DRIFT_SPEED * 0.5;
|
||||
this._ambientVelocities[i * 3 + 2] = (Math.random() - 0.5) * EFFECTS.AMBIENT_DRIFT_SPEED;
|
||||
|
||||
this._ambientPhases[i] = Math.random() * Math.PI * 2;
|
||||
opacities[i] = EFFECTS.AMBIENT_OPACITY * (0.5 + Math.random() * 0.5);
|
||||
}
|
||||
|
||||
const ambientGeo = new THREE.BufferGeometry();
|
||||
ambientGeo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
|
||||
|
||||
const ambientMat = new THREE.PointsMaterial({
|
||||
size: EFFECTS.AMBIENT_SIZE,
|
||||
color: 0xccccaa,
|
||||
transparent: true,
|
||||
opacity: EFFECTS.AMBIENT_OPACITY,
|
||||
blending: THREE.AdditiveBlending,
|
||||
depthWrite: false,
|
||||
sizeAttenuation: true,
|
||||
});
|
||||
|
||||
this._ambientPoints = new THREE.Points(ambientGeo, ambientMat);
|
||||
this._ambientPoints.frustumCulled = false;
|
||||
this.scene.add(this._ambientPoints);
|
||||
this._ambientTime = 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Event wiring
|
||||
// =========================================================================
|
||||
|
||||
_wireEvents() {
|
||||
eventBus.on(Events.WEIGHT_CAUGHT, (data) => {
|
||||
try {
|
||||
const color = EFFECTS.WEIGHT_COLORS[data.type] || 0xffffff;
|
||||
const combo = data.combo || 0;
|
||||
const count = EFFECTS.CATCH_PARTICLES + Math.min(combo, 10) * EFFECTS.COMBO_PARTICLE_GROWTH;
|
||||
this.burst(
|
||||
new THREE.Vector3(data.x, 2.5, 0),
|
||||
Math.min(count, 40),
|
||||
color,
|
||||
EFFECTS.PARTICLE_SPEED_MAX
|
||||
);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
|
||||
eventBus.on(Events.WEIGHT_MISSED, (data) => {
|
||||
try {
|
||||
this.burst(
|
||||
new THREE.Vector3(data.x, 0.2, 0),
|
||||
EFFECTS.MISS_PARTICLES,
|
||||
EFFECTS.MISS_COLOR,
|
||||
EFFECTS.PARTICLE_SPEED_MIN + 1
|
||||
);
|
||||
this._spawnShockwave(data.x, EFFECTS.SHOCKWAVE_COLOR);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
|
||||
eventBus.on(Events.POWERUP_COLLECTED, () => {
|
||||
try {
|
||||
// Spiral burst in green
|
||||
this.burst(
|
||||
new THREE.Vector3(0, 3, 0),
|
||||
EFFECTS.POWERUP_PARTICLES,
|
||||
EFFECTS.POWERUP_COLOR,
|
||||
EFFECTS.PARTICLE_SPEED_MAX,
|
||||
true // spiral mode
|
||||
);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
|
||||
eventBus.on(Events.SPECTACLE_COMBO, ({ combo }) => {
|
||||
try {
|
||||
const count = EFFECTS.COMBO_BASE_PARTICLES + combo * EFFECTS.COMBO_PARTICLE_GROWTH;
|
||||
this.burst(
|
||||
new THREE.Vector3(0, 4, 0),
|
||||
Math.min(count, 35),
|
||||
0xffdd44,
|
||||
EFFECTS.PARTICLE_SPEED_MAX
|
||||
);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
|
||||
eventBus.on(Events.SPECTACLE_STREAK, ({ combo }) => {
|
||||
try {
|
||||
this.burst(
|
||||
new THREE.Vector3(0, 3, 0),
|
||||
EFFECTS.STREAK_PARTICLES,
|
||||
0xff8844,
|
||||
EFFECTS.PARTICLE_SPEED_MAX * 1.5
|
||||
);
|
||||
this._spawnShockwave(0, EFFECTS.SHOCKWAVE_STREAK_COLOR);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
|
||||
eventBus.on(Events.SPECTACLE_ENTRANCE, () => {
|
||||
try {
|
||||
// Delayed particle burst (fires after GigaChad lands)
|
||||
setTimeout(() => {
|
||||
this.burst(
|
||||
new THREE.Vector3(0, 0.5, 0),
|
||||
EFFECTS.ENTRANCE_PARTICLES,
|
||||
EFFECTS.ENTRANCE_COLOR,
|
||||
EFFECTS.PARTICLE_SPEED_MAX
|
||||
);
|
||||
}, 1000);
|
||||
} catch (e) { /* graceful degradation */ }
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Burst — emit N particles at a position
|
||||
// =========================================================================
|
||||
|
||||
burst(position, count, colorHex, speed, spiral = false) {
|
||||
const posAttr = this._geometry.getAttribute('position');
|
||||
const colAttr = this._geometry.getAttribute('color');
|
||||
const sizeAttr = this._geometry.getAttribute('size');
|
||||
|
||||
const color = new THREE.Color(colorHex);
|
||||
|
||||
for (let n = 0; n < count; n++) {
|
||||
const i = this._acquireParticle();
|
||||
if (i === -1) break; // pool exhausted
|
||||
|
||||
// Position
|
||||
posAttr.array[i * 3] = position.x + (Math.random() - 0.5) * 0.3;
|
||||
posAttr.array[i * 3 + 1] = position.y + (Math.random() - 0.5) * 0.3;
|
||||
posAttr.array[i * 3 + 2] = position.z + (Math.random() - 0.5) * 0.3;
|
||||
|
||||
// Color with slight variation
|
||||
colAttr.array[i * 3] = Math.min(1, color.r + (Math.random() - 0.5) * 0.2);
|
||||
colAttr.array[i * 3 + 1] = Math.min(1, color.g + (Math.random() - 0.5) * 0.2);
|
||||
colAttr.array[i * 3 + 2] = Math.min(1, color.b + (Math.random() - 0.5) * 0.2);
|
||||
|
||||
// Size variation
|
||||
sizeAttr.array[i] = EFFECTS.PARTICLE_SIZE * (0.5 + Math.random() * 1.0);
|
||||
|
||||
// Velocity
|
||||
let vx, vy, vz;
|
||||
if (spiral) {
|
||||
const angle = (n / count) * Math.PI * 4;
|
||||
const r = speed * (0.5 + Math.random() * 0.5);
|
||||
vx = Math.cos(angle) * r;
|
||||
vy = speed * (0.5 + Math.random());
|
||||
vz = Math.sin(angle) * r * 0.5;
|
||||
} else {
|
||||
// Random spherical burst
|
||||
const theta = Math.random() * Math.PI * 2;
|
||||
const phi = Math.random() * Math.PI;
|
||||
const r = speed * (0.3 + Math.random() * 0.7);
|
||||
vx = Math.sin(phi) * Math.cos(theta) * r;
|
||||
vy = Math.abs(Math.cos(phi) * r) + 1; // bias upward
|
||||
vz = Math.sin(phi) * Math.sin(theta) * r * 0.5;
|
||||
}
|
||||
|
||||
this._velocities[i * 3] = vx;
|
||||
this._velocities[i * 3 + 1] = vy;
|
||||
this._velocities[i * 3 + 2] = vz;
|
||||
|
||||
const lifetime = EFFECTS.PARTICLE_LIFETIME * (0.5 + Math.random() * 0.5);
|
||||
this._lifetimes[i] = lifetime;
|
||||
this._maxLifetimes[i] = lifetime;
|
||||
this._states[i] = ALIVE;
|
||||
}
|
||||
|
||||
posAttr.needsUpdate = true;
|
||||
colAttr.needsUpdate = true;
|
||||
sizeAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Shockwave ring on floor
|
||||
// =========================================================================
|
||||
|
||||
_spawnShockwave(x, colorHex) {
|
||||
const geometry = new THREE.RingGeometry(
|
||||
EFFECTS.SHOCKWAVE_RADIUS_START,
|
||||
EFFECTS.SHOCKWAVE_RADIUS_START + 0.1,
|
||||
32
|
||||
);
|
||||
const material = new THREE.MeshBasicMaterial({
|
||||
color: colorHex,
|
||||
transparent: true,
|
||||
opacity: 0.8,
|
||||
side: THREE.DoubleSide,
|
||||
depthWrite: false,
|
||||
});
|
||||
const ring = new THREE.Mesh(geometry, material);
|
||||
ring.rotation.x = -Math.PI / 2;
|
||||
ring.position.set(x, 0.05, 0);
|
||||
this.scene.add(ring);
|
||||
|
||||
this._shockwaves.push({
|
||||
mesh: ring,
|
||||
time: 0,
|
||||
duration: EFFECTS.SHOCKWAVE_DURATION,
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Pool management
|
||||
// =========================================================================
|
||||
|
||||
_acquireParticle() {
|
||||
// Search from nextIndex for a dead particle
|
||||
for (let attempts = 0; attempts < this._poolSize; attempts++) {
|
||||
const i = (this._nextIndex + attempts) % this._poolSize;
|
||||
if (this._states[i] === DEAD) {
|
||||
this._nextIndex = (i + 1) % this._poolSize;
|
||||
return i;
|
||||
}
|
||||
}
|
||||
return -1; // pool fully allocated
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Update — called every frame from Game.js animate loop
|
||||
// =========================================================================
|
||||
|
||||
update(delta) {
|
||||
this._updateBurstParticles(delta);
|
||||
this._updateAmbientParticles(delta);
|
||||
this._updateShockwaves(delta);
|
||||
}
|
||||
|
||||
_updateBurstParticles(delta) {
|
||||
const posAttr = this._geometry.getAttribute('position');
|
||||
const sizeAttr = this._geometry.getAttribute('size');
|
||||
let anyAlive = false;
|
||||
|
||||
for (let i = 0; i < this._poolSize; i++) {
|
||||
if (this._states[i] !== ALIVE) continue;
|
||||
anyAlive = true;
|
||||
|
||||
this._lifetimes[i] -= delta;
|
||||
if (this._lifetimes[i] <= 0) {
|
||||
// Kill particle — move off-screen
|
||||
this._states[i] = DEAD;
|
||||
posAttr.array[i * 3 + 1] = -100;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Apply velocity + gravity
|
||||
const vi = i * 3;
|
||||
this._velocities[vi + 1] += EFFECTS.PARTICLE_GRAVITY * delta;
|
||||
|
||||
posAttr.array[vi] += this._velocities[vi] * delta;
|
||||
posAttr.array[vi + 1] += this._velocities[vi + 1] * delta;
|
||||
posAttr.array[vi + 2] += this._velocities[vi + 2] * delta;
|
||||
|
||||
// Floor bounce (don't let particles go below floor)
|
||||
if (posAttr.array[vi + 1] < 0.05) {
|
||||
posAttr.array[vi + 1] = 0.05;
|
||||
this._velocities[vi + 1] *= -0.3;
|
||||
}
|
||||
|
||||
// Fade out via size reduction (cheaper than per-particle alpha)
|
||||
const lifeFrac = this._lifetimes[i] / this._maxLifetimes[i];
|
||||
sizeAttr.array[i] = EFFECTS.PARTICLE_SIZE * lifeFrac * (0.5 + Math.random() * 0.1);
|
||||
}
|
||||
|
||||
if (anyAlive) {
|
||||
posAttr.needsUpdate = true;
|
||||
sizeAttr.needsUpdate = true;
|
||||
}
|
||||
}
|
||||
|
||||
_updateAmbientParticles(delta) {
|
||||
this._ambientTime += delta;
|
||||
const posAttr = this._ambientPoints.geometry.getAttribute('position');
|
||||
const count = EFFECTS.AMBIENT_PARTICLE_COUNT;
|
||||
|
||||
for (let i = 0; i < count; i++) {
|
||||
const vi = i * 3;
|
||||
const phase = this._ambientPhases[i];
|
||||
|
||||
// Gentle sinusoidal drift
|
||||
posAttr.array[vi] += this._ambientVelocities[vi] * delta + Math.sin(this._ambientTime * 0.5 + phase) * 0.002;
|
||||
posAttr.array[vi + 1] += this._ambientVelocities[vi + 1] * delta + Math.cos(this._ambientTime * 0.3 + phase) * 0.001;
|
||||
posAttr.array[vi + 2] += this._ambientVelocities[vi + 2] * delta;
|
||||
|
||||
// Wrap around gym boundaries
|
||||
if (posAttr.array[vi] > ARENA.HALF_WIDTH + 1) posAttr.array[vi] = -ARENA.HALF_WIDTH - 1;
|
||||
if (posAttr.array[vi] < -ARENA.HALF_WIDTH - 1) posAttr.array[vi] = ARENA.HALF_WIDTH + 1;
|
||||
if (posAttr.array[vi + 1] > 16) posAttr.array[vi + 1] = 1;
|
||||
if (posAttr.array[vi + 1] < 0.5) posAttr.array[vi + 1] = 14;
|
||||
if (posAttr.array[vi + 2] > ARENA.DEPTH / 2 + 1) posAttr.array[vi + 2] = -ARENA.DEPTH / 2;
|
||||
if (posAttr.array[vi + 2] < -ARENA.DEPTH / 2 - 1) posAttr.array[vi + 2] = ARENA.DEPTH / 2;
|
||||
}
|
||||
|
||||
// Pulse ambient opacity gently
|
||||
this._ambientPoints.material.opacity = EFFECTS.AMBIENT_OPACITY + Math.sin(this._ambientTime * 0.8) * 0.05;
|
||||
|
||||
posAttr.needsUpdate = true;
|
||||
}
|
||||
|
||||
_updateShockwaves(delta) {
|
||||
for (let i = this._shockwaves.length - 1; i >= 0; i--) {
|
||||
const sw = this._shockwaves[i];
|
||||
sw.time += delta;
|
||||
const t = sw.time / sw.duration;
|
||||
|
||||
if (t >= 1) {
|
||||
this.scene.remove(sw.mesh);
|
||||
sw.mesh.geometry.dispose();
|
||||
sw.mesh.material.dispose();
|
||||
this._shockwaves.splice(i, 1);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Expand ring
|
||||
const radius = EFFECTS.SHOCKWAVE_RADIUS_START +
|
||||
(EFFECTS.SHOCKWAVE_RADIUS_END - EFFECTS.SHOCKWAVE_RADIUS_START) * t;
|
||||
sw.mesh.scale.setScalar(radius / EFFECTS.SHOCKWAVE_RADIUS_START);
|
||||
|
||||
// Fade out
|
||||
sw.mesh.material.opacity = 0.8 * (1 - t);
|
||||
}
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Cleanup
|
||||
// =========================================================================
|
||||
|
||||
destroy() {
|
||||
this.scene.remove(this._points);
|
||||
this._geometry.dispose();
|
||||
this._material.dispose();
|
||||
|
||||
this.scene.remove(this._ambientPoints);
|
||||
this._ambientPoints.geometry.dispose();
|
||||
this._ambientPoints.material.dispose();
|
||||
|
||||
for (const sw of this._shockwaves) {
|
||||
this.scene.remove(sw.mesh);
|
||||
sw.mesh.geometry.dispose();
|
||||
sw.mesh.material.dispose();
|
||||
}
|
||||
this._shockwaves = [];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
// =============================================================================
|
||||
// ScreenEffects.js — Full-screen visual effects
|
||||
// Flash overlay, camera FOV pulse, hit freeze, light pulse, screen shake.
|
||||
// All effects are non-blocking and degrade gracefully.
|
||||
// =============================================================================
|
||||
|
||||
import { EFFECTS, CAMERA, SPECTACLE } from '../core/Constants.js';
|
||||
import { eventBus, Events } from '../core/EventBus.js';
|
||||
|
||||
export class ScreenEffects {
|
||||
constructor(camera, dirLight) {
|
||||
this._camera = camera;
|
||||
this._dirLight = dirLight;
|
||||
|
||||
// --- Flash overlay (HTML div) ---
|
||||
this._flashEl = document.createElement('div');
|
||||
this._flashEl.id = 'screen-flash';
|
||||
this._flashEl.style.cssText = `
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
z-index: 11;
|
||||
opacity: 0;
|
||||
transition: none;
|
||||
`;
|
||||
document.body.appendChild(this._flashEl);
|
||||
|
||||
// Flash state
|
||||
this._flashTimer = 0;
|
||||
this._flashDuration = 0;
|
||||
this._flashAlpha = 0;
|
||||
|
||||
// --- FOV pulse state ---
|
||||
this._baseFOV = CAMERA.FOV;
|
||||
this._fovPulseTimer = 0;
|
||||
this._fovPulsePhase = 'none'; // 'in', 'out', 'none'
|
||||
|
||||
// --- Light pulse state ---
|
||||
this._baseLightIntensity = dirLight ? dirLight.intensity : 0.9;
|
||||
this._lightPulseTimer = 0;
|
||||
|
||||
// --- Hit freeze state ---
|
||||
this._freezeTimer = 0;
|
||||
this._isFrozen = false;
|
||||
|
||||
// --- Enhanced screen shake state ---
|
||||
this._shakeTimer = 0;
|
||||
this._shakeIntensity = 0;
|
||||
|
||||
// --- Wire events ---
|
||||
this._wireEvents();
|
||||
}
|
||||
|
||||
_wireEvents() {
|
||||
// White flash on entrance (delayed to match landing)
|
||||
eventBus.on(Events.SPECTACLE_ENTRANCE, () => {
|
||||
setTimeout(() => {
|
||||
this._triggerFlash('#ffffff', EFFECTS.FLASH_WHITE_ALPHA, EFFECTS.FLASH_DURATION);
|
||||
this._triggerShake(0.012, 0.15);
|
||||
}, 1000);
|
||||
});
|
||||
|
||||
// Color flash on weight catch + FOV pulse + light pulse
|
||||
eventBus.on(Events.WEIGHT_CAUGHT, (data) => {
|
||||
const combo = data.combo || 0;
|
||||
this._triggerFOVPulse();
|
||||
this._triggerLightPulse();
|
||||
|
||||
// Subtle white flash on catch, increasing with combo
|
||||
if (combo >= 3) {
|
||||
const alpha = Math.min(0.15 + combo * 0.02, 0.35);
|
||||
this._triggerFlash('#ffffff', alpha, 0.15);
|
||||
}
|
||||
});
|
||||
|
||||
// Red flash + stronger shake on miss
|
||||
eventBus.on(Events.WEIGHT_MISSED, () => {
|
||||
this._triggerFlash('#ff0000', EFFECTS.FLASH_RED_ALPHA, EFFECTS.FLASH_DURATION);
|
||||
this._triggerShake(SPECTACLE.SCREEN_SHAKE_INTENSITY, SPECTACLE.SCREEN_SHAKE_DURATION);
|
||||
});
|
||||
|
||||
// Green flash on powerup collect
|
||||
eventBus.on(Events.POWERUP_COLLECTED, () => {
|
||||
this._triggerFlash('#00ff44', EFFECTS.FLASH_GREEN_ALPHA, EFFECTS.FLASH_DURATION * 1.2);
|
||||
this._triggerLightPulse();
|
||||
});
|
||||
|
||||
// Hit freeze on player damage
|
||||
eventBus.on(Events.PLAYER_HIT, () => {
|
||||
this._triggerFreeze();
|
||||
});
|
||||
|
||||
// Enhanced shake on streak
|
||||
eventBus.on(Events.SPECTACLE_STREAK, ({ combo }) => {
|
||||
const intensity = Math.min(0.15 + combo * 0.01, 0.4);
|
||||
this._triggerShake(intensity, 0.35);
|
||||
this._triggerFlash('#ffdd44', 0.3, 0.4);
|
||||
this._triggerLightPulse();
|
||||
});
|
||||
|
||||
// Combo-scaled effects
|
||||
eventBus.on(Events.SPECTACLE_COMBO, ({ combo }) => {
|
||||
const shakeIntensity = Math.min(
|
||||
SPECTACLE.SCREEN_SHAKE_INTENSITY + combo * 0.03,
|
||||
0.5
|
||||
);
|
||||
this._triggerShake(shakeIntensity * 0.3, 0.1);
|
||||
});
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Flash overlay
|
||||
// =========================================================================
|
||||
|
||||
_triggerFlash(color, alpha, duration) {
|
||||
this._flashEl.style.background = color;
|
||||
this._flashEl.style.opacity = String(alpha);
|
||||
this._flashTimer = duration;
|
||||
this._flashDuration = duration;
|
||||
this._flashAlpha = alpha;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// FOV pulse (camera zoom in/out)
|
||||
// =========================================================================
|
||||
|
||||
_triggerFOVPulse() {
|
||||
this._fovPulsePhase = 'in';
|
||||
this._fovPulseTimer = 0;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Light pulse
|
||||
// =========================================================================
|
||||
|
||||
_triggerLightPulse() {
|
||||
if (!this._dirLight) return;
|
||||
this._lightPulseTimer = EFFECTS.LIGHT_PULSE_DURATION;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Hit freeze
|
||||
// =========================================================================
|
||||
|
||||
_triggerFreeze() {
|
||||
this._freezeTimer = EFFECTS.FREEZE_DURATION;
|
||||
this._isFrozen = true;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Screen shake (enhanced, combo-scaled)
|
||||
// =========================================================================
|
||||
|
||||
_triggerShake(intensity, duration) {
|
||||
// Take the stronger shake if one is already active
|
||||
if (this._shakeTimer > 0 && this._shakeIntensity > intensity) return;
|
||||
this._shakeIntensity = intensity;
|
||||
this._shakeTimer = duration;
|
||||
}
|
||||
|
||||
// =========================================================================
|
||||
// Update — called every frame from Game.js
|
||||
// Returns { frozen, shakeX, shakeY } for the game loop to apply
|
||||
// =========================================================================
|
||||
|
||||
update(delta) {
|
||||
const result = {
|
||||
frozen: false,
|
||||
shakeX: 0,
|
||||
shakeY: 0,
|
||||
};
|
||||
|
||||
// --- Hit freeze ---
|
||||
if (this._isFrozen) {
|
||||
this._freezeTimer -= delta;
|
||||
if (this._freezeTimer <= 0) {
|
||||
this._isFrozen = false;
|
||||
} else {
|
||||
result.frozen = true;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Flash overlay fade ---
|
||||
if (this._flashTimer > 0) {
|
||||
this._flashTimer -= delta;
|
||||
const t = Math.max(0, this._flashTimer / this._flashDuration);
|
||||
this._flashEl.style.opacity = String(this._flashAlpha * t);
|
||||
if (this._flashTimer <= 0) {
|
||||
this._flashEl.style.opacity = '0';
|
||||
}
|
||||
}
|
||||
|
||||
// --- FOV pulse ---
|
||||
if (this._fovPulsePhase === 'in') {
|
||||
this._fovPulseTimer += delta;
|
||||
const t = Math.min(this._fovPulseTimer / EFFECTS.FOV_PULSE_IN, 1);
|
||||
this._camera.fov = this._baseFOV - EFFECTS.FOV_PULSE_AMOUNT * t;
|
||||
this._camera.updateProjectionMatrix();
|
||||
if (t >= 1) {
|
||||
this._fovPulsePhase = 'out';
|
||||
this._fovPulseTimer = 0;
|
||||
}
|
||||
} else if (this._fovPulsePhase === 'out') {
|
||||
this._fovPulseTimer += delta;
|
||||
const t = Math.min(this._fovPulseTimer / EFFECTS.FOV_PULSE_OUT, 1);
|
||||
// Ease out
|
||||
const eased = 1 - Math.pow(1 - t, 2);
|
||||
this._camera.fov = (this._baseFOV - EFFECTS.FOV_PULSE_AMOUNT) + EFFECTS.FOV_PULSE_AMOUNT * eased;
|
||||
this._camera.updateProjectionMatrix();
|
||||
if (t >= 1) {
|
||||
this._fovPulsePhase = 'none';
|
||||
this._camera.fov = this._baseFOV;
|
||||
this._camera.updateProjectionMatrix();
|
||||
}
|
||||
}
|
||||
|
||||
// --- Light pulse ---
|
||||
if (this._lightPulseTimer > 0 && this._dirLight) {
|
||||
this._lightPulseTimer -= delta;
|
||||
const t = Math.max(0, this._lightPulseTimer / EFFECTS.LIGHT_PULSE_DURATION);
|
||||
this._dirLight.intensity = this._baseLightIntensity + EFFECTS.LIGHT_PULSE_AMOUNT * t;
|
||||
if (this._lightPulseTimer <= 0) {
|
||||
this._dirLight.intensity = this._baseLightIntensity;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Screen shake ---
|
||||
if (this._shakeTimer > 0) {
|
||||
this._shakeTimer -= delta;
|
||||
result.shakeX = (Math.random() - 0.5) * this._shakeIntensity * 2;
|
||||
result.shakeY = (Math.random() - 0.5) * this._shakeIntensity * 2;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
destroy() {
|
||||
if (this._flashEl.parentNode) {
|
||||
this._flashEl.parentNode.removeChild(this._flashEl);
|
||||
}
|
||||
// Reset camera FOV
|
||||
this._camera.fov = this._baseFOV;
|
||||
this._camera.updateProjectionMatrix();
|
||||
// Reset light
|
||||
if (this._dirLight) {
|
||||
this._dirLight.intensity = this._baseLightIntensity;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -65,26 +65,51 @@ export class Player {
|
||||
const { model, clips } = await loadAnimatedModel(cfg.path);
|
||||
console.log('GigaChad base clips:', clips.map(c => c.name));
|
||||
|
||||
// Compute bounding box to determine model size
|
||||
const bbox = new THREE.Box3().setFromObject(model);
|
||||
const size = new THREE.Vector3();
|
||||
bbox.getSize(size);
|
||||
console.log('GigaChad bounding box size:', size.x.toFixed(2), size.y.toFixed(2), size.z.toFixed(2));
|
||||
// SkinnedMesh bounding boxes are unreliable (bind-pose vertices near origin).
|
||||
// Instead, compute bounds from the raw geometry positions of all child meshes.
|
||||
let minY = Infinity, maxY = -Infinity;
|
||||
model.traverse((child) => {
|
||||
if (child.isMesh && child.geometry) {
|
||||
child.geometry.computeBoundingBox();
|
||||
const gb = child.geometry.boundingBox;
|
||||
if (gb) {
|
||||
// Account for the child's local position offset
|
||||
minY = Math.min(minY, gb.min.y + child.position.y);
|
||||
maxY = Math.max(maxY, gb.max.y + child.position.y);
|
||||
}
|
||||
}
|
||||
});
|
||||
const geoHeight = maxY - minY;
|
||||
console.log('GigaChad geometry height:', geoHeight.toFixed(3), 'minY:', minY.toFixed(3));
|
||||
|
||||
// Scale model to target height (~PLAYER.HEIGHT)
|
||||
// Scale to target height
|
||||
const targetHeight = PLAYER.HEIGHT;
|
||||
const currentHeight = size.y;
|
||||
const scaleFactor = (targetHeight / currentHeight) * cfg.scale;
|
||||
const scaleFactor = geoHeight > 0.01 ? (targetHeight / geoHeight) * cfg.scale : cfg.scale;
|
||||
model.scale.setScalar(scaleFactor);
|
||||
console.log('GigaChad scale factor:', scaleFactor.toFixed(2));
|
||||
|
||||
// Recompute bounding box after scaling for floor alignment
|
||||
const scaledBbox = new THREE.Box3().setFromObject(model);
|
||||
// Align feet to floor: shift up so bounding box min.y = 0
|
||||
model.position.y = -scaledBbox.min.y;
|
||||
// Align feet to floor after scaling
|
||||
model.position.y = -minY * scaleFactor;
|
||||
|
||||
// Face the camera (Meshy models typically face +Z)
|
||||
model.rotation.y = cfg.rotationY;
|
||||
|
||||
// Fix PBR materials — Meshy exports MeshPhysicalMaterial that appears black
|
||||
// without environment maps. Convert to Lambert for reliable scene lighting.
|
||||
model.traverse((child) => {
|
||||
if (child.isMesh && child.material) {
|
||||
const mat = child.material;
|
||||
if (mat.isMeshStandardMaterial || mat.isMeshPhysicalMaterial) {
|
||||
const newMat = new THREE.MeshLambertMaterial({
|
||||
color: mat.color ?? new THREE.Color(PLAYER.SKIN_COLOR),
|
||||
map: mat.map,
|
||||
});
|
||||
child.material = newMat;
|
||||
mat.dispose();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this.mesh.add(model);
|
||||
this._glbModel = model;
|
||||
|
||||
|
||||
@@ -106,18 +106,18 @@ export class LevelBuilder {
|
||||
this.scene.add(ambient);
|
||||
|
||||
// Main directional light (overhead, slight angle)
|
||||
const dirLight = new THREE.DirectionalLight(COLORS.DIR_LIGHT, COLORS.DIR_INTENSITY);
|
||||
dirLight.position.set(2, 15, 5);
|
||||
dirLight.castShadow = true;
|
||||
dirLight.shadow.mapSize.width = 1024;
|
||||
dirLight.shadow.mapSize.height = 1024;
|
||||
dirLight.shadow.camera.near = 1;
|
||||
dirLight.shadow.camera.far = 30;
|
||||
dirLight.shadow.camera.left = -ARENA.HALF_WIDTH;
|
||||
dirLight.shadow.camera.right = ARENA.HALF_WIDTH;
|
||||
dirLight.shadow.camera.top = 10;
|
||||
dirLight.shadow.camera.bottom = -5;
|
||||
this.scene.add(dirLight);
|
||||
this.dirLight = new THREE.DirectionalLight(COLORS.DIR_LIGHT, COLORS.DIR_INTENSITY);
|
||||
this.dirLight.position.set(2, 15, 5);
|
||||
this.dirLight.castShadow = true;
|
||||
this.dirLight.shadow.mapSize.width = 1024;
|
||||
this.dirLight.shadow.mapSize.height = 1024;
|
||||
this.dirLight.shadow.camera.near = 1;
|
||||
this.dirLight.shadow.camera.far = 30;
|
||||
this.dirLight.shadow.camera.left = -ARENA.HALF_WIDTH;
|
||||
this.dirLight.shadow.camera.right = ARENA.HALF_WIDTH;
|
||||
this.dirLight.shadow.camera.top = 10;
|
||||
this.dirLight.shadow.camera.bottom = -5;
|
||||
this.scene.add(this.dirLight);
|
||||
|
||||
// Spot light (gym spotlight effect pointing down at center)
|
||||
const spot = new THREE.SpotLight(COLORS.SPOT_LIGHT, COLORS.SPOT_INTENSITY, 25, Math.PI / 4, 0.5);
|
||||
|
||||
Reference in New Issue
Block a user