feat: implement damage dummy system, floating UI numbers, and double-barrel shotgun mechanics
This commit is contained in:
@@ -31,6 +31,7 @@ func _build_geometry() -> void:
|
|||||||
_build_platforms()
|
_build_platforms()
|
||||||
_build_wall_run_corridor()
|
_build_wall_run_corridor()
|
||||||
_build_speed_corridor()
|
_build_speed_corridor()
|
||||||
|
_build_target_dummy()
|
||||||
_build_lighting()
|
_build_lighting()
|
||||||
_build_player()
|
_build_player()
|
||||||
|
|
||||||
@@ -207,6 +208,15 @@ func _build_speed_corridor() -> void:
|
|||||||
_box_static(Vector3(34, 0.15, float(i)), Vector3(8, 0.3, 0.5), Color(0.8, 0.8, 0.2), "SpeedBump_%d" % i)
|
_box_static(Vector3(34, 0.15, float(i)), Vector3(8, 0.3, 0.5), Color(0.8, 0.8, 0.2), "SpeedBump_%d" % i)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Dummies ───────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
func _build_target_dummy() -> void:
|
||||||
|
var dummy = load("res://entities/target_dummy.gd").new()
|
||||||
|
dummy.name = "TargetDummy"
|
||||||
|
# Place it somewhat centrally in the main arena, but out of the immediate spawn area
|
||||||
|
dummy.position = Vector3(0, 0, -10)
|
||||||
|
add_child(dummy)
|
||||||
|
|
||||||
# ── Lighting ──────────────────────────────────────────────────────────────────
|
# ── Lighting ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func _build_lighting() -> void:
|
func _build_lighting() -> void:
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
extends StaticBody3D
|
||||||
|
class_name TargetDummy
|
||||||
|
|
||||||
|
var mesh_instance: MeshInstance3D
|
||||||
|
var dps_label: Label3D
|
||||||
|
|
||||||
|
# Array of dictionaries: { "time": float, "amount": float }
|
||||||
|
var damage_history: Array = []
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
add_to_group("enemies") # Just in case we need to filter
|
||||||
|
|
||||||
|
# Collision Shape
|
||||||
|
var shape = CollisionShape3D.new()
|
||||||
|
shape.shape = CapsuleShape3D.new()
|
||||||
|
shape.shape.radius = 0.5
|
||||||
|
shape.shape.height = 2.0
|
||||||
|
shape.position = Vector3(0, 1.0, 0)
|
||||||
|
add_child(shape)
|
||||||
|
|
||||||
|
# Mesh
|
||||||
|
mesh_instance = MeshInstance3D.new()
|
||||||
|
var mesh = CapsuleMesh.new()
|
||||||
|
mesh.radius = 0.5
|
||||||
|
mesh.height = 2.0
|
||||||
|
mesh_instance.mesh = mesh
|
||||||
|
|
||||||
|
var mat = StandardMaterial3D.new()
|
||||||
|
mat.albedo_color = Color(0.8, 0.2, 0.2) # Red dummy
|
||||||
|
mat.roughness = 0.7
|
||||||
|
mesh.material = mat
|
||||||
|
mesh_instance.position = Vector3(0, 1.0, 0)
|
||||||
|
add_child(mesh_instance)
|
||||||
|
|
||||||
|
# DPS Label
|
||||||
|
dps_label = Label3D.new()
|
||||||
|
dps_label.text = "DPS: 0"
|
||||||
|
dps_label.pixel_size = 0.01
|
||||||
|
dps_label.billboard = BaseMaterial3D.BILLBOARD_ENABLED
|
||||||
|
dps_label.position = Vector3(0, 2.3, 0)
|
||||||
|
dps_label.modulate = Color(1, 0.8, 0)
|
||||||
|
add_child(dps_label)
|
||||||
|
|
||||||
|
func take_damage(amount: float, hit_position: Vector3, source: Node = null) -> void:
|
||||||
|
var now = Time.get_ticks_msec() / 1000.0
|
||||||
|
damage_history.append({"time": now, "amount": amount})
|
||||||
|
|
||||||
|
if source and source.has_method("spawn_damage_number"):
|
||||||
|
source.spawn_damage_number(amount, hit_position)
|
||||||
|
|
||||||
|
_wiggle()
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
_update_dps()
|
||||||
|
|
||||||
|
func _update_dps() -> void:
|
||||||
|
var now = Time.get_ticks_msec() / 1000.0
|
||||||
|
var total_damage: float = 0.0
|
||||||
|
|
||||||
|
# Keep only last 5 seconds
|
||||||
|
var valid_history = []
|
||||||
|
for dmg in damage_history:
|
||||||
|
if now - dmg["time"] <= 5.0:
|
||||||
|
valid_history.append(dmg)
|
||||||
|
total_damage += dmg["amount"]
|
||||||
|
|
||||||
|
damage_history = valid_history
|
||||||
|
|
||||||
|
if damage_history.size() > 0:
|
||||||
|
var dps = total_damage / 5.0
|
||||||
|
dps_label.text = "DPS: %.1f" % dps
|
||||||
|
else:
|
||||||
|
dps_label.text = "DPS: 0"
|
||||||
|
|
||||||
|
func _wiggle() -> void:
|
||||||
|
# Small tween to shake the dummy
|
||||||
|
var tween = create_tween()
|
||||||
|
var orig_pos = Vector3(0, 1.0, 0)
|
||||||
|
var wiggle_dir = Vector3(randf_range(-0.1, 0.1), 0, randf_range(-0.1, 0.1))
|
||||||
|
|
||||||
|
tween.tween_property(mesh_instance, "position", orig_pos + wiggle_dir, 0.05)
|
||||||
|
tween.tween_property(mesh_instance, "position", orig_pos - wiggle_dir, 0.05)
|
||||||
|
tween.tween_property(mesh_instance, "position", orig_pos, 0.05)
|
||||||
|
|
||||||
|
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://bts5u08jyv5la
|
||||||
@@ -8,6 +8,7 @@ signal chain_updated(count: int, bonus: float)
|
|||||||
var _machine: MovementStateMachine = null
|
var _machine: MovementStateMachine = null
|
||||||
var head_pivot: Node3D = null # FPSCameraRig node
|
var head_pivot: Node3D = null # FPSCameraRig node
|
||||||
var camera: Camera3D = null
|
var camera: Camera3D = null
|
||||||
|
var _damage_layer: CanvasLayer = null
|
||||||
|
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
@@ -22,9 +23,29 @@ func _ready() -> void:
|
|||||||
if head_pivot:
|
if head_pivot:
|
||||||
camera = head_pivot.get_node_or_null("Camera3D")
|
camera = head_pivot.get_node_or_null("Camera3D")
|
||||||
|
|
||||||
|
_damage_layer = CanvasLayer.new()
|
||||||
|
add_child(_damage_layer)
|
||||||
|
|
||||||
set_process(true)
|
set_process(true)
|
||||||
set_physics_process(true)
|
set_physics_process(true)
|
||||||
|
|
||||||
|
func spawn_damage_number(amount: float, hit_pos: Vector3) -> void:
|
||||||
|
if not camera or not _damage_layer: return
|
||||||
|
|
||||||
|
var label = load("res://ui/floating_damage_text.gd").new()
|
||||||
|
label.text = str(round(amount))
|
||||||
|
label.add_theme_font_size_override("font_size", 32)
|
||||||
|
label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.1))
|
||||||
|
label.add_theme_color_override("font_outline_color", Color(0, 0, 0, 0.8))
|
||||||
|
label.add_theme_constant_override("outline_size", 6)
|
||||||
|
|
||||||
|
# Small random offset in 3D space so multiple pellets don't perfectly overlap
|
||||||
|
var offset = Vector3(randf_range(-0.4, 0.4), randf_range(-0.4, 0.4), randf_range(-0.4, 0.4))
|
||||||
|
label.target_pos = hit_pos + offset
|
||||||
|
label.camera = camera
|
||||||
|
|
||||||
|
_damage_layer.add_child(label)
|
||||||
|
|
||||||
|
|
||||||
func _ensure_machine() -> MovementStateMachine:
|
func _ensure_machine() -> MovementStateMachine:
|
||||||
if is_instance_valid(_machine):
|
if is_instance_valid(_machine):
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
extends Label
|
||||||
|
class_name FloatingDamageText
|
||||||
|
|
||||||
|
var target_pos: Vector3
|
||||||
|
var camera: Camera3D
|
||||||
|
var float_offset: float = 0.0
|
||||||
|
|
||||||
|
func _ready() -> void:
|
||||||
|
# Setup Tween for floating up and fading out
|
||||||
|
var tween = create_tween()
|
||||||
|
tween.set_parallel(true)
|
||||||
|
# Float up 100 pixels
|
||||||
|
tween.tween_property(self, "float_offset", 100.0, 1.0)
|
||||||
|
# Fade out
|
||||||
|
tween.tween_property(self, "modulate:a", 0.0, 1.0)
|
||||||
|
|
||||||
|
tween.chain().tween_callback(queue_free)
|
||||||
|
|
||||||
|
func _process(_delta: float) -> void:
|
||||||
|
if not is_instance_valid(camera):
|
||||||
|
queue_free()
|
||||||
|
return
|
||||||
|
|
||||||
|
if camera.is_position_behind(target_pos):
|
||||||
|
visible = false
|
||||||
|
else:
|
||||||
|
visible = true
|
||||||
|
var screen_pos = camera.unproject_position(target_pos)
|
||||||
|
# Center the label on the point, then subtract the float offset (moving up on screen)
|
||||||
|
position = screen_pos - size / 2.0 + Vector2(0, -float_offset)
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
uid://b7i68343deyx
|
||||||
@@ -9,6 +9,7 @@ var reload_timer: float = 0.0
|
|||||||
|
|
||||||
var player: CharacterBody3D
|
var player: CharacterBody3D
|
||||||
var camera: Camera3D
|
var camera: Camera3D
|
||||||
|
var muzzle_flash: OmniLight3D
|
||||||
|
|
||||||
func _ready() -> void:
|
func _ready() -> void:
|
||||||
set_process_input(true)
|
set_process_input(true)
|
||||||
@@ -53,6 +54,14 @@ func _build_model() -> void:
|
|||||||
barrel_r.position = Vector3(0.04, 0.03, -0.1)
|
barrel_r.position = Vector3(0.04, 0.03, -0.1)
|
||||||
add_child(barrel_r)
|
add_child(barrel_r)
|
||||||
|
|
||||||
|
# Muzzle Flash Light
|
||||||
|
muzzle_flash = OmniLight3D.new()
|
||||||
|
muzzle_flash.light_color = Color(1.0, 0.8, 0.3)
|
||||||
|
muzzle_flash.light_energy = 0.0
|
||||||
|
muzzle_flash.omni_range = 4.0
|
||||||
|
muzzle_flash.position = Vector3(0, 0.03, -0.45)
|
||||||
|
add_child(muzzle_flash)
|
||||||
|
|
||||||
func _process(delta: float) -> void:
|
func _process(delta: float) -> void:
|
||||||
if reloading:
|
if reloading:
|
||||||
reload_timer -= delta
|
reload_timer -= delta
|
||||||
@@ -86,6 +95,8 @@ func _try_fire() -> void:
|
|||||||
shells -= 1
|
shells -= 1
|
||||||
print("BANG! Shells left: ", shells)
|
print("BANG! Shells left: ", shells)
|
||||||
_apply_impulse()
|
_apply_impulse()
|
||||||
|
_shoot_hitscan()
|
||||||
|
_play_muzzle_flash()
|
||||||
|
|
||||||
# Start reload timer if we just shot the LAST shell
|
# Start reload timer if we just shot the LAST shell
|
||||||
if shells == 0:
|
if shells == 0:
|
||||||
@@ -117,3 +128,50 @@ func _apply_impulse() -> void:
|
|||||||
if push_dir.y > 0:
|
if push_dir.y > 0:
|
||||||
# Just slightly lift the player to break floor contact immediately
|
# Just slightly lift the player to break floor contact immediately
|
||||||
player.position.y += 0.05
|
player.position.y += 0.05
|
||||||
|
|
||||||
|
func _play_muzzle_flash() -> void:
|
||||||
|
if muzzle_flash:
|
||||||
|
muzzle_flash.light_energy = 8.0
|
||||||
|
var tween = create_tween()
|
||||||
|
tween.tween_property(muzzle_flash, "light_energy", 0.0, 0.1)
|
||||||
|
|
||||||
|
func _shoot_hitscan() -> void:
|
||||||
|
if not camera: return
|
||||||
|
|
||||||
|
var space_state = camera.get_world_3d().direct_space_state
|
||||||
|
var origin = camera.global_position
|
||||||
|
var forward = -camera.global_transform.basis.z.normalized()
|
||||||
|
var right = camera.global_transform.basis.x.normalized()
|
||||||
|
var up = camera.global_transform.basis.y.normalized()
|
||||||
|
|
||||||
|
var num_pellets = 12
|
||||||
|
var max_range = 50.0
|
||||||
|
var base_dmg = 15.0
|
||||||
|
var min_dmg = 3.0
|
||||||
|
var falloff_start = 8.0
|
||||||
|
var spread_angle = 0.08 # Radians
|
||||||
|
|
||||||
|
for i in range(num_pellets):
|
||||||
|
# Random spread inside cone
|
||||||
|
var r = sqrt(randf()) * spread_angle
|
||||||
|
var theta = randf() * PI * 2.0
|
||||||
|
var offset_dir = (right * cos(theta) + up * sin(theta)) * r
|
||||||
|
var pellet_dir = (forward + offset_dir).normalized()
|
||||||
|
|
||||||
|
var target = origin + pellet_dir * max_range
|
||||||
|
|
||||||
|
var query = PhysicsRayQueryParameters3D.create(origin, target)
|
||||||
|
if player:
|
||||||
|
query.exclude = [player.get_rid()]
|
||||||
|
|
||||||
|
var result = space_state.intersect_ray(query)
|
||||||
|
if result:
|
||||||
|
var hit_dist = origin.distance_to(result.position)
|
||||||
|
var damage = base_dmg
|
||||||
|
|
||||||
|
if hit_dist > falloff_start:
|
||||||
|
var falloff_factor = clampf((hit_dist - falloff_start) / (max_range - falloff_start), 0.0, 1.0)
|
||||||
|
damage = lerpf(base_dmg, min_dmg, falloff_factor)
|
||||||
|
|
||||||
|
if result.collider.has_method("take_damage"):
|
||||||
|
result.collider.take_damage(damage, result.position, player)
|
||||||
|
|||||||
Reference in New Issue
Block a user