Feat/visual audio overhaul #21

Merged
Dotts merged 31 commits from feat/visual-audio-overhaul into main 2026-07-21 14:09:18 -07:00
94 changed files with 651 additions and 213 deletions
Showing only changes of commit 6c8b0ddd28 - Show all commits
+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.
+12 -1
View File
@@ -163,6 +163,11 @@ func _ready() -> void:
calf_r.cast_shadow = shadow_setting calf_r.cast_shadow = shadow_setting
calf_r_pivot.add_child(calf_r) 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 ## Skin System
var current_skin: PlayerSkin var current_skin: PlayerSkin
var skin_model: Node3D 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.albedo_color = Color(0.95, 0.85, 0.78)
hmat.roughness = 0.8 hmat.roughness = 0.8
head.mesh.material = hmat 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: func update_state(state: String, speed: float, is_crouching: bool = false) -> void:
current_state = state current_state = state
@@ -257,7 +265,10 @@ func set_weapon(script_path: String) -> void:
w.set_process_input(false) w.set_process_input(false)
if shadows_only: if shadows_only:
_set_shadows_recursive(w) _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 # Force position after the weapon's _build_model() sets it for 1st person
w.position = Vector3(-0.15, 1.0, 0.4) w.position = Vector3(-0.15, 1.0, 0.4)
w.rotation_degrees = Vector3(0, 180, 0) w.rotation_degrees = Vector3(0, 180, 0)
+4 -1
View File
@@ -353,7 +353,10 @@ func set_weapon(script_path: String) -> void:
w.ready.connect(func(): w.ready.connect(func():
w.set_process(false) w.set_process(false)
w.set_process_input(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) _set_shadows_recursive(w)
# Undo the first-person viewmodel placement from the weapon's _ready. # Undo the first-person viewmodel placement from the weapon's _ready.
w.position = Vector3(0.0, 0.08, 0.03) 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) 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 ────────────────────────────────────────────────────────────────── # ── Playback ──────────────────────────────────────────────────────────────────
func _build_pools() -> void: func _build_pools() -> void:
+20 -65
View File
@@ -36,6 +36,8 @@ var death_screen: Control
var ragdoll_instance: Node3D var ragdoll_instance: Node3D
# Audio # Audio
var footstep_player: AudioStreamPlayer var footstep_player: AudioStreamPlayer
var footstep_streams: Array = []
var land_player: AudioStreamPlayer
var dash_player: AudioStreamPlayer var dash_player: AudioStreamPlayer
var recent_attackers: Dictionary = {} # attacker_id: timestamp var recent_attackers: Dictionary = {} # attacker_id: timestamp
var hit_player: AudioStreamPlayer var hit_player: AudioStreamPlayer
@@ -182,6 +184,7 @@ func set_third_person(on: bool) -> void:
return return
third_person = on third_person = on
if is_instance_valid(_tp_camera): if is_instance_valid(_tp_camera):
_tp_camera.fov = SettingsManager.world_fov # stay in sync with settings
_tp_camera.current = on _tp_camera.current = on
if is_instance_valid(camera): if is_instance_valid(camera):
camera.current = not on camera.current = not on
@@ -250,8 +253,18 @@ func _hide_remote_weapons() -> void:
func _setup_audio() -> void: func _setup_audio() -> void:
footstep_player = AudioStreamPlayer.new() footstep_player = AudioStreamPlayer.new()
footstep_player.bus = "SFX" 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) 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 = AudioStreamPlayer.new()
jump_player.bus = "SFX" jump_player.bus = "SFX"
@@ -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) _spawn_remote_projectile(origin, target_or_dir, _weapon_name)
func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void: func _spawn_remote_tracer(origin: Vector3, target: Vector3) -> void:
var tracer = Node3D.new() HitscanTracer.spawn_bolt(get_tree().current_scene, origin, target)
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)
func _spawn_remote_projectile(origin: Vector3, fire_dir: Vector3, weapon_name: String) -> void: 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! # 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: func rpc_play_explosion(pos: Vector3, radius: float) -> void:
if is_multiplayer_authority(): return if is_multiplayer_authority(): return
var visual = MeshInstance3D.new() ExplosionVFX.spawn(get_tree(), pos, radius)
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)
@rpc("any_peer", "call_local", "reliable") @rpc("any_peer", "call_local", "reliable")
func server_take_damage(amount: float, hit_pos: Vector3, attacker_id: int, weapon_name: String, impulse: Vector3) -> void: 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": elif ev == "dash":
_speedline_burst = 1.0 _speedline_burst = 1.0
elif ev == "land": elif ev == "land":
# Landing thud: reuse the footstep sample, pitched down and louder if land_player:
# with impact. Ground state resets pitch/volume before each step.
if footstep_player:
var heavy: bool = data.get("heavy", false) var heavy: bool = data.get("heavy", false)
footstep_player.pitch_scale = 0.55 if heavy else 0.7 land_player.pitch_scale = (0.8 if heavy else 1.0) * randf_range(0.95, 1.05)
footstep_player.volume_db = 2.0 if heavy else -2.0 land_player.volume_db = 2.0 if heavy else -3.0
footstep_player.play() land_player.play()
func _process(delta: float) -> void: func _process(delta: float) -> void:
# Remote players: interpolate toward the owner's synced transform and # 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 _footstep_timer -= delta
if _footstep_timer <= 0.0: if _footstep_timer <= 0.0:
if player.footstep_player: if player.footstep_player:
# Reset pitch/volume (the landing thud reuses this player) and # Random sample + slight pitch variation so steps don't machine-gun.
# add slight 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.pitch_scale = randf_range(0.92, 1.08)
player.footstep_player.volume_db = -6.0 player.footstep_player.volume_db = -6.0
player.footstep_player.play() player.footstep_player.play()
+1
View File
@@ -126,3 +126,4 @@ emote={
[rendering] [rendering]
rendering_device/driver.windows="d3d12" 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.light_energy = 0.25
fill.shadow_enabled = false fill.shadow_enabled = false
level.add_child(fill) 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 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. # land near the original flat color while keeping the grid contrast.
mat.set_shader_parameter("albedo_color", Color( 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))) 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 _cache[key] = mat
return mat return mat
+6 -1
View File
@@ -18,7 +18,12 @@ var _debug_ui_panel: PanelContainer
func _ready() -> void: func _ready() -> void:
# Hide mouse # Hide mouse
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED 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() _build_hud()
# Multiplayer Spawning # Multiplayer Spawning
+38 -19
View File
@@ -9,7 +9,23 @@ var _host_lobby_players_list: ItemList
func _ready() -> void: func _ready() -> void:
Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) 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") var nm = get_node_or_null("/root/NetworkManager")
if nm: if nm:
nm.connection_succeeded.connect(_on_connection_succeeded) nm.connection_succeeded.connect(_on_connection_succeeded)
@@ -25,6 +41,7 @@ func _ready() -> void:
var viewport = SubViewport.new() var viewport = SubViewport.new()
viewport.own_world_3d = true viewport.own_world_3d = true
viewport.msaa_3d = Viewport.MSAA_4X
right_panel.add_child(viewport) right_panel.add_child(viewport)
_build_3d_diorama(viewport) _build_3d_diorama(viewport)
@@ -40,13 +57,14 @@ func _ready() -> void:
_ui_vbox.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50) _ui_vbox.set_anchor_and_offset(SIDE_BOTTOM, 1.0, -50)
add_child(_ui_vbox) add_child(_ui_vbox)
# Title # Title — big comic wordmark with a slight kinetic tilt
var title = Label.new() var title = Label.new()
title.text = "Papaya Shooter" title.text = "PAPAYA SHOOTER"
title.add_theme_font_size_override("font_size", 72) title.add_theme_font_size_override("font_size", 92)
title.add_theme_color_override("font_color", Color(1, 0.6, 0.2)) # Papaya orange title.add_theme_color_override("font_color", UITheme.PAPAYA)
title.add_theme_color_override("font_outline_color", Color(0, 0, 0)) title.add_theme_color_override("font_outline_color", UITheme.INK)
title.add_theme_constant_override("outline_size", 12) title.add_theme_constant_override("outline_size", 16)
title.rotation_degrees = -2.5
_ui_vbox.add_child(title) _ui_vbox.add_child(title)
# Spacer # Spacer
@@ -269,6 +287,9 @@ func _ready() -> void:
_host_lobby_players_list.add_theme_font_size_override("font_size", 24) _host_lobby_players_list.add_theme_font_size_override("font_size", 24)
hl_right.add_child(_host_lobby_players_list) 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: func _load_maps_modularly(target_hbox: Container, is_multiplayer: bool, card_size: Vector2) -> void:
var maps_dir = "res://scenes/maps/" var maps_dir = "res://scenes/maps/"
var dir = DirAccess.open(maps_dir) var dir = DirAccess.open(maps_dir)
@@ -492,13 +513,9 @@ func _build_3d_diorama(viewport: SubViewport) -> void:
var world = Node3D.new() var world = Node3D.new()
viewport.add_child(world) viewport.add_child(world)
# Lighting and Env # Lighting and Env — same stylized sunset sky as in-game for continuity
var env = WorldEnvironment.new() var env = WorldEnvironment.new()
var environment = Environment.new() env.environment = LevelEnvironment.make_environment("sunset")
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
world.add_child(env) world.add_child(env)
var sun = DirectionalLight3D.new() var sun = DirectionalLight3D.new()
@@ -514,22 +531,21 @@ func _build_3d_diorama(viewport: SubViewport) -> void:
fill.light_energy = 0.5 fill.light_energy = 0.5
world.add_child(fill) world.add_child(fill)
# Floor # Floor — toon world-grid like the levels
var floor_mesh = MeshInstance3D.new() var floor_mesh = MeshInstance3D.new()
floor_mesh.mesh = PlaneMesh.new() floor_mesh.mesh = PlaneMesh.new()
floor_mesh.mesh.size = Vector2(20, 20) floor_mesh.mesh.size = Vector2(20, 20)
var floor_mat = StandardMaterial3D.new() floor_mesh.mesh.surface_set_material(0, LevelMaterials.tinted(Color(0.32, 0.3, 0.38)))
floor_mat.albedo_color = Color(0.2, 0.2, 0.2)
floor_mesh.mesh.surface_set_material(0, floor_mat)
world.add_child(floor_mesh) world.add_child(floor_mesh)
# Player Model (Sliding Pose) # Player Model (Sliding Pose)
var player_model = Node3D.new() var player_model = Node3D.new()
player_model.position = Vector3(0, 0.5, 0) # Lowered for slide player_model.position = Vector3(0, 0.5, 0) # Lowered for slide
world.add_child(player_model) world.add_child(player_model)
var body_mat = StandardMaterial3D.new() var body_mat = StandardMaterial3D.new()
body_mat.albedo_color = Color(0.3, 0.6, 0.9) 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) # Torso (Capsule tilted back)
var torso = MeshInstance3D.new() var torso = MeshInstance3D.new()
@@ -576,6 +592,9 @@ func _build_3d_diorama(viewport: SubViewport) -> void:
if shotgun.has_method("set_process_input"): if shotgun.has_method("set_process_input"):
shotgun.set_process_input(false) 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 # Camera
var camera = Camera3D.new() var camera = Camera3D.new()
world.add_child(camera) world.add_child(camera)
+7 -6
View File
@@ -16,7 +16,10 @@ var _scoreboard_grid: GridContainer
func _ready() -> void: func _ready() -> void:
_nm = get_node_or_null("/root/NetworkManager") _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 # Top HUD
var top_hbox = HBoxContainer.new() var top_hbox = HBoxContainer.new()
top_hbox.set_anchors_preset(Control.PRESET_TOP_WIDE) top_hbox.set_anchors_preset(Control.PRESET_TOP_WIDE)
@@ -37,8 +40,8 @@ func _ready() -> void:
_local_kills_label = Label.new() _local_kills_label = Label.new()
_local_kills_label.text = "0 Kills" _local_kills_label.text = "0 Kills"
_local_kills_label.add_theme_font_size_override("font_size", 64) _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_color", UITheme.PAPAYA)
_local_kills_label.add_theme_color_override("font_outline_color", Color.BLACK) _local_kills_label.add_theme_color_override("font_outline_color", UITheme.INK)
_local_kills_label.add_theme_constant_override("outline_size", 12) _local_kills_label.add_theme_constant_override("outline_size", 12)
top_hbox.add_child(_local_kills_label) top_hbox.add_child(_local_kills_label)
@@ -68,9 +71,7 @@ func _ready() -> void:
_scoreboard_panel = PanelContainer.new() _scoreboard_panel = PanelContainer.new()
_scoreboard_panel.custom_minimum_size = Vector2(800, 600) _scoreboard_panel.custom_minimum_size = Vector2(800, 600)
var sb_style = StyleBoxFlat.new() # Styled by the shared UITheme PanelContainer stylebox (ink-border card)
sb_style.bg_color = Color(0, 0, 0, 0.8)
_scoreboard_panel.add_theme_stylebox_override("panel", sb_style)
_scoreboard_panel.hide() _scoreboard_panel.hide()
center_container.add_child(_scoreboard_panel) 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 # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.position = muzzle_flash.position
fire_sound.volume_db = -5.0 fire_sound.volume_db = -5.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
+2 -2
View File
@@ -34,13 +34,13 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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 fire_sound.volume_db = 0.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
func _process(delta: float) -> void: func _process(delta: float) -> void:
+4 -30
View File
@@ -111,6 +111,7 @@ func _fire() -> void:
func _play_muzzle_flash() -> void: func _play_muzzle_flash() -> void:
if muzzle_flash: if muzzle_flash:
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
muzzle_flash.light_energy = 8.0 muzzle_flash.light_energy = 8.0
var tween = create_tween() var tween = create_tween()
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05) 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) player.server_play_fire_effects.rpc_id(1, origin, final_target, name, true)
func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void: func _spawn_tracer(_origin: Vector3, final_target: Vector3) -> void:
# Spawn cosmetic tracer # Cel-styled cosmetic tracer from the barrel tip
var tracer = Node3D.new() var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
tracer.set_script(load("res://weapons/hitscan_tracer.gd")) HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)
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)
+1
View File
@@ -109,6 +109,7 @@ func _fire() -> void:
func _play_muzzle_flash() -> void: func _play_muzzle_flash() -> void:
if muzzle_flash: if muzzle_flash:
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
muzzle_flash.light_energy = 8.0 muzzle_flash.light_energy = 8.0
var tween = create_tween() var tween = create_tween()
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05) tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.05)
+2 -2
View File
@@ -44,14 +44,14 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.position = muzzle_flash.position
fire_sound.volume_db = -3.0 fire_sound.volume_db = -3.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
func _process(delta: float) -> void: func _process(delta: float) -> void:
+5 -31
View File
@@ -44,14 +44,14 @@ func _build_model() -> void:
# Audio Players # Audio Players
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.position = muzzle_flash.position
fire_sound.volume_db = -20.0 fire_sound.volume_db = -20.0
add_child(fire_sound) add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) add_child(reload_sound)
func _process(delta: float) -> void: func _process(delta: float) -> void:
@@ -127,6 +127,7 @@ func _apply_impulse() -> void:
func _play_muzzle_flash() -> void: func _play_muzzle_flash() -> void:
if muzzle_flash: if muzzle_flash:
ExplosionVFX.muzzle_flash(muzzle_flash.get_parent(), muzzle_flash.position)
muzzle_flash.light_energy = 8.0 muzzle_flash.light_energy = 8.0
var tween = create_tween() var tween = create_tween()
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.1) 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 # ImpactSpawner not available in this build, skip
pass pass
# Spawn cosmetic tracer # Cel-styled cosmetic tracer per pellet
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
var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position var visual_origin = muzzle_flash.global_position if muzzle_flash else global_position
HitscanTracer.spawn_bolt(get_tree().current_scene, visual_origin, final_target)
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)
+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): 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) owner_player.server_play_explosion.rpc_id(1, pos, explosion_radius)
# Local Visual Effect # Local visual + audio: shared cel-shaded burst (also used by remote replay)
var visual = MeshInstance3D.new() ExplosionVFX.spawn(get_tree(), pos, explosion_radius)
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)
+35
View File
@@ -6,6 +6,41 @@ var target_position: Vector3
var distance_traveled: float = 0.0 var distance_traveled: float = 0.0
var max_distance: 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: func _ready() -> void:
max_distance = global_position.distance_to(target_position) max_distance = global_position.distance_to(target_position)
if max_distance < 0.1: if max_distance < 0.1:
+9 -1
View File
@@ -19,7 +19,9 @@ static func _ensure_mats():
for c in colors: p_colors.append(c) for c in colors: p_colors.append(c)
g.offsets = p_offsets g.offsets = p_offsets
g.colors = p_colors 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() var t = GradientTexture2D.new()
t.gradient = g t.gradient = g
t.fill = GradientTexture2D.FILL_RADIAL t.fill = GradientTexture2D.FILL_RADIAL
@@ -62,6 +64,12 @@ static func _ensure_mats():
static func spawn(scene_tree: SceneTree, type: String, pos: Vector3, normal: Vector3, size: float = 0.2): static func spawn(scene_tree: SceneTree, type: String, pos: Vector3, normal: Vector3, size: float = 0.2):
_ensure_mats() _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 mi = MeshInstance3D.new()
var qm = QuadMesh.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) # Audio (reusing shotgun reload or something generic for now, ideally a swish)
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.pitch_scale = 1.2
fire_sound.volume_db = 0.0 fire_sound.volume_db = 0.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
+2 -2
View File
@@ -35,12 +35,12 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.position = muzzle_flash.position
fire_sound.volume_db = -8.0 fire_sound.volume_db = -8.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
+2 -2
View File
@@ -36,12 +36,12 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void: func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -35,12 +35,12 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.position = muzzle_flash.position
fire_sound.volume_db = -10.0 fire_sound.volume_db = -10.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
+2 -2
View File
@@ -51,14 +51,14 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.position = muzzle_flash.position
fire_sound.volume_db = -5.0 fire_sound.volume_db = -5.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void: func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -45,14 +45,14 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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.position = muzzle_flash.position
fire_sound.volume_db = -5.0 fire_sound.volume_db = -5.0
model_root.add_child(fire_sound) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void: func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -38,12 +38,12 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void: func _spawn_custom_projectile(origin: Vector3, fire_dir: Vector3) -> void:
+2 -2
View File
@@ -54,12 +54,12 @@ func _build_model() -> void:
# Audio # Audio
fire_sound = AudioStreamPlayer3D.new() fire_sound = AudioStreamPlayer3D.new()
fire_sound.bus = "SFX" 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) model_root.add_child(fire_sound)
reload_sound = AudioStreamPlayer3D.new() reload_sound = AudioStreamPlayer3D.new()
reload_sound.bus = "SFX" 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) model_root.add_child(reload_sound)
func _process(delta: float) -> void: func _process(delta: float) -> void:
+10 -3
View File
@@ -41,11 +41,13 @@ func _setup_viewmodel_viewport() -> void:
sub_viewport.transparent_bg = true sub_viewport.transparent_bg = true
# Shared world so it renders main scene's objects that have layer 20 # 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) # (In Godot 4, own_world_3d is false by default, but let's be explicit)
sub_viewport.own_world_3d = false 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) sv_container.add_child(sub_viewport)
vm_camera = Camera3D.new() vm_camera = Camera3D.new()
vm_camera.cull_mask = 1 << 19 # Only see layer 20 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) sub_viewport.add_child(vm_camera)
# Add a light specifically for the viewmodel in case main lights don't reach # Add a light specifically for the viewmodel in case main lights don't reach
@@ -231,7 +233,12 @@ func _spawn_weapon(slot: int, weapon_id: String) -> void:
w.set_meta("script_path", script_path) w.set_meta("script_path", script_path)
_add_procedural_arms(w) _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 weapons to viewmodel layer
_set_layer_recursive(w, 1 << 19) _set_layer_recursive(w, 1 << 19)