Renamed a bunch of player stuff to pawn stuff and implemented extensive work on getting single-address 'netplay' code working. Not sure if I've created issues with single player but in theory it should all transfer across as if the player is simply always the host.

This commit is contained in:
2026-01-08 07:15:20 -05:00
parent 9fe376e27e
commit ec02685065
69 changed files with 1525 additions and 708 deletions

568
scripts/pawn_controller.gd Normal file
View File

@@ -0,0 +1,568 @@
class_name PawnController extends CharacterBody3D
enum State {
NORMAL,
KNOCKDOWN,
KNOCKUP,
FLUNG,
BOUND,
DEAD
}
const camera_template = preload("res://templates/camera.tscn")
const trap_template = preload("res://templates/trap.tscn")
const remove_trap_modal = preload("res://templates/remove_trap_modal.tscn")
const disarm_trap_modal = preload("res://templates/disarm_trap_modal.tscn")
@export var speed : float = 10
@onready var body : PawnBody = $PawnBody
@onready var data : PawnLevelData = $Data
@onready var trap_sound : AudioStreamPlayer3D = $TrapSound
@onready var detonate_sound : AudioStreamPlayer3D = $DetonateSound
@onready var detect_sound : AudioStreamPlayer3D = $DetectSound
@onready var fling_sound : AudioStreamPlayer3D = $FlingSound
@onready var crash_sound : AudioStreamPlayer3D = $CrashSound
@onready var reload_sound : AudioStreamPlayer3D = $PawnBody/ReloadSound
@onready var detect_icon : Sprite3D = $DetectIcon
var id : int = 1
var state : State
var button_actions : Dictionary[int, String]
var current_square : Vector3i
var facing : Vector3
var detecting : bool = false
var detect_squares : Dictionary[Vector3i, bool] = {}
var detect_tween : Tween = null
var fling_direction : Vector3
var fling_speed : float
var struggle_timer : float
var struggle_angle : float
var poison_strength : int = 0
var poison_time_remaining : float = 0
var poison_pulse_timer : float
var melee_range : float = 3.0
var ranged_range : float = 6
var attack_timer : float = 0
var melee_recovery_time : float = .75
var ranged_recovery_time : float = .2
var ranged_reload_time : float = 1
var projectile_speed : float = 10.0
var projectile_damage : int = 4
var ammo = 5
var max_ammo = 5
var combat_target
var meleeing : bool = false
var input_locked : bool = false
var action_tween : Tween = null
var camera : PawnCamera = null
var modal = null
signal trap_cycled(trap_index : int)
signal trap_quantity_changed(trap_index : int, quantity : int)
signal trap_list_changed(traps)
signal health_changed(current : int, max : int)
signal harmed()
signal poison_status_changed(poisoned : bool)
signal combat_target_changed(melee : bool)
signal ammo_changed(current : int, max : int)
signal struggling(value : float)
func _physics_process(delta: float) -> void:
if attack_timer > 0:
attack_timer -= delta
check_attack_target()
if is_poisoned():
poison_time_remaining -= delta
poison_pulse_timer += delta
if poison_pulse_timer >= 1.0:
poison_pulse_timer -= 1.0
hurt(poison_strength)
if poison_time_remaining <= 0:
var pshader : ShaderMaterial = body.material.next_pass
pshader.set_shader_parameter("strength", 0)
poison_status_changed.emit(false)
var dir = Input.get_vector("west", "east", "north", "south")
dir = Vector3(dir.x, 0, dir.y)
if struggle_timer > 0:
struggle_timer -= delta
var snapped_angle = round(dir.angle_to(Vector3(1,0,0)) * 4 / PI) * PI * .25
if abs(snapped_angle - struggle_angle) >= PI * .25:
struggle_angle = snapped_angle
struggle_timer -= 2 * delta
struggling.emit(struggle_timer)
if struggle_timer <= 0:
hurt(1)
knockdown(facing)
else:
return
if input_locked or modal != null:
dir = Vector3.ZERO
var can_fall : bool = false
var moving : bool = false
match(state):
State.FLUNG:
can_fall = true
moving = true
dir = fling_direction
body.look_at(body.global_position - dir)
can_fall = true
velocity = fling_speed * dir
#Raycast for a wall
var space_state = get_world_3d().direct_space_state
# use global coordinates, not local to node
var start = global_position + Vector3(0,0.25,0)
var end = start + velocity.normalized() * 0.25 + velocity * 2 * delta
var query = PhysicsRayQueryParameters3D.create(start, end, 1|2, [self])
var result = space_state.intersect_ray(query)
if result:
var opp : PawnController = result.collider as PawnController
if opp:
opp.knockdown(fling_direction)
opp.hurt(10)
knockdown(-fling_direction)
hurt(10)
moving = false
State.NORMAL:
can_fall = true
if dir.length_squared() > 0:
moving = true
facing = dir.normalized()
body.look_at(body.global_position - dir)
velocity = speed * dir
if is_poisoned():
velocity *= 0.5
if detecting:
velocity *= .33
State.KNOCKUP:
if is_on_floor():
knockdown(facing)
else:
moving = true
can_fall = true
State.KNOCKDOWN,State.BOUND,State.DEAD:
return
if can_fall:
var down = 0
if !is_on_floor():
velocity.y = velocity.y + get_gravity().y * delta
moving = true
if moving:
move_and_slide()
if detecting:
update_detecting()
#Deal with the rest of the buttons
if !input_locked:
if modal:
var evt = InputEventAction.new()
var buttons = [
"lay trap",
"detect",
"attack",
"detonate"
]
for button in buttons:
if Input.is_action_just_pressed(button):
evt.action = button
evt.pressed = true
modal.button_pressed(evt)
elif state == State.NORMAL:
if Input.is_action_just_pressed("left cycle trap"):
cycle_active_trap(-1)
if Input.is_action_just_pressed("right cycle trap"):
cycle_active_trap(1)
if Input.is_action_just_pressed("detonate"):
detonate()
elif Input.is_action_just_pressed("detect"):
start_detecting()
elif Input.is_action_just_released("detect"):
stop_detecting()
elif !detecting and Input.is_action_just_pressed("lay trap"):
try_lay_trap()
elif Input.is_action_pressed("attack"):
attack()
func attack() -> void:
if attack_timer > 0:
return
if meleeing:
body.play_animation("melee")
attack_timer = melee_recovery_time
else:
if ammo <= 0:
reload_sound.play()
ammo = max_ammo
attack_timer = ranged_reload_time
ammo_changed.emit(ammo, max_ammo)
return
ammo-=1
ammo_changed.emit(ammo, max_ammo)
attack_timer = ranged_recovery_time
body.play_animation("shoot")
if combat_target != null:
var v = body.global_position.direction_to(combat_target.global_position)
v.y = 0
body.look_at(body.global_position - v)
func fire_ranged() -> void:
var shot = body.projectile_template.instantiate()
var tdir : Vector3 = Vector3.ZERO
shot.speed = projectile_speed
tdir = body.ranged_point.global_position.direction_to(combat_target.global_position) if combat_target else facing
shot.direction = tdir
shot.damage = projectile_damage
shot.position = body.ranged_point.global_position
Game.level.add_projectile(shot)
func check_attack_target() -> void:
var ranged_closest = null
var ranged_d_sq = 99999999
var melee_closest = null
var melee_d_sq = 99999999
var ranged_sq = ranged_range * ranged_range
var melee_sq = melee_range * melee_range
var space_state = get_world_3d().direct_space_state
combat_target = null
# use global coordinates, not local to node
for target : Node3D in get_tree().get_nodes_in_group("combat"):
if target == self:
continue
#Check to see if they're within the correct direction
var angle = abs(facing.angle_to(target.global_position - global_position))
if angle > PI / 4.0:
continue
#Determine if they're within shot range
var d_sq = global_position.distance_squared_to(target.global_position)
if(d_sq > ranged_sq or(ranged_closest != null and ranged_d_sq <= d_sq)):
continue
#Raycast to see if they're a valid target
var start = global_position + Vector3(0,1,0)
var end = target.global_position + Vector3(0,1,0)
var query = PhysicsRayQueryParameters3D.create(start, end, 1|2, [self])
var result = space_state.intersect_ray(query)
if !result or (result.collider is not PawnController and result.collider is not CombatTarget):
return
if d_sq < melee_sq:
melee_closest = target
melee_d_sq = d_sq
else:
ranged_closest = target
ranged_d_sq = d_sq
if melee_closest != null:
meleeing = true
combat_target = melee_closest
else:
meleeing = false
if ranged_closest != null:
combat_target = ranged_closest
combat_target_changed.emit(melee_closest != null)
func try_lay_trap() -> void:
if !is_on_floor():
return
if data.traps[data.active_trap].quantity < 1:
return
var square : Vector3i = (global_position - Vector3.ONE * .5).round()
if !Game.level.is_valid_trap_square(square):
return
action_tween = create_tween()
input_locked = true
action_tween.tween_interval(.2)
action_tween.tween_callback(Callable(lay_trap).bind(square, data.active_trap))
action_tween.tween_interval(.25)
action_tween.tween_callback(clear_action)
func lay_trap(square : Vector3i, idx : int) -> void:
var type : Trap.Type = data.traps[idx].type
var trap = trap_template.instantiate()
trap.setup(type, facing, id)
trap.disarmed.connect(_on_trap_disarmed)
trap.activated.connect(_on_trap_activated)
data.traps[idx].quantity -= 1
trap_quantity_changed.emit(idx, data.traps[idx].quantity)
Game.level.add_trap(trap, square)
trap_sound.play()
func clear_action() -> void:
input_locked = false
action_tween = null
func update_detecting() -> void:
var new_square : Vector3i = (global_position - Vector3.ONE * .5).round()
if new_square == current_square:
return
current_square = new_square
var new_squares : Dictionary[Vector3i, bool] = {}
for i in range(-2, 3):
for j in range(-2, 3):
for k in range(-2, 2):
if abs(i) + abs(j) < 3:
var sq = current_square + Vector3i(i, k, j)
new_squares[sq] = true
for sq in detect_squares.keys():
if !new_squares.has(sq):
Game.level.detect_square(sq, false)
var remove_list = []
var trap_detected : bool = false
for sq in new_squares.keys():
if detect_squares.has(sq):
continue
if !Game.level.detect_square(sq, true):
remove_list.append(sq)
else:
var trap = Game.level.get_square_trap(sq + Vector3i(0,1,0))
if trap and trap.is_just_revealed():
trap_detected = true
if trap_detected:
detect_alert()
detect_squares = new_squares
for key in remove_list:
detect_squares.erase(key)
var trap : Trap = Game.level.get_square_trap(current_square)
if trap != null:
if trap.trap_owner == Multiplayer.id:
show_remove_trap_modal()
else:
trap.disarming = true
show_disarm_trap_modal()
func detect_alert() -> void:
detect_sound.play()
detect_icon.visible = true
if detect_tween != null:
detect_tween.stop()
detect_tween = create_tween()
detect_tween.tween_interval(.75)
detect_tween.tween_property(detect_icon, "visible", false, 0)
detect_tween.tween_callback(func(): detect_tween = null)
func close_modal() -> void:
if modal != null:
modal.queue_free()
modal = null
func show_remove_trap_modal() -> void:
stop_detecting()
modal = remove_trap_modal.instantiate()
modal.square = current_square
Game.level.add_child(modal)
func show_disarm_trap_modal() -> void:
stop_detecting()
modal = disarm_trap_modal.instantiate()
modal.difficulty = Game.level.difficulty
modal.square = current_square
var trap = Game.level.traps[current_square]
trap.disarming = true
trap.disarm_id = Multiplayer.id
trap.removed.connect(modal._on_trap_removed)
trap.activated.connect(modal._on_trap_failed)
harmed.connect(modal._on_trap_failed)
Game.level.add_child(modal)
func start_detecting() -> void:
detecting = true
current_square = (global_position - Vector3.ONE * .5).round()
detect_squares = {}
for i in range(-2, 3):
for j in range(-2, 3):
for k in range(-2, 2):
if abs(i) + abs(j) < 3:
var sq = current_square + Vector3i(i, k, j)
detect_squares[sq] = true
var remove_list = []
var trap_detected : bool = false
for sq in detect_squares.keys():
if !Game.level.detect_square(sq, true):
remove_list.append(sq)
else:
var trap = Game.level.get_square_trap(sq + Vector3i(0,1,0))
if trap and trap.is_just_revealed():
trap_detected = true
if trap_detected:
detect_alert()
for key in remove_list:
detect_squares.erase(key)
var trap : Trap = Game.level.get_square_trap(current_square)
if trap != null:
if trap.trap_owner == Multiplayer.id:
show_remove_trap_modal()
func stop_detecting() -> void:
detecting = false
for sq in detect_squares.keys():
Game.level.detect_square(sq, false)
detect_squares = {}
@rpc("authority", "call_local", "reliable")
func setup(id : int, traps : Array, pos : Vector3) -> void:
self.id = id
self.global_position = pos
var traplist : Array[PawnLevelData.TrapData] = []
for trap in traps:
traplist.append(PawnLevelData.TrapData.new(trap.type, trap.qty, trap.qty))
$Data.traps = traplist
set_multiplayer_authority(id)
struggling.connect(body._on_struggle_changed)
Game.setup_player(self)
func remove_trap_at(square) -> void:
var trap : Trap = Game.level.traps[square]
for i in range(len(data.traps)):
var d = data.traps[i]
if d.type == trap.type:
d.quantity += 1
trap_quantity_changed.emit(i, d.quantity)
break
trap.remove()
Game.level.traps.erase(square)
func cycle_active_trap(dir) -> void:
var prev = data.active_trap
data.active_trap += dir
if data.active_trap < 0:
data.active_trap = 0
if data.active_trap >= len(data.traps):
data.active_trap = len(data.traps) - 1
if prev != data.active_trap:
trap_cycled.emit(data.active_trap)
func can_hurt() -> bool:
return true
func hurt(damage : int) -> void:
data.life = max(0, data.life - damage)
if damage > 0:
harmed.emit()
if struggle_timer > 0:
struggle_timer = 0
struggling.emit(struggle_timer)
knockdown(Vector3(0,0,-1))
health_changed.emit(data.life, data.max_life)
func _on_trap_disarmed(type : Trap.Type) -> void:
for i in range(len(data.traps)):
var d = data.traps[i]
if d.type == type:
d.max -= 1
trap_quantity_changed.emit(i, d.quantity)
break
#hurt
#blast
#blast_players
#activate
#activate_trap
#fail
#on_trap_failed
func _on_trap_activated(type : Trap.Type) -> void:
for i in range(len(data.traps)):
var d = data.traps[i]
if d.type == type:
d.quantity = min(d.max, d.quantity+ 1)
trap_quantity_changed.emit(i, d.quantity)
break
func detonate() -> void:
var switch_list = []
for trap : Trap in Game.level.traps.values():
if trap.type == Trap.Type.SWITCH and trap.trap_owner == id:
switch_list.append(trap)
detonate_sound.play()
for trap : Trap in switch_list:
trap.activate()
func is_poisoned() -> bool:
return poison_time_remaining > 0
func poison(damage : int, length : float) -> void:
if is_poisoned():
if damage > poison_strength:
poison_strength = damage
if length > poison_time_remaining:
poison_time_remaining = length
else:
poison_strength = damage
poison_time_remaining = length
poison_pulse_timer = 0
var pshader : ShaderMaterial = body.material.next_pass
pshader.set_shader_parameter("strength", 0.5)
poison_status_changed.emit(true)
func fling(direction : Vector3, speed : float) -> void:
state = State.FLUNG
fling_direction = direction
fling_speed = speed
fling_sound.play()
func knockdown(direction : Vector3) -> void:
state = State.KNOCKDOWN
input_locked = true
body.look_at(Vector3(0,1,0), direction)
fling_sound.stop()
crash_sound.play()
var knockdown_tween = create_tween()
knockdown_tween.tween_interval(1.5)
knockdown_tween.tween_property(self, "state", State.NORMAL, 0)
knockdown_tween.tween_property(self, "input_locked", false, 0)
knockdown_tween.tween_callback(Callable(body.look_at).bind(facing, Vector3(0,1,0)))
func knockup(velocity : Vector3) -> void:
if state != State.KNOCKUP:
state = State.KNOCKUP
input_locked = true
if is_on_floor():
self.velocity = Vector3.UP * .1
move_and_slide()
self.velocity = velocity
func pitfall(duration : float) -> void:
struggle_timer = duration
body.struggle_bar.max_value = struggle_timer
body.struggle_bar.value = struggle_timer
body.show_struggle()
func start_pitfall(square : Vector3, duration : float) -> void:
input_locked = true
velocity = Vector3.ZERO
var tween = create_tween()
tween.tween_property(self, "position", square + Vector3(.5,0,.5), 0.25)
tween.tween_property(self, "input_locked", false, 0)
tween.tween_callback(pitfall.bind(duration))