Files
Nicholas ButzkeandClaude Fable 5 6c8b0ddd28 feat: cel-shaded look & feel overhaul — real audio, comic UI, themed VFX
Audio (all CC0 — Kenney packs + OpenGameArt, see assets/sounds/SOURCES.md):
- Replace every procedural synth WAV with forged sounds (ffmpeg pitch/layer
  mixes): distinct fire sounds per weapon with 2-3 variations, explosions,
  footsteps x5, land/jump/dash/vault, knife swing, hit/kill confirms,
  bullet impacts, reload, flight loops, UI hover/click/confirm/error,
  match jingles, menu music
- AudioManager.stream_for(): AudioStreamRandomizer per sound id — every
  weapon now gets variation + pitch randomization on each shot
- Explosions and bullet impacts play positional audio; landing has its own
  sound instead of a pitched footstep; ambient wind bed on every map

Visuals:
- ExplosionVFX: shared cel-shaded burst (white-hot stepped core, ink
  shockwave ring, star spikes, flat smoke puffs) replaces the orange
  sphere in both local and remote-replay paths
- Comic star muzzle flashes on all weapons; unified cel tracer bolts with
  ink outlines across hitscan/shotgun/remote paths
- Impact decals: hard-stepped ink-splat gradients instead of soft airbrush
- Toon shading on first-person view models + arms, third-person weapons,
  and the procedural humanoid fallback (no hull outlines on FBX weapons —
  their hard normals tear the inverted hull)
- Fix: giant soft "blob" highlight on floors — toon rim/specular disabled
  on level-geometry materials (pre-existing artifact since the cel commit)
- Fix: own third-person weapon rendered into the first-person camera
  (shadows-only now covers first_person_mode)

UI:
- UITheme: comic theme on the root window — Bangers display font (OFL),
  paper panels with thick ink borders + hard drop shadows, papaya accent,
  themed buttons/inputs/popups; hover/click sounds on all menu buttons
- Main menu: tilted comic wordmark, sunset toon diorama, looping menu music
- Match HUD: themed scoreboard/killfeed/kill counter

Viewports:
- 4x MSAA project-wide + viewmodel/diorama viewports (crisp ink lines)
- Viewmodel camera near plane 0.01; third-person camera FOV syncs settings
- debug/visual_capture.gd: dev tool to screenshot menu + level for review

Verified: 11/11 movement tests, spawn smoke test 0 failures, before/after
screenshot comparison of menu and level.

Co-Authored-By: Claude Fable 5 <[email protected]>
2026-07-17 18:15:42 -04:00

170 lines
6.6 KiB
GDScript

extends Object
class_name ExplosionVFX
## Cel-shaded anime explosion, shared by local projectiles and the remote
## replay RPC. Flat unshaded colors stepped through hot stages (white core ->
## yellow -> orange -> smoke), an expanding ink shockwave ring, and radiating
## star spikes — no gradients, no realistic fire, all comic-book punch.
const CORE_STAGES := [
Color(1.0, 1.0, 0.95), # white-hot flash
Color(1.0, 0.85, 0.2), # yellow
Color(1.0, 0.45, 0.1), # orange
]
const INK := Color(0.09, 0.08, 0.1)
const SMOKE := Color(0.82, 0.8, 0.85)
static func _flat_mat(color: Color, emissive: float = 0.0) -> StandardMaterial3D:
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.albedo_color = color
if emissive > 0.0:
m.emission_enabled = true
m.emission = color
m.emission_energy_multiplier = emissive
return m
static func spawn(scene_tree: SceneTree, pos: Vector3, radius: float) -> void:
var root := scene_tree.current_scene
if root == null:
return
# ── Core fireball: stepped color pops (each stage is a hard swap) ─────
var core := MeshInstance3D.new()
var core_mesh := SphereMesh.new()
core_mesh.radius = 1.0
core_mesh.height = 2.0
core_mesh.radial_segments = 16
core_mesh.rings = 8
core.mesh = core_mesh
var core_mat := _flat_mat(CORE_STAGES[0], 2.0)
core.material_override = core_mat
root.add_child(core)
core.global_position = pos
core.scale = Vector3.ONE * radius * 0.35
var t := core.create_tween()
t.tween_property(core, "scale", Vector3.ONE * radius * 0.9, 0.07) \
.set_trans(Tween.TRANS_QUINT).set_ease(Tween.EASE_OUT)
t.tween_callback(func(): core_mat.albedo_color = CORE_STAGES[1]; core_mat.emission = CORE_STAGES[1])
t.tween_property(core, "scale", Vector3.ONE * radius * 1.05, 0.08)
t.tween_callback(func(): core_mat.albedo_color = CORE_STAGES[2]; core_mat.emission = CORE_STAGES[2])
t.tween_property(core, "scale", Vector3.ONE * radius * 1.15, 0.08)
# collapse to nothing (anime explosions vanish, they don't fade)
t.tween_property(core, "scale", Vector3.ONE * 0.01, 0.12) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN)
t.tween_callback(core.queue_free)
# ── Ink shockwave ring: flat torus expanding outward ──────────────────
var ring := MeshInstance3D.new()
var ring_mesh := TorusMesh.new()
ring_mesh.inner_radius = 0.92
ring_mesh.outer_radius = 1.0
ring_mesh.rings = 32
ring_mesh.ring_segments = 6
ring.mesh = ring_mesh
ring.material_override = _flat_mat(INK)
root.add_child(ring)
ring.global_position = pos
ring.scale = Vector3.ONE * radius * 0.3
var rt := ring.create_tween()
rt.tween_property(ring, "scale", Vector3(radius * 2.2, radius * 0.5, radius * 2.2), 0.28) \
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
rt.parallel().tween_property(ring, "transparency", 1.0, 0.28) \
.set_trans(Tween.TRANS_EXPO).set_ease(Tween.EASE_IN)
rt.tween_callback(ring.queue_free)
# ── Star spikes: stretched octahedra radiating out (impact flash) ─────
var spike_count := 6
for i in spike_count:
var spike := MeshInstance3D.new()
var spike_mesh := PrismMesh.new()
spike_mesh.size = Vector3(0.14, 1.0, 0.14)
spike.mesh = spike_mesh
spike.material_override = _flat_mat(Color(1.0, 0.95, 0.6), 1.5)
root.add_child(spike)
var dir := Vector3(randf_range(-1, 1), randf_range(-0.3, 1), randf_range(-1, 1)).normalized()
spike.global_position = pos + dir * radius * 0.3
var up := Vector3.UP if absf(dir.dot(Vector3.UP)) < 0.98 else Vector3.RIGHT
spike.look_at(pos + dir * 2.0, up)
spike.rotate_object_local(Vector3.RIGHT, PI / 2.0)
spike.scale = Vector3(radius * 0.25, radius * 0.9, radius * 0.25)
var st := spike.create_tween()
st.tween_property(spike, "global_position", pos + dir * radius * 1.5, 0.16) \
.set_trans(Tween.TRANS_QUART).set_ease(Tween.EASE_OUT)
st.parallel().tween_property(spike, "scale", Vector3(0.02, radius * 0.3, 0.02), 0.16)
st.tween_callback(spike.queue_free)
# ── Smoke puffs: flat gray spheres that pop out then shrink away ──────
var puff_count := 5
for i in puff_count:
var puff := MeshInstance3D.new()
var puff_mesh := SphereMesh.new()
puff_mesh.radius = 1.0
puff_mesh.height = 2.0
puff_mesh.radial_segments = 10
puff_mesh.rings = 5
puff.mesh = puff_mesh
var shade := randf_range(0.75, 0.95)
puff.material_override = _flat_mat(Color(shade, shade, shade * 1.05))
root.add_child(puff)
var dir := Vector3(randf_range(-1, 1), randf_range(0.2, 1), randf_range(-1, 1)).normalized()
puff.global_position = pos + dir * radius * 0.4
puff.scale = Vector3.ONE * radius * randf_range(0.2, 0.35)
var pt := puff.create_tween()
pt.tween_property(puff, "global_position", pos + dir * radius * randf_range(0.9, 1.3), 0.3) \
.set_trans(Tween.TRANS_CUBIC).set_ease(Tween.EASE_OUT)
pt.parallel().tween_property(puff, "scale", Vector3.ONE * radius * randf_range(0.4, 0.55), 0.3)
pt.tween_property(puff, "scale", Vector3.ONE * 0.01, 0.25) \
.set_trans(Tween.TRANS_BACK).set_ease(Tween.EASE_IN)
pt.tween_callback(puff.queue_free)
# ── Audio: explosion samples with variation ───────────────────────────
var am = scene_tree.root.get_node_or_null("AudioManager")
if am and am.has_sound("explosion"):
am.play_3d("explosion", pos)
## Comic star-flash at a weapon muzzle: 4 flat spikes + hot core, parented to
## the weapon so it tracks view sway, gone in ~70 ms. Pairs with the existing
## OmniLight pop the weapons already do.
static func muzzle_flash(parent: Node3D, local_pos: Vector3) -> void:
if parent == null or not parent.is_inside_tree():
return
var flash := Node3D.new()
parent.add_child(flash)
flash.position = local_pos
flash.rotation.z = randf() * TAU
var core := MeshInstance3D.new()
var core_mesh := SphereMesh.new()
core_mesh.radius = 0.035
core_mesh.height = 0.07
core_mesh.radial_segments = 8
core_mesh.rings = 4
core.mesh = core_mesh
core.material_override = _flat_mat(Color(1.0, 0.98, 0.85), 3.0)
flash.add_child(core)
for i in 4:
var spike := MeshInstance3D.new()
var spike_mesh := PrismMesh.new()
spike_mesh.size = Vector3(0.025, 0.11, 0.012)
spike.mesh = spike_mesh
spike.material_override = _flat_mat(Color(1.0, 0.82, 0.25), 2.0)
spike.position = Vector3(0, 0, 0)
spike.rotation.z = TAU * float(i) / 4.0 + randf_range(-0.2, 0.2)
spike.translate_object_local(Vector3(0, 0.07, 0))
flash.add_child(spike)
var t := flash.create_tween()
t.tween_property(flash, "scale", Vector3.ONE * 1.4, 0.04) \
.set_trans(Tween.TRANS_QUINT).set_ease(Tween.EASE_OUT)
t.tween_property(flash, "scale", Vector3.ONE * 0.01, 0.03)
t.tween_callback(flash.queue_free)