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:
rshtirmer
2026-03-02 14:14:51 -05:00
parent 28fd68d18d
commit f49458bcd2
8 changed files with 1054 additions and 56 deletions
+42 -1
View File
@@ -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
+71 -1
View File
@@ -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,
};
+38 -30
View File
@@ -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;
}
}
}
+37 -12
View File
@@ -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;
+12 -12
View File
@@ -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);