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]>
This commit is contained in:
Nicholas Butzke
2026-07-17 18:15:42 -04:00
co-authored by Claude Fable 5
parent 1358183f98
commit 6c8b0ddd28
94 changed files with 651 additions and 213 deletions
+16
View File
@@ -0,0 +1,16 @@
# Sound sources
All current WAVs are forged (pitch/layer/trim via ffmpeg — see repo history)
from these CC0 packs, no attribution required:
- Kenney Impact Sounds — https://kenney.nl/assets/impact-sounds (CC0)
- Kenney Sci-Fi Sounds — https://kenney.nl/assets/sci-fi-sounds (CC0)
- Kenney Interface Sounds — https://kenney.nl/assets/interface-sounds (CC0)
- Kenney Music Jingles — https://kenney.nl/assets/music-jingles (CC0)
Exceptions (still procedural from `generate_sounds.py`): `wind.wav`,
`wallrun.wav`, `slide.wav` — seamless noise loops that the Kenney one-shots
can't replace.
Menu music: `music_menu.wav` — "Menu Music" by mrpoly, CC0,
https://opengameart.org/content/menu-music
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
Copyright 2010 The Bangers Project Authors (https://github.com/googlefonts/bangers)
This Font Software is licensed under the SIL Open Font License, Version 1.1.
This license is copied below, and is also available with a FAQ at:
https://scripts.sil.org/OFL
-----------------------------------------------------------
SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
-----------------------------------------------------------
PREAMBLE
The goals of the Open Font License (OFL) are to stimulate worldwide
development of collaborative font projects, to support the font creation
efforts of academic and linguistic communities, and to provide a free and
open framework in which fonts may be shared and improved in partnership
with others.
The OFL allows the licensed fonts to be used, studied, modified and
redistributed freely as long as they are not sold by themselves. The
fonts, including any derivative works, can be bundled, embedded,
redistributed and/or sold with any software provided that any reserved
names are not used by derivative works. The fonts and derivatives,
however, cannot be released under any other type of license. The
requirement for fonts to remain under this license does not apply
to any document created using the fonts or their derivatives.
DEFINITIONS
"Font Software" refers to the set of files released by the Copyright
Holder(s) under this license and clearly marked as such. This may
include source files, build scripts and documentation.
"Reserved Font Name" refers to any names specified as such after the
copyright statement(s).
"Original Version" refers to the collection of Font Software components as
distributed by the Copyright Holder(s).
"Modified Version" refers to any derivative made by adding to, deleting,
or substituting -- in part or in whole -- any of the components of the
Original Version, by changing formats or by porting the Font Software to a
new environment.
"Author" refers to any designer, engineer, programmer, technical
writer or other person who contributed to the Font Software.
PERMISSION & CONDITIONS
Permission is hereby granted, free of charge, to any person obtaining
a copy of the Font Software, to use, study, copy, merge, embed, modify,
redistribute, and sell modified and unmodified copies of the Font
Software, subject to the following conditions:
1) Neither the Font Software nor any of its individual components,
in Original or Modified Versions, may be sold by itself.
2) Original or Modified Versions of the Font Software may be bundled,
redistributed and/or sold with any software, provided that each copy
contains the above copyright notice and this license. These can be
included either as stand-alone text files, human-readable headers or
in the appropriate machine-readable metadata fields within text or
binary files as long as those fields can be easily viewed by the user.
3) No Modified Version of the Font Software may use the Reserved Font
Name(s) unless explicit written permission is granted by the corresponding
Copyright Holder. This restriction only applies to the primary font name as
presented to the users.
4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
Software shall not be used to promote, endorse or advertise any
Modified Version, except to acknowledge the contribution(s) of the
Copyright Holder(s) and the Author(s) or with their explicit written
permission.
5) The Font Software, modified or unmodified, in part or in whole,
must be distributed entirely under this license, and must not be
distributed under any other license. The requirement for fonts to
remain under this license does not apply to any document created
using the Font Software.
TERMINATION
This license becomes null and void if any of the above conditions are
not met.
DISCLAIMER
THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
OTHER DEALINGS IN THE FONT SOFTWARE.
+11
View File
@@ -163,6 +163,11 @@ func _ready() -> void:
calf_r.cast_shadow = shadow_setting
calf_r_pivot.add_child(calf_r)
# Cel-shade + ink outline the blocky fallback body so it matches skinned
# player models (shadows-only bodies skip it: outlines would cast shadows)
if not shadows_only:
LevelMaterials.apply_toon_recursive(root_pivot, 0.012)
## Skin System
var current_skin: PlayerSkin
var skin_model: Node3D
@@ -222,6 +227,9 @@ func _apply_color(col: Color) -> void:
hmat.albedo_color = Color(0.95, 0.85, 0.78)
hmat.roughness = 0.8
head.mesh.material = hmat
# Re-toon after the material swap (fresh StandardMaterials above)
if not shadows_only:
LevelMaterials.apply_toon_recursive(root_pivot, 0.012)
func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
current_state = state
@@ -257,6 +265,9 @@ func set_weapon(script_path: String) -> void:
w.set_process_input(false)
if shadows_only:
_set_shadows_recursive(w)
else:
# Toon shading only — FBX weapon normals tear inverted-hull outlines
LevelMaterials.apply_toon_recursive(w, 0.0)
# Force position after the weapon's _build_model() sets it for 1st person
w.position = Vector3(-0.15, 1.0, 0.4)
+4 -1
View File
@@ -353,7 +353,10 @@ func set_weapon(script_path: String) -> void:
w.ready.connect(func():
w.set_process(false)
w.set_process_input(false)
if shadows_only:
# Owner's first-person view must not see their own held weapon (it
# sits right in front of the lens as a huge blob) — shadows only,
# same as the body. set_owner_visible reveals it in third person.
if shadows_only or first_person_mode:
_set_shadows_recursive(w)
# Undo the first-person viewmodel placement from the weapon's _ready.
w.position = Vector3(0.0, 0.08, 0.03)
+37
View File
@@ -0,0 +1,37 @@
extends SceneTree
## Dev tool: launch with rendering, screenshot the main menu and a level,
## then quit. Run:
## godot --path . -s res://debug/visual_capture.gd -- <output_dir>
## Screenshots land in <output_dir>/shot_menu.png and shot_level.png.
var _frames := 0
var _out_dir := "."
func _initialize() -> void:
var args := OS.get_cmdline_user_args()
if args.size() > 0:
_out_dir = args[0]
change_scene_to_file("res://ui/main_menu/main_menu.tscn")
func _process(_delta: float) -> bool:
_frames += 1
if _frames == 60:
_shot("menu")
var nm = root.get_node_or_null("NetworkManager")
if nm and nm.has_method("start_singleplayer_match"):
nm.start_singleplayer_match("Deathmatch")
change_scene_to_file("res://scenes/maps/test_level/test_level.tscn")
elif _frames == 240:
_shot("level")
return true
return false
func _shot(tag: String) -> void:
var img := root.get_viewport().get_texture().get_image()
var path := _out_dir + "/shot_" + tag + ".png"
img.save_png(path)
print("visual_capture: saved ", path)
+15
View File
@@ -124,6 +124,21 @@ func has_sound(id: String) -> bool:
return _library.has(id)
## Build an AudioStreamRandomizer for a registered id: every play() picks a
## random variation with random pitch. For nodes that own their own player
## (weapons) instead of going through the pools. Falls back to a plain load
## of fallback_path if the id isn't registered.
func stream_for(id: String, fallback_path: String = "") -> AudioStream:
var entry = _library.get(id)
if entry == null:
return load(fallback_path) if fallback_path != "" and ResourceLoader.exists(fallback_path) else null
var r := AudioStreamRandomizer.new()
r.random_pitch = 1.0 + entry["pitch_var"]
for s in entry["streams"]:
r.add_stream(-1, s)
return r
# ── Playback ──────────────────────────────────────────────────────────────────
func _build_pools() -> void:
+20 -65
View File
@@ -36,6 +36,8 @@ var death_screen: Control
var ragdoll_instance: Node3D
# Audio
var footstep_player: AudioStreamPlayer
var footstep_streams: Array = []
var land_player: AudioStreamPlayer
var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer
@@ -182,6 +184,7 @@ func set_third_person(on: bool) -> void:
return
third_person = on
if is_instance_valid(_tp_camera):
_tp_camera.fov = SettingsManager.world_fov # stay in sync with settings
_tp_camera.current = on
if is_instance_valid(camera):
camera.current = not on
@@ -250,9 +253,19 @@ func _hide_remote_weapons() -> void:
func _setup_audio() -> void:
footstep_player = AudioStreamPlayer.new()
footstep_player.bus = "SFX"
footstep_player.stream = load("res://assets/sounds/footstep.wav")
for path in ["res://assets/sounds/footstep.wav", "res://assets/sounds/footstep_01.wav",
"res://assets/sounds/footstep_02.wav", "res://assets/sounds/footstep_03.wav",
"res://assets/sounds/footstep_04.wav"]:
if ResourceLoader.exists(path):
footstep_streams.append(load(path))
footstep_player.stream = footstep_streams[0] if not footstep_streams.is_empty() else null
add_child(footstep_player)
land_player = AudioStreamPlayer.new()
land_player.bus = "SFX"
land_player.stream = load("res://assets/sounds/land.wav")
add_child(land_player)
jump_player = AudioStreamPlayer.new()
jump_player.bus = "SFX"
jump_player.stream = load("res://assets/sounds/jump.wav")
@@ -492,34 +505,7 @@ func rpc_play_fire_effects(origin: Vector3, target_or_dir: Vector3, _weapon_name
_spawn_remote_projectile(origin, target_or_dir, _weapon_name)
func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void:
var tracer = Node3D.new()
tracer.set_script(load("res://weapons/hitscan_tracer.gd"))
var t_mesh_inst = MeshInstance3D.new()
var t_mesh = CapsuleMesh.new()
t_mesh.radius = 0.02
t_mesh.height = 1.0
var t_mat = StandardMaterial3D.new()
t_mat.albedo_color = Color(1.0, 0.8, 0.2)
t_mat.emission_enabled = true
t_mat.emission = Color(2.0, 1.5, 0.5)
t_mesh.material = t_mat
t_mesh_inst.mesh = t_mesh
t_mesh_inst.rotation.x = deg_to_rad(90)
t_mesh_inst.position = Vector3(0, 0, -0.5)
tracer.add_child(t_mesh_inst)
tracer.target_position = target
if origin.distance_squared_to(target) > 0.1:
get_tree().current_scene.add_child(tracer)
tracer.global_position = origin
var up_vec = Vector3.UP
if abs((target - origin).normalized().dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
tracer.look_at(target, up_vec)
HitscanTracer.spawn_bolt(get_tree().current_scene, origin, target)
func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: String) -> void:
# Try to find the actual weapon to spawn its custom projectile so it looks identical!
@@ -620,36 +606,7 @@ func server_play_explosion(pos: Vector3, radius: float) -> void:
func rpc_play_explosion(pos: Vector3, radius: float) -> void:
if is_multiplayer_authority(): return
var visual = MeshInstance3D.new()
var s_mesh = SphereMesh.new()
s_mesh.radius = radius
s_mesh.height = radius * 2.0
visual.mesh = s_mesh
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.5, 0.0, 0.6) # Translucent orange
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(1.0, 0.3, 0.0)
visual.material_override = mat
visual.position = pos
get_tree().current_scene.add_child(visual)
var tween = visual.create_tween()
tween.tween_property(mat, "albedo_color:a", 0.0, 0.3)
tween.parallel().tween_property(visual, "scale", Vector3(1.1, 1.1, 1.1), 0.3)
tween.tween_callback(visual.queue_free)
var audio = AudioStreamPlayer3D.new()
audio.stream = load("res://assets/sounds/shotgun_fire.wav")
audio.bus = "SFX"
audio.pitch_scale = 0.7
audio.max_distance = 150.0
get_tree().current_scene.add_child(audio)
audio.global_position = pos
audio.play()
audio.finished.connect(audio.queue_free)
ExplosionVFX.spawn(get_tree(), pos, radius)
@rpc("any_peer", "call_local", "reliable")
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void:
@@ -973,13 +930,11 @@ func _on_movement_event(ev: String, data: Dictionary) -> void:
elif ev == "dash":
_speedline_burst = 1.0
elif ev == "land":
# Landing thud: reuse the footstep sample, pitched down and louder
# with impact. Ground state resets pitch/volume before each step.
if footstep_player:
if land_player:
var heavy: bool = data.get("heavy", false)
footstep_player.pitch_scale = 0.55 if heavy else 0.7
footstep_player.volume_db = 2.0 if heavy else -2.0
footstep_player.play()
land_player.pitch_scale = (0.8 if heavy else 1.0) * randf_range(0.95, 1.05)
land_player.volume_db = 2.0 if heavy else -3.0
land_player.play()
func _process(delta: float) -> void:
# Remote players: interpolate toward the owner's synced transform and
+3 -2
View File
@@ -82,8 +82,9 @@ func update(delta: float) -> void:
_footstep_timer -= delta
if _footstep_timer <= 0.0:
if player.footstep_player:
# Reset pitch/volume (the landing thud reuses this player) and
# add slight variation so steps don't machine-gun.
# Random sample + slight pitch variation so steps don't machine-gun.
if "footstep_streams" in player and not player.footstep_streams.is_empty():
player.footstep_player.stream = player.footstep_streams.pick_random()
player.footstep_player.pitch_scale = randf_range(0.92, 1.08)
player.footstep_player.volume_db = -6.0
player.footstep_player.play()
+1
View File
@@ -126,3 +126,4 @@ emote={
[rendering]
rendering_device/driver.windows="d3d12"
anti_aliasing/quality/msaa_3d=2
+14
View File
@@ -33,6 +33,20 @@ static func add_to(level: Node, sky_variant: String = "day") -> WorldEnvironment
fill.light_energy = 0.25
fill.shadow_enabled = false
level.add_child(fill)
# Quiet ambient bed so the arena never sits in dead air.
if not level.has_node("AmbientBed") and ResourceLoader.exists("res://assets/sounds/wind.wav"):
var amb := AudioStreamPlayer.new()
amb.name = "AmbientBed"
amb.stream = load("res://assets/sounds/wind.wav")
if amb.stream is AudioStreamWAV:
amb.stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
amb.stream.loop_end = amb.stream.data.size() / 2
amb.volume_db = -30.0
amb.pitch_scale = 0.6
amb.bus = "SFX"
amb.autoplay = true
level.add_child(amb)
return env
+5
View File
@@ -42,6 +42,11 @@ static func tinted(tint: Color, dark: bool = false) -> Material:
# land near the original flat color while keeping the grid contrast.
mat.set_shader_parameter("albedo_color", Color(
minf(tint.r * 1.9, 1.0), minf(tint.g * 1.9, 1.0), minf(tint.b * 1.9, 1.0)))
# Level surfaces are huge; the toon rim/specular reads as a giant soft
# "blob" highlight smeared across floors and walls. Keep those effects
# for characters/props only.
mat.set_shader_parameter("rim_strength", 0.0)
mat.set_shader_parameter("specular_strength", 0.0)
_cache[key] = mat
return mat
+5
View File
@@ -19,6 +19,11 @@ func _ready() -> void:
# Hide mouse
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
# Maps that don't ship their own environment get the shared stylized one
# (sky gradient, sun+fill, glow, cel color grade, ambient bed).
if not has_node("WorldEnvironment"):
LevelEnvironment.add_to(self)
_build_hud()
# Multiplayer Spawning
+35 -16
View File
@@ -10,6 +10,22 @@ var _host_lobby_players_list: ItemList
func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE)
# Comic/cel theme for every Control in the game (root window inherits down)
UITheme.apply_global(get_tree())
# Menu music (scene-owned: stops automatically when a level loads)
var music_path := "res://assets/sounds/music_menu.wav"
if ResourceLoader.exists(music_path):
var music := AudioStreamPlayer.new()
music.stream = load(music_path)
if music.stream is AudioStreamWAV:
music.stream.loop_mode = AudioStreamWAV.LOOP_FORWARD
music.stream.loop_end = music.stream.data.size() / 4 # 16-bit stereo frames
music.bus = "Music"
music.volume_db = -8.0
music.autoplay = true
add_child(music)
var nm = get_node_or_null("/root/NetworkManager")
if nm:
nm.connection_succeeded.connect(_on_connection_succeeded)
@@ -25,6 +41,7 @@ func _ready() -> void:
var viewport = SubViewport.new()
viewport.own_world_3d = true
viewport.msaa_3d = Viewport.MSAA_4X
right_panel.add_child(viewport)
_build_3d_diorama(viewport)
@@ -40,13 +57,14 @@ func _ready() -> void:
_ui_vbox.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50)
add_child(_ui_vbox)
# Title
# Title — big comic wordmark with a slight kinetic tilt
var title = Label.new()
title.text = "Papaya Shooter"
title.add_theme_font_size_override("font_size", 72)
title.add_theme_color_override("font_color", Color(1, 0.6, 0.2)) # Papaya orange
title.add_theme_color_override("font_outline_color", Color(0, 0, 0))
title.add_theme_constant_override("outline_size", 12)
title.text = "PAPAYA SHOOTER"
title.add_theme_font_size_override("font_size", 92)
title.add_theme_color_override("font_color", UITheme.PAPAYA)
title.add_theme_color_override("font_outline_color", UITheme.INK)
title.add_theme_constant_override("outline_size", 16)
title.rotation_degrees = -2.5
_ui_vbox.add_child(title)
# Spacer
@@ -269,6 +287,9 @@ func _ready() -> void:
_host_lobby_players_list.add_theme_font_size_override("font_size", 24)
hl_right.add_child(_host_lobby_players_list)
# Hover/click sounds on every menu button
UITheme.wire_sounds(self)
func _load_maps_modularly(target_hbox: Container, is_multiplayer: bool, card_size: Vector2) -> void:
var maps_dir = "res://scenes/maps/"
var dir = DirAccess.open(maps_dir)
@@ -492,13 +513,9 @@ func _build_3d_diorama(viewport: SubViewport) -> void:
var world = Node3D.new()
viewport.add_child(world)
# Lighting and Env
# Lighting and Env — same stylized sunset sky as in-game for continuity
var env = WorldEnvironment.new()
var environment = Environment.new()
environment.background_mode = Environment.BG_COLOR
environment.background_color = Color(0.1, 0.15, 0.2) # Dark stylish background
environment.tonemap_mode = Environment.TONE_MAPPER_ACES
env.environment = environment
env.environment = LevelEnvironment.make_environment("sunset")
world.add_child(env)
var sun = DirectionalLight3D.new()
@@ -514,13 +531,11 @@ func _build_3d_diorama(viewport: SubViewport) -> void:
fill.light_energy = 0.5
world.add_child(fill)
# Floor
# Floor — toon world-grid like the levels
var floor_mesh = MeshInstance3D.new()
floor_mesh.mesh = PlaneMesh.new()
floor_mesh.mesh.size = Vector2(20, 20)
var floor_mat = StandardMaterial3D.new()
floor_mat.albedo_color = Color(0.2, 0.2, 0.2)
floor_mesh.mesh.surface_set_material(0, floor_mat)
floor_mesh.mesh.surface_set_material(0, LevelMaterials.tinted(Color(0.32, 0.3, 0.38)))
world.add_child(floor_mesh)
# Player Model (Sliding Pose)
@@ -530,6 +545,7 @@ func _build_3d_diorama(viewport: SubViewport) -> void:
var body_mat = StandardMaterial3D.new()
body_mat.albedo_color = Color(0.3, 0.6, 0.9)
# Toonified below via apply_toon_recursive once the model is assembled
# Torso (Capsule tilted back)
var torso = MeshInstance3D.new()
@@ -576,6 +592,9 @@ func _build_3d_diorama(viewport: SubViewport) -> void:
if shotgun.has_method("set_process_input"):
shotgun.set_process_input(false)
# Cel-shade the diorama (no hull outlines — the FBX shotgun tears them)
LevelMaterials.apply_toon_recursive(player_model, 0.0)
# Camera
var camera = Camera3D.new()
world.add_child(camera)
+6 -5
View File
@@ -16,6 +16,9 @@ var _scoreboard_grid: GridContainer
func _ready() -> void:
_nm = get_node_or_null("/root/NetworkManager")
# Ensure the comic theme is active even when a level is loaded directly
# (tests, debug runs) without passing through the main menu.
UITheme.apply_global(get_tree())
# Top HUD
var top_hbox = HBoxContainer.new()
@@ -37,8 +40,8 @@ func _ready() -> void:
_local_kills_label = Label.new()
_local_kills_label.text = "0 Kills"
_local_kills_label.add_theme_font_size_override("font_size", 64)
_local_kills_label.add_theme_color_override("font_color", Color(1, 0.8, 0.2))
_local_kills_label.add_theme_color_override("font_outline_color", Color.BLACK)
_local_kills_label.add_theme_color_override("font_color", UITheme.PAPAYA)
_local_kills_label.add_theme_color_override("font_outline_color", UITheme.INK)
_local_kills_label.add_theme_constant_override("outline_size", 12)
top_hbox.add_child(_local_kills_label)
@@ -68,9 +71,7 @@ func _ready() -> void:
_scoreboard_panel = PanelContainer.new()
_scoreboard_panel.custom_minimum_size = Vector2(800, 600)
var sb_style = StyleBoxFlat.new()
sb_style.bg_color = Color(0, 0, 0, 0.8)
_scoreboard_panel.add_theme_stylebox_override("panel", sb_style)
# Styled by the shared UITheme PanelContainer stylebox (ink-border card)
_scoreboard_panel.hide()
center_container.add_child(_scoreboard_panel)
+122
View File
@@ -0,0 +1,122 @@
extends Object
class_name UITheme
## Shared comic/cel UI theme: Bangers display font, paper panels with thick
## ink borders and hard drop shadows, papaya accent. Built once, applied to
## the root Window so every Control in the game inherits it.
const FONT_PATH := "res://assets/ui/fonts/Bangers-Regular.ttf"
const INK := Color(0.09, 0.08, 0.12)
const PAPER := Color(0.98, 0.96, 0.9)
const PAPER_DIM := Color(0.92, 0.9, 0.84)
const PAPAYA := Color(1.0, 0.55, 0.15)
const PAPAYA_HOT := Color(1.0, 0.68, 0.25)
const TEAL := Color(0.16, 0.72, 0.68)
static var _theme: Theme = null
## Comic panel stylebox: flat fill, thick ink border, hard offset shadow.
static func box(bg: Color, border: Color = INK, radius: int = 10,
border_w: int = 3, shadow: bool = true) -> StyleBoxFlat:
var sb := StyleBoxFlat.new()
sb.bg_color = bg
sb.border_color = border
sb.set_border_width_all(border_w)
sb.set_corner_radius_all(radius)
sb.content_margin_left = 16
sb.content_margin_right = 16
sb.content_margin_top = 8
sb.content_margin_bottom = 8
if shadow:
sb.shadow_color = Color(INK.r, INK.g, INK.b, 0.55)
sb.shadow_size = 0
sb.shadow_offset = Vector2(4, 4)
sb.shadow_size = 2
return sb
static func build() -> Theme:
if _theme:
return _theme
var t := Theme.new()
var font: Font = load(FONT_PATH) if ResourceLoader.exists(FONT_PATH) else null
if font:
t.default_font = font
t.default_font_size = 26
# ── Buttons: paper card, papaya on hover, squash on press ────────────
t.set_stylebox("normal", "Button", box(PAPER))
t.set_stylebox("hover", "Button", box(PAPAYA_HOT))
t.set_stylebox("pressed", "Button", box(PAPAYA, INK, 10, 3, false))
t.set_stylebox("focus", "Button", box(PAPER, TEAL))
t.set_stylebox("disabled", "Button", box(PAPER_DIM, Color(0.4, 0.4, 0.45)))
t.set_color("font_color", "Button", INK)
t.set_color("font_hover_color", "Button", INK)
t.set_color("font_pressed_color", "Button", PAPER)
t.set_color("font_focus_color", "Button", INK)
t.set_color("font_disabled_color", "Button", Color(0.45, 0.45, 0.5))
# OptionButton / CheckBox get the same treatment explicitly (Godot theme
# types don't inherit styling from Button)
for cls in ["OptionButton", "MenuButton", "CheckBox", "CheckButton"]:
t.set_stylebox("normal", cls, box(PAPER))
t.set_stylebox("hover", cls, box(PAPAYA_HOT))
t.set_stylebox("pressed", cls, box(PAPAYA, INK, 10, 3, false))
t.set_stylebox("focus", cls, box(PAPER, TEAL))
t.set_stylebox("disabled", cls, box(PAPER_DIM, Color(0.4, 0.4, 0.45)))
t.set_color("font_color", cls, INK)
t.set_color("font_hover_color", cls, INK)
t.set_color("font_pressed_color", cls, PAPER)
t.set_color("font_focus_color", cls, INK)
t.set_color("font_disabled_color", cls, Color(0.45, 0.45, 0.5))
# ── Labels: ink outline everywhere for that inked-cel readability ────
t.set_color("font_outline_color", "Label", INK)
t.set_constant("outline_size", "Label", 8)
t.set_color("font_color", "Label", PAPER)
# RichTextLabel (killfeed)
t.set_color("default_color", "RichTextLabel", PAPER)
t.set_color("font_outline_color", "RichTextLabel", INK)
t.set_constant("outline_size", "RichTextLabel", 6)
# ── Inputs ───────────────────────────────────────────────────────────
t.set_stylebox("normal", "LineEdit", box(PAPER, INK, 8, 3, false))
t.set_stylebox("focus", "LineEdit", box(PAPER, TEAL, 8, 3, false))
t.set_color("font_color", "LineEdit", INK)
t.set_color("caret_color", "LineEdit", INK)
# ── Panels / lists ───────────────────────────────────────────────────
t.set_stylebox("panel", "PanelContainer", box(Color(0.13, 0.12, 0.18, 0.92), INK, 14, 4))
t.set_stylebox("panel", "ItemList", box(Color(0.13, 0.12, 0.18, 0.92), INK, 10, 3, false))
t.set_color("font_color", "ItemList", PAPER)
# ── Popup menus (OptionButton dropdowns) ─────────────────────────────
t.set_stylebox("panel", "PopupMenu", box(PAPER, INK, 8, 3, false))
t.set_color("font_color", "PopupMenu", INK)
t.set_color("font_hover_color", "PopupMenu", PAPAYA)
_theme = t
return t
## Apply the theme to the whole root window (inherited by every Control in
## every scene from then on). Idempotent.
static func apply_global(tree: SceneTree) -> void:
if tree and tree.root and tree.root.theme != build():
tree.root.theme = build()
## Attach hover/click UI sounds to every BaseButton under `root` (recursive).
static func wire_sounds(root: Node) -> void:
var am = root.get_tree().root.get_node_or_null("AudioManager")
if am == null:
return
for btn in root.find_children("*", "BaseButton", true, false):
if btn.has_meta("ui_sfx_wired"):
continue
btn.set_meta("ui_sfx_wired", true)
btn.mouse_entered.connect(func(): am.play_ui("ui_hover"))
btn.pressed.connect(func(): am.play_ui("ui_click"))
+1
View File
@@ -0,0 +1 @@
uid://doqqa8s8bal5u
+2 -2
View File
@@ -35,12 +35,12 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/ak47_fire.wav")
fire_sound.stream = AudioManager.stream_for("ak47_fire", "res://assets/sounds/ak47_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -5.0
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav") # generic reload
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
+2 -2
View File
@@ -34,13 +34,13 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/awp_fire.wav")
fire_sound.stream = AudioManager.stream_for("awp_fire", "res://assets/sounds/awp_fire.wav")
fire_sound.volume_db = 0.0
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav") # Reusing for now
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
func _process(delta: float) -> void:
+4 -30
View File
@@ -111,6 +111,7 @@ func _fire() -> void:
func _play_muzzle_flash() -> void:
if muzzle_flash:
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
muzzle_flash.light_energy = 8.0
var tween = create_tween()
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
@@ -175,33 +176,6 @@ func _shoot_hitscan() -> void:
player.server_play_fire_effects.rpc_id(1, origin, final_target, name, true)
func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void:
# Spawn cosmetic tracer
var tracer = Node3D.new()
tracer.set_script(load("res://weapons/hitscan_tracer.gd"))
var t_mesh_inst = MeshInstance3D.new()
var t_mesh = CapsuleMesh.new()
t_mesh.radius = 0.02
t_mesh.height = 1.0
var t_mat = StandardMaterial3D.new()
t_mat.albedo_color = Color(1.0, 0.8, 0.2) # Yellow-orange tracer
t_mat.emission_enabled = true
t_mat.emission = Color(2.0, 1.5, 0.5)
t_mesh.material = t_mat
t_mesh_inst.mesh = t_mesh
t_mesh_inst.rotation.x = deg_to_rad(90) # Orient capsule along Z axis
t_mesh_inst.position = Vector3(0, 0, -0.5) # Shift it half a meter forward so the tail is at the origin
tracer.add_child(t_mesh_inst)
tracer.target_position = final_target
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position # Spawn from the barrel tip
if visual_origin.distance_squared_to(final_target) > 0.1:
get_tree().current_scene.add_child(tracer)
tracer.global_position = visual_origin
var up_vec = Vector3.UP
if abs((final_target - visual_origin).normalized().dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
tracer.look_at(final_target, up_vec)
# Cel-styled cosmetic tracer from the barrel tip
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)
+1
View File
@@ -109,6 +109,7 @@ func _fire() -> void:
func _play_muzzle_flash() -> void:
if muzzle_flash:
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
muzzle_flash.light_energy = 8.0
var tween = create_tween()
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
+2 -2
View File
@@ -44,14 +44,14 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/dmr_fire.wav")
fire_sound.stream = AudioManager.stream_for("dmr_fire", "res://assets/sounds/dmr_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -3.0
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav")
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
func _process(delta: float) -> void:
+5 -31
View File
@@ -44,14 +44,14 @@ func _build_model() -> void:
# Audio Players
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/shotgun_fire.wav")
fire_sound.stream = AudioManager.stream_for("shotgun_fire", "res://assets/sounds/shotgun_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -20.0
add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav")
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
add_child(reload_sound)
func _process(delta: float) -> void:
@@ -127,6 +127,7 @@ func _apply_impulse() -> void:
func _play_muzzle_flash() -> void:
if muzzle_flash:
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
muzzle_flash.light_energy = 8.0
var tween = create_tween()
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.1)
@@ -180,33 +181,6 @@ func _shoot_hitscan() -> void:
# ImpactSpawner not available in this build, skip
pass
# Spawn cosmetic tracer
var tracer = Node3D.new()
tracer.set_script(load("res://weapons/hitscan_tracer.gd"))
var t_mesh_inst = MeshInstance3D.new()
var t_mesh = CapsuleMesh.new()
t_mesh.radius = 0.02
t_mesh.height = 1.0
var t_mat = StandardMaterial3D.new()
t_mat.albedo_color = Color(1.0, 0.8, 0.2)
t_mat.emission_enabled = true
t_mat.emission = Color(2.0, 1.5, 0.5)
t_mesh.material = t_mat
t_mesh_inst.mesh = t_mesh
t_mesh_inst.rotation.x = deg_to_rad(90)
t_mesh_inst.position = Vector3(0, 0, -0.719)
tracer.add_child(t_mesh_inst)
tracer.target_position = final_target
# Cel-styled cosmetic tracer per pellet
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
if visual_origin.distance_squared_to(final_target) > 0.1:
get_tree().current_scene.add_child(tracer)
tracer.global_position = visual_origin
var up_vec = Vector3.UP
if abs((final_target - visual_origin).normalized().dot(up_vec)) > 0.99:
up_vec = Vector3.RIGHT
tracer.look_at(final_target, up_vec)
HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)
+169
View File
@@ -0,0 +1,169 @@
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)
+1
View File
@@ -0,0 +1 @@
uid://bpijupwnu2dyv
+2 -32
View File
@@ -210,35 +210,5 @@ func _explode(pos: Vector3) -> void:
if owner_player and owner_player.is_multiplayer_authority() and owner_player.has_method("server_play_explosion") and owner_player.multiplayer.has_multiplayer_peer() and not(owner_player.multiplayer.multiplayer_peer is OfflineMultiplayerPeer):
owner_player.server_play_explosion.rpc_id(1, pos, explosion_radius)
# Local Visual Effect
var visual = MeshInstance3D.new()
var s_mesh = SphereMesh.new()
s_mesh.radius = explosion_radius
s_mesh.height = explosion_radius * 2.0
visual.mesh = s_mesh
var mat = StandardMaterial3D.new()
mat.albedo_color = Color(1.0, 0.5, 0.0, 0.6) # Translucent orange
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.emission_enabled = true
mat.emission = Color(1.0, 0.3, 0.0)
visual.material_override = mat
visual.position = pos
get_tree().current_scene.add_child(visual)
var tween = visual.create_tween()
tween.tween_property(mat, "albedo_color:a", 0.0, 0.3)
tween.parallel().tween_property(visual, "scale", Vector3(1.1, 1.1, 1.1), 0.3)
tween.tween_callback(visual.queue_free)
# Audio Effect
var audio = AudioStreamPlayer3D.new()
audio.stream = load("res://assets/sounds/shotgun_fire.wav") # using shotgun fire for a crash explosion
audio.bus = "SFX"
audio.pitch_scale = explosion_pitch
audio.max_distance = 150.0
get_tree().current_scene.add_child(audio)
audio.global_position = pos
audio.play()
audio.finished.connect(audio.queue_free)
# Local visual + audio: shared cel-shaded burst (also used by remote replay)
ExplosionVFX.spawn(get_tree(), pos, explosion_radius)
+35
View File
@@ -6,6 +6,41 @@ var target_position: Vector3
var distance_traveled: float = 0.0
var max_distance: float = 0.0
## Spawn a cel-styled tracer bolt: unshaded hot core with an ink outline,
## flying from origin to target. Shared by every hitscan weapon and the
## remote-fire replay so all bullets look on-theme.
static func spawn_bolt(scene: Node, origin: Vector3, target: Vector3,
color: Color = Color(1.0, 0.72, 0.15)) -> void:
if scene == null or origin.distance_squared_to(target) <= 0.1:
return
var tracer := HitscanTracer.new()
var core := MeshInstance3D.new()
var core_mesh := CapsuleMesh.new()
core_mesh.radius = 0.025
core_mesh.height = 1.1
var m := StandardMaterial3D.new()
m.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
m.albedo_color = color.lightened(0.35)
m.emission_enabled = true
m.emission = color
m.emission_energy_multiplier = 2.2
core_mesh.material = m
core.mesh = core_mesh
core.rotation.x = deg_to_rad(90) # orient capsule along Z
core.position = Vector3(0, 0, -0.55) # tail sits at the origin
core.material_overlay = LevelMaterials.outline(0.012)
tracer.add_child(core)
tracer.position = origin
tracer.target_position = target
scene.add_child(tracer)
var up := Vector3.UP
if absf((target - origin).normalized().dot(up)) > 0.99:
up = Vector3.RIGHT
tracer.look_at(target, up)
func _ready() -> void:
max_distance = global_position.distance_to(target_position)
if max_distance < 0.1:
+8
View File
@@ -19,6 +19,8 @@ static func _ensure_mats():
for c in colors: p_colors.append(c)
g.offsets = p_offsets
g.colors = p_colors
# Hard color steps: reads as inked cel splats instead of soft airbrush
g.interpolation_mode = Gradient.GRADIENT_INTERPOLATE_CONSTANT
var t = GradientTexture2D.new()
t.gradient = g
@@ -63,6 +65,12 @@ static func _ensure_mats():
static func spawn(scene_tree: SceneTree, type: String, pos: Vector3, normal: Vector3, size: float = 0.2):
_ensure_mats()
# Positional impact tick (pooled; safe at fire-rate frequency)
if type == "bullet":
var am = scene_tree.root.get_node_or_null("AudioManager")
if am and am.has_sound("bullet_impact"):
am.play_3d("bullet_impact", pos, -6.0)
var mi = MeshInstance3D.new()
var qm = QuadMesh.new()
+1 -1
View File
@@ -44,7 +44,7 @@ func _build_model() -> void:
# Audio (reusing shotgun reload or something generic for now, ideally a swish)
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/dash.wav")
fire_sound.stream = AudioManager.stream_for("knife_swing", "res://assets/sounds/knife_swing.wav")
fire_sound.pitch_scale = 1.2
fire_sound.volume_db = 0.0
model_root.add_child(fire_sound)
+2 -2
View File
@@ -35,12 +35,12 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/m4_fire.wav")
fire_sound.stream = AudioManager.stream_for("m4_fire", "res://assets/sounds/m4_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -8.0
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav")
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
+2 -2
View File
@@ -36,12 +36,12 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/mortar_fire.wav")
fire_sound.stream = AudioManager.stream_for("mortar_fire", "res://assets/sounds/mortar_fire.wav")
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav") # Reusing
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -35,12 +35,12 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/mp7_fire.wav")
fire_sound.stream = AudioManager.stream_for("mp7_fire", "res://assets/sounds/mp7_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -10.0
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav")
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
+2 -2
View File
@@ -51,14 +51,14 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/nailgun_fire.wav")
fire_sound.stream = AudioManager.stream_for("nailgun_fire", "res://assets/sounds/nailgun_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -5.0
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav")
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -45,14 +45,14 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/plasma_fire.wav")
fire_sound.stream = AudioManager.stream_for("plasma_fire", "res://assets/sounds/plasma_fire.wav")
fire_sound.position = muzzle_flash.position
fire_sound.volume_db = -5.0
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav")
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -38,12 +38,12 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/rocket_fire.wav")
fire_sound.stream = AudioManager.stream_for("rocket_fire", "res://assets/sounds/rocket_fire.wav")
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav") # Reusing
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -54,12 +54,12 @@ func _build_model() -> void:
# Audio
fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX"
fire_sound.stream = load("res://assets/sounds/swarm_fire.wav")
fire_sound.stream = AudioManager.stream_for("swarm_fire", "res://assets/sounds/swarm_fire.wav")
model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX"
reload_sound.stream = load("res://assets/sounds/shotgun_reload.wav") # Reusing
reload_sound.stream = AudioManager.stream_for("shotgun_reload", "res://assets/sounds/shotgun_reload.wav")
model_root.add_child(reload_sound)
func _process(delta: float) -> void:
+7
View File
@@ -42,10 +42,12 @@ func _setup_viewmodel_viewport() -> void:
# Shared world so it renders main scene's objects that have layer 20
# (In Godot 4, own_world_3d is false by default, but let's be explicit)
sub_viewport.own_world_3d = false
sub_viewport.msaa_3d = Viewport.MSAA_4X # keep ink outlines crisp on the viewmodel
sv_container.add_child(sub_viewport)
vm_camera = Camera3D.new()
vm_camera.cull_mask = 1 << 19 # Only see layer 20
vm_camera.near = 0.01 # viewmodel sits close to the lens; avoid clipping
sub_viewport.add_child(vm_camera)
# Add a light specifically for the viewmodel in case main lights don't reach
@@ -232,6 +234,11 @@ func _spawn_weapon(slot: int, weapon_id: String) -> void:
_add_procedural_arms(w)
# Cel-shade the view model + arms so the first-person weapon matches
# the world's look. No inverted-hull outline: FBX weapon models have
# hard-edged normals that make the hull tear into scratchy artifacts.
LevelMaterials.apply_toon_recursive(w, 0.0)
# Set weapons to viewmodel layer
_set_layer_recursive(w, 1 << 19)