Turns the sandbox into a survival roguelike loop: outfit your gladiator with banked gold, enter the arena, earn gold from kills and wave clears, die, keep 10% of your total value (floored at the 500 fresh-start). Economy (GameState autoload): - banked_gold, owned items, and loadout persist to user://save.cfg - Kill gold (basic 10g, armed 25g) credited server-side to the killer - Wave-clear bonus (25 + 5/wave) for all players; wave synced to peers - Death settlement liquidates gear, guarded against double-fire Character creation / shop: - Menu reframed: armory shop + live Character Sheet loadout preview - Character Sheet gains preview mode (renders from loadout, no player) - Buy/Equip with hand rules; two-handers and off-hands displace each other symmetrically (shop and in-game pickups) - Chosen loadout auto-equips on spawn via existing RPC path Combat feel: - Attacks snap to camera facing and lock direction for the swing - Dash commits to its direction for the full duration - Weapon slots show name text when no icon is set - Hitbox/hurtbox debug meshes hidden by default (H toggles) Death is final: players no longer respawn; a results screen shows waves, time, kills, and the settlement, then returns to camp. Armed enemies no longer respawn (were an infinite gold farm and respawned invisible).
150 lines
4.7 KiB
GDScript
150 lines
4.7 KiB
GDScript
extends Area3D
|
|
class_name HitBox
|
|
|
|
## A component that deals damage to HurtBoxes
|
|
## Attach to weapons or attack effects
|
|
## Uses direct physics queries for reliable hit detection
|
|
|
|
signal hit_landed(target: Node, damage: float, knockback: float, attacker_pos: Vector3)
|
|
|
|
## Damage dealt on hit
|
|
@export var damage: float = 10.0
|
|
## Knockback force applied
|
|
@export var knockback: float = 5.0
|
|
## Owner entity (used to prevent self-damage and identify attacker)
|
|
@export var owner_entity: Node = null
|
|
|
|
## Global debug visibility toggle (press H in-game to toggle)
|
|
static var debug_visible: bool = false
|
|
|
|
## Whether hitbox is currently active (only deals damage when active)
|
|
var is_active: bool = false
|
|
## Tracks entities hit this attack (prevents multi-hit)
|
|
var _hits_this_attack: Array[Node] = []
|
|
## Shape for queries (extracted from child CollisionShape3D)
|
|
var _query_shape: Shape3D = null
|
|
## Debug mesh for visualization
|
|
var _debug_mesh: MeshInstance3D = null
|
|
var _debug_material: StandardMaterial3D = null
|
|
|
|
func _ready():
|
|
# Find the collision shape for queries
|
|
for child in get_children():
|
|
if child is CollisionShape3D and child.shape:
|
|
_query_shape = child.shape
|
|
_create_debug_visualization(child)
|
|
break
|
|
|
|
func _create_debug_visualization(collision_shape: CollisionShape3D):
|
|
# Create a semi-transparent red mesh to visualize the hitbox
|
|
_debug_mesh = MeshInstance3D.new()
|
|
_debug_material = StandardMaterial3D.new()
|
|
_debug_material.albedo_color = Color(1.0, 0.0, 0.0, 0.4) # Red, semi-transparent
|
|
_debug_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
|
|
_debug_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
|
|
_debug_material.cull_mode = BaseMaterial3D.CULL_DISABLED # Visible from both sides
|
|
|
|
# Create mesh matching the collision shape
|
|
var mesh: Mesh = null
|
|
if collision_shape.shape is BoxShape3D:
|
|
var box_mesh = BoxMesh.new()
|
|
box_mesh.size = collision_shape.shape.size
|
|
mesh = box_mesh
|
|
elif collision_shape.shape is SphereShape3D:
|
|
var sphere_mesh = SphereMesh.new()
|
|
sphere_mesh.radius = collision_shape.shape.radius
|
|
sphere_mesh.height = collision_shape.shape.radius * 2
|
|
mesh = sphere_mesh
|
|
elif collision_shape.shape is CapsuleShape3D:
|
|
var capsule_mesh = CapsuleMesh.new()
|
|
capsule_mesh.radius = collision_shape.shape.radius
|
|
capsule_mesh.height = collision_shape.shape.height
|
|
mesh = capsule_mesh
|
|
|
|
if mesh:
|
|
_debug_mesh.mesh = mesh
|
|
_debug_mesh.material_override = _debug_material
|
|
_debug_mesh.visible = HitBox.debug_visible
|
|
# Don't set transform - it inherits from parent CollisionShape3D
|
|
collision_shape.add_child(_debug_mesh)
|
|
|
|
func _physics_process(_delta):
|
|
# Update debug visibility
|
|
if _debug_mesh:
|
|
_debug_mesh.visible = HitBox.debug_visible
|
|
|
|
if not is_active:
|
|
return
|
|
|
|
_check_hits()
|
|
|
|
func _check_hits():
|
|
if not _query_shape:
|
|
# Fallback: create a default sphere
|
|
var sphere = SphereShape3D.new()
|
|
sphere.radius = 2.0
|
|
_query_shape = sphere
|
|
|
|
# Use physics server for reliable queries
|
|
var space_state = get_world_3d().direct_space_state
|
|
var query = PhysicsShapeQueryParameters3D.new()
|
|
query.shape = _query_shape
|
|
query.transform = global_transform
|
|
query.collision_mask = 16 # Layer 5 (hurtbox)
|
|
query.collide_with_areas = true
|
|
query.collide_with_bodies = false
|
|
|
|
var results = space_state.intersect_shape(query, 32)
|
|
|
|
for result in results:
|
|
var collider = result["collider"]
|
|
if collider is HurtBox:
|
|
_process_hit(collider)
|
|
|
|
func _process_hit(hurtbox: HurtBox):
|
|
# Don't hit our own hurtbox
|
|
if hurtbox.owner_entity == owner_entity:
|
|
return
|
|
|
|
# Don't hit same entity twice in one attack
|
|
if hurtbox.owner_entity in _hits_this_attack:
|
|
return
|
|
|
|
# Enemies don't damage other enemies (only players)
|
|
if owner_entity is BaseEnemy and hurtbox.owner_entity is BaseEnemy:
|
|
return
|
|
|
|
# Register this hit
|
|
var target = hurtbox.owner_entity
|
|
if target:
|
|
_hits_this_attack.append(target)
|
|
|
|
# Get attacker position for knockback direction
|
|
var attacker_pos = global_position
|
|
if owner_entity and owner_entity is Node3D:
|
|
attacker_pos = owner_entity.global_position
|
|
|
|
# Emit signal - let the weapon/owner handle damage routing to server
|
|
hit_landed.emit(target, damage, knockback, attacker_pos)
|
|
|
|
## Activate hitbox (call when attack starts)
|
|
func activate():
|
|
is_active = true
|
|
_hits_this_attack.clear()
|
|
# Change to yellow when active
|
|
if _debug_material:
|
|
_debug_material.albedo_color = Color(1.0, 1.0, 0.0, 0.5) # Yellow, semi-transparent
|
|
|
|
## Deactivate hitbox (call when attack ends)
|
|
func deactivate():
|
|
is_active = false
|
|
_hits_this_attack.clear()
|
|
# Change back to red when inactive
|
|
if _debug_material:
|
|
_debug_material.albedo_color = Color(1.0, 0.0, 0.0, 0.4) # Red, semi-transparent
|
|
|
|
## Set damage stats (usually from weapon data)
|
|
func set_stats(new_damage: float, new_knockback: float):
|
|
damage = new_damage
|
|
knockback = new_knockback
|