Files

853 lines
30 KiB
GDScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
extends Node3D
const PORT := 27840
var port := PORT
var config := {"server_name": "Relay / Local", "mode": "TDM", "map": "quarry", "max_players": 8, "bots": 7, "difficulty": 0, "score_limit": 50, "time_limit": 600}
var active := false
var is_host := false
var dedicated := false
var local_id := 1
var players := {}
var scores := [0, 0]
var time_left := 600.0
var match_over := false
var winner := ""
var arena: RelayArena
var hit_stop: HitStop
var combat: CombatSystem
var bots: BotDirector
var audio: ArenaAudio
var discovery: LanDiscovery
var ui: GameUI
var hud: ArenaHUD
var menu_camera: Camera3D
var spectator_camera: Camera3D
var death_camera_active := false
var death_camera_target_id := 0
var death_camera_fallback := Transform3D.IDENTITY
var death_camera_look := Vector3.ZERO
var grenades: Node3D
var effects: Node3D
var projectiles: Node3D
var objectives: RoundObjectives
var history: CombatHistory
var ammo_drops: AmmoDrops
var streaks: Killstreaks
var burn_zones: BurnZones
var grenade_serial := 0
var snapshot_clock := 0.0
var snapshot_serial := 0
var snapshot_fragments := SnapshotFragments.new()
var input_clock := 0.0
var input_sequence := 0
var pending := {}
var sensitivity := 0.002
var base_fov := 88.0
var nickname := "Operator"
var operator_id := "steve"
var loadout := 0
var paused := false
var graphics_quality := 1
var headless := false
var connect_clock := 0.0
var connecting := false
var last_address := "127.0.0.1:27840"
var action_sequence := 0
var ready_for_play := false
func _ready() -> void:
hit_stop = HitStop.new()
add_child(hit_stop)
Input.use_accumulated_input = false
headless = DisplayServer.get_name() == "headless"
dedicated = "--dedicated" in OS.get_cmdline_user_args()
if not headless:
PlayerSettings.restore(self)
ClassLoadouts.restore(loadout)
history = CombatHistory.new()
history.game = self
add_child(history)
setup_inputs()
arena = RelayArena.new()
add_child(arena)
GraphicsSettings.apply(self, graphics_quality)
grenades = Node3D.new()
grenades.name = "Grenades"
add_child(grenades)
effects = Node3D.new()
effects.name = "CombatEffects"
add_child(effects)
projectiles = Node3D.new()
projectiles.name = "Projectiles"
add_child(projectiles)
objectives = RoundObjectives.new()
objectives.game = self
add_child(objectives)
objectives.reset()
combat = CombatSystem.new(self)
for hazard in arena.find_children("*", "Node3D", true, false):
if hazard is WreckFire: hazard.damage_tick.connect(_on_wreck_tick)
burn_zones = BurnZones.new()
burn_zones.game = self
add_child(burn_zones)
ammo_drops = AmmoDrops.new()
ammo_drops.game = self
add_child(ammo_drops)
streaks = Killstreaks.new()
streaks.game = self
add_child(streaks)
bots = BotDirector.new(self)
audio = ArenaAudio.new()
add_child(audio)
var music := MusicDirector.new()
music.game = self
add_child(music)
discovery = LanDiscovery.new()
discovery.game = self
add_child(discovery)
menu_camera = Camera3D.new()
add_child(menu_camera)
menu_camera.position = Vector3(26, 13, 32)
menu_camera.look_at(Vector3(-2, 1.2, -4))
menu_camera.current = true
spectator_camera = Camera3D.new()
spectator_camera.name = "DeathCamera"
spectator_camera.near = 0.04
spectator_camera.fov = 78
add_child(spectator_camera)
ui = GameUI.new()
ui.game = self
ui.host_requested.connect(host)
ui.join_requested.connect(join)
ui.leave_requested.connect(leave)
ui.preferences_changed.connect(save_preferences)
add_child(ui)
hud = ArenaHUD.new()
hud.game = self
add_child(hud)
multiplayer.connected_to_server.connect(_connected)
multiplayer.connection_failed.connect(func(): leave("Verbindung fehlgeschlagen. IP und Firewall prüfen."))
multiplayer.server_disconnected.connect(func(): leave("Host hat das Spiel beendet."))
multiplayer.peer_disconnected.connect(_disconnected)
await ShaderPrewarm.run(self)
ready_for_play = true
var args := OS.get_cmdline_user_args()
if dedicated:
var server := DedicatedServer.new()
server.game = self
add_child(server)
server.start(args)
return
for arg in args:
if arg.begins_with("--port="):
port = int(arg.get_slice("=", 1))
for arg in args:
if arg.begins_with("--map="): config["map"] = arg.trim_prefix("--map=")
if "--host" in args:
host(config)
for arg in args:
if arg.begins_with("--join="):
join(arg.get_slice("=", 1))
if "--smoke" in args:
var timer := get_tree().create_timer(12)
timer.timeout.connect(func():
print("SMOKE active=", active, " players=", players.size(), " scores=", scores)
get_tree().quit(0 if active and players.size() > 1 else 1))
func setup_inputs() -> void:
if headless: ControlBindings.defaults()
else: ControlBindings.setup()
func select_map(id: String) -> void:
if arena.map_id == id: return
remove_child(arena)
arena.free()
arena = MapCatalog.create(id)
add_child(arena)
bots.routes = BotRoutes.new(arena)
for hazard in arena.find_children("*", "Node3D", true, false):
if hazard is WreckFire: hazard.damage_tick.connect(_on_wreck_tick)
GraphicsSettings.apply(self, graphics_quality)
objectives.reset()
if id == "dust2":
menu_camera.position = Vector3(35, 27, 35)
menu_camera.look_at(Vector3(0, 3, -10))
else:
menu_camera.position = Vector3(26, 13, 32)
menu_camera.look_at(Vector3(-2, 1.2, -4))
func host(settings: Dictionary, as_dedicated: bool = false) -> bool:
var selected_map := str(settings.get("map", "quarry"))
if selected_map not in MapCatalog.IDS:
ui.message("Unbekannte Map: " + selected_map)
return false
dedicated = as_dedicated
var peer := ENetMultiplayerPeer.new()
if dedicated:
peer.set_bind_ip(settings.get("bind", "0.0.0.0"))
var err := peer.create_server(port, 8 if dedicated else 7)
if err != OK:
ui.message("Port %s ist belegt oder nicht verfügbar (Fehler %s)." % [port, err])
if dedicated:
printerr("SERVER ERROR: UDP %s konnte nicht gebunden werden (Fehler %s)." % [port, err])
return false
config = settings.duplicate()
config["map"] = selected_map
select_map(selected_map)
config["difficulty"] = clampi(int(config.get("difficulty", 0)), 0, 3)
config.max_players = clampi(int(config.max_players), 2, 8)
config.bots = clampi(int(config.bots), 0, config.max_players if dedicated else config.max_players - 1)
config.score_limit = clampi(int(config.score_limit), 1, 200)
config.time_limit = clampi(int(config.time_limit), 60, 1800)
multiplayer.multiplayer_peer = peer
local_id = 0 if dedicated else 1
is_host = true
active = true
match_over = false
winner = ""
scores = [0, 0]
time_left = config.time_limit
if not dedicated:
add_player(1, nickname, false, loadout)
fill_bots()
objectives.reset()
enter_match()
return true
func join(address: String) -> void:
last_address = address.strip_edges()
save_preferences()
var host_address := address.strip_edges()
if host_address.contains(":"):
port = int(host_address.get_slice(":", 1))
host_address = host_address.get_slice(":", 0)
if not host_address.is_valid_ip_address() or port < 1024 or port > 65535:
ui.message("Bitte eine gültige lokale IPv4-Adresse eingeben.")
return
var peer := ENetMultiplayerPeer.new()
var err := peer.create_client(host_address, port)
if err != OK:
ui.message("Verbindung konnte nicht gestartet werden: %s" % err)
return
multiplayer.multiplayer_peer = peer
is_host = false
connecting = true
connect_clock = 0
ui.message("Verbinde mit %s …" % host_address)
func _connected() -> void:
local_id = multiplayer.get_unique_id()
register_player.rpc_id(1, nickname, loadout, WeaponSkins.loadout_designs(loadout), WeaponStickers.bundle(WeaponSkins.loadout_designs(loadout)), operator_id)
@rpc("any_peer", "call_remote", "reliable")
func register_player(display_name: String, selected: int, cosmetics: Dictionary = {}, sticker_files: Dictionary = {}, appearance: String = "steve") -> void:
if not is_host or not active:
return
var id := multiplayer.get_remote_sender_id()
if players.has(id):
return
var humans := 0
for p: Fighter in players.values():
if not p.bot:
humans += 1
if humans >= config.max_players:
rejected.rpc_id(id, "Server ist voll.")
return
for p: Fighter in players.values():
if p.bot and (dedicated or players.size() >= config.max_players):
remove_player(p.peer_id)
break
var joined := add_player(id, display_name.strip_edges().left(20), false, Arsenal.primary_id(selected), appearance)
joined.skin_designs = WeaponSkins.clean_loadout(cosmetics, joined.primary)
for index in [joined.primary, 4]: joined.magazines[index] = WeaponAttachments.stats(index, joined.skin_designs.get(str(index), {})).mag
var accepted_stickers := WeaponStickers.accept(sticker_files)
if not accepted_stickers.is_empty(): receive_stickers.rpc(accepted_stickers)
for member: Fighter in players.values():
var files := WeaponStickers.bundle(member.skin_designs)
if not files.is_empty(): receive_stickers.rpc_id(id, files)
welcome.rpc_id(id, config)
print("JOIN accepted ", id, " roster=", players.size())
@rpc("authority", "call_remote", "reliable")
func rejected(reason: String) -> void:
leave(reason)
@rpc("authority", "call_remote", "reliable")
func welcome(settings: Dictionary) -> void:
var selected_map := str(settings.get("map", "quarry"))
if selected_map not in MapCatalog.IDS:
leave("Die Map des Hosts ist in dieser Spielversion nicht vorhanden.")
return
config = settings
select_map(selected_map)
active = true
connecting = false
match_over = false
enter_match()
func enter_match() -> void:
ui.hide_menu()
paused = false
if not headless:
Input.mouse_mode = Input.MOUSE_MODE_CAPTURED
func add_player(id: int, display_name: String, ai: bool, selected: int, appearance: String = "steve") -> Fighter:
var counts := [0, 0]
for p: Fighter in players.values():
counts[p.team] += 1
var p := Fighter.new()
var model := ("gas_mask" if posmod(id, 2) == 1 else "steve") if ai else (operator_id if id == local_id else appearance)
p.setup(self, id, display_name if not display_name.is_empty() else "Operator", 0 if counts[0] <= counts[1] else 1, ai, selected, model)
p.respawn_requested.connect(respawn)
p.combat_requested.connect(combat.actions)
p.bot_update_requested.connect(bots.update)
p.fall_damage_requested.connect(func(actor: Fighter): combat.damage(actor, actor, 200, "FALL", false))
p.footstep_requested.connect(func(pos: Vector3, actor_id: int): fx.rpc("step", pos, actor_id, 0))
players[id] = p
add_child(p)
respawn(p)
return p
func fill_bots() -> void:
var target: int = mini(config.max_players, config.bots + (0 if dedicated else 1))
var id := -1
while players.size() < target:
while players.has(id):
id -= 1
add_player(id, ["Rook", "Mica", "Cinder", "Atlas", "Echo", "Vale", "Finch"][(-id - 1) % 7] + " [BOT]", true, Arsenal.PRIMARY_IDS[(-id - 1) % Arsenal.PRIMARY_IDS.size()])
func restart_round() -> void:
if not is_host or not dedicated or not active:
return
history.stop()
history.frames.clear()
scores = [0, 0]
time_left = config.time_limit
winner = ""
match_over = false
for grenade in grenades.get_children():
grenades.remove_child(grenade)
grenade.queue_free()
burn_zones.clear()
streaks.clear()
ammo_drops.clear()
for effect in effects.get_children():
effects.remove_child(effect)
effect.queue_free()
objectives.reset()
for projectile in projectiles.get_children():
projectiles.remove_child(projectile)
projectile.queue_free()
for fighter: Fighter in players.values():
fighter.kills = 0
fighter.streak = 0
fighter.ult_charge = 0
fighter.infinite_left = 0
fighter.recon_left = 0
fighter.deaths = 0
respawn(fighter)
snapshot_clock = 0.05
func remove_player(id: int) -> void:
bots.steering.erase(id)
if players.has(id):
players[id].queue_free()
players.erase(id)
func _disconnected(id: int) -> void:
if is_host:
remove_player(id)
fill_bots()
func leave(reason: String = "") -> void:
snapshot_fragments = SnapshotFragments.new()
hit_stop.restore()
history.stop()
history.frames.clear()
death_camera_active = false
death_camera_target_id = 0
active = false
connecting = false
match_over = false
if multiplayer.multiplayer_peer:
multiplayer.multiplayer_peer.close()
multiplayer.multiplayer_peer = OfflineMultiplayerPeer.new()
is_host = false
for id in players.keys():
remove_player(id)
for grenade in grenades.get_children():
grenade.queue_free()
burn_zones.clear()
streaks.clear()
ammo_drops.clear()
for effect in effects.get_children():
effects.remove_child(effect)
effect.queue_free()
objectives.reset()
for projectile in projectiles.get_children():
projectiles.remove_child(projectile)
projectile.queue_free()
menu_camera.current = true
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE
ui.main_menu()
ui.message(reason)
func enemies(a: Fighter, b: Fighter) -> bool:
return a != b and (config.mode == "FFA" or a.team != b.team)
func death_camera_killer(victim: Fighter) -> Fighter:
if victim.hp > 0 or victim.killer_id == 0 or victim.killer_id == victim.peer_id:
return null
var target: Fighter = players.get(victim.killer_id)
if not is_instance_valid(target) or target.is_queued_for_deletion() or target.hp <= 0:
return null
return target
func is_killcam_visible(victim: Fighter) -> bool:
return not headless and not match_over and death_camera_active and spectator_camera.current \
and death_camera_target_id == victim.killer_id and death_camera_killer(victim) != null
# Returns true while normal first-person camera animation must be suppressed.
# Only the local Fighter calls this; remote/bot cameras never become current.
func update_death_camera(victim: Fighter, delta: float) -> bool:
if headless or paused:
return true
if not active or not victim.is_local():
return true
if victim.hp > 0 or match_over:
history.stop()
death_camera_active = false
death_camera_target_id = 0
victim.camera.make_current()
return false
if not death_camera_active:
history.begin(victim.killer_id)
death_camera_active = true
death_camera_target_id = 0
# Capture once: even later authoritative corpse corrections cannot move it.
death_camera_fallback = victim.camera.global_transform
death_camera_fallback.origin = victim.eye() + Vector3.UP * 0.5
var target := death_camera_killer(victim)
if target == null:
history.stop()
death_camera_target_id = 0
spectator_camera.global_transform = death_camera_fallback
else:
if history.advance(delta, spectator_camera):
death_camera_target_id = target.peer_id
return true
spectator_camera.fov = 78
var pivot := target.eye()
var forward := Arsenal.direction(target.yaw, target.pitch)
var shoulder := Basis(Vector3.UP, target.yaw) * Vector3.RIGHT * 0.45
var destination := pivot - forward * 2.4 + Vector3.UP * 0.5 + shoulder
if death_camera_target_id != target.peer_id:
# Cut on entry; never fly across the map from the victim to the killer.
spectator_camera.global_position = destination
death_camera_look = pivot
else:
var weight := 1.0 - exp(-delta * 8.0)
spectator_camera.global_position = spectator_camera.global_position.lerp(destination, weight)
death_camera_look = death_camera_look.lerp(pivot, weight)
spectator_camera.look_at(death_camera_look)
death_camera_target_id = target.peer_id
spectator_camera.make_current()
return true
func visible_between(a: Vector3, b: Vector3, exclude: Array = []) -> bool:
var rids: Array[RID] = []
rids.assign(exclude)
var ray := PhysicsRayQueryParameters3D.create(a, b, 1, rids)
return get_world_3d().direct_space_state.intersect_ray(ray).is_empty()
func respawn(p: Fighter) -> void:
if not p.pending_class.is_empty():
p.primary = p.pending_class.primary
p.skin_designs = p.pending_class.skins
p.pending_class = {}
p.world_weapon = -1
var best := arena.spawn_points[0]
var best_score := -INF
for candidate in arena.spawn_points:
var score := randf_range(0, 2)
var home := candidate.z > 0 if p.team == 0 else candidate.z < 0
if home and config.mode != "FFA":
score += 12
for other: Fighter in players.values():
if other == p or other.hp <= 0:
continue
var distance := candidate.distance_to(other.global_position)
if distance < 2:
score -= 1000
if enemies(p, other):
score -= maxf(0, 30 - distance) * 3
if visible_between(candidate + Vector3.UP * 1.65, other.eye()):
score -= 65
if other.cooldown > 0 and distance < 25:
score -= 25
if score > best_score:
best_score = score
best = candidate
p.reset_at(best)
func _unhandled_input(event: InputEvent) -> void:
if ShaderPrewarm.running: return
if event.is_action_pressed("fullscreen") and not event.is_echo():
var window := get_window()
window.mode = Window.MODE_WINDOWED if window.mode == Window.MODE_FULLSCREEN else Window.MODE_FULLSCREEN
save_preferences()
if event.is_action_pressed("pause") and not event.is_echo() and active:
paused = not paused
Input.mouse_mode = Input.MOUSE_MODE_VISIBLE if paused else Input.MOUSE_MODE_CAPTURED
if paused:
ui.pause_menu()
else:
ui.hide_menu()
if not active or paused or match_over or not players.has(local_id):
return
var p: Fighter = players[local_id]
if p.hp <= 0:
return
if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED:
FpsCamera.mouse(p, event, sensitivity)
for action in ["jump", "reload", "grenade", "flash", "switch", "melee", "interact", "shield", "ultimate"]:
if event.is_action_pressed(action):
pending[action] = true
func _physics_process(dt: float) -> void:
if connecting:
connect_clock += dt
if connect_clock > 10:
leave("Zeitüberschreitung. Host-IP, Port und Netzwerk prüfen.")
if not active:
return
if players.has(local_id) and not players[local_id].bot:
var p: Fighter = players[local_id]
var command := {"move": Vector2.ZERO, "yaw": p.yaw, "pitch": p.pitch}
if p.hp > 0 and not paused and not match_over and not headless:
command.merge({"move": Input.get_vector("left", "right", "forward", "back"),
"sprint": Input.is_action_pressed("sprint"), "crouch": Input.is_action_pressed("crouch"),
"ads": Input.is_action_pressed("aim"), "fire": Input.is_action_pressed("fire")}, true)
if is_host:
command.merge(pending)
elif not pending.is_empty():
action_sequence += 1
submit_actions.rpc_id(1, action_sequence, pending)
pending.clear()
p.input_data = command.duplicate()
p.input_age = 0
if not is_host:
input_sequence += 1
submit_input.rpc_id(1, input_sequence, command)
if is_host:
ammo_drops.advance(dt)
streaks.advance(dt)
burn_zones.advance(dt)
objectives.advance(dt)
if not match_over:
time_left = maxf(0, time_left - dt)
if time_left <= 0 and streaks.nuke_left <= 0:
finish_match()
snapshot_clock += dt
if snapshot_clock >= 0.05:
snapshot_clock = 0
var roster := []
for p: Fighter in players.values():
roster.append(p.snapshot())
history.record(roster)
var frags := []
for g: FragGrenade in grenades.get_children():
frags.append({"id": int(g.name), "p": g.global_position, "owner": g.owner_id, "kind": g.kind})
var packet := var_to_bytes([roster, scores, time_left, match_over, winner, frags, burn_zones.snapshot(), objectives.snapshot(), streaks.snapshot(), ammo_drops.rows]).compress(FileAccess.COMPRESSION_DEFLATE)
snapshot_serial += 1
var count := ceili(float(packet.size()) / SnapshotFragments.PART_BYTES)
for index in count:
receive_state_part.rpc(snapshot_serial, index, count, packet.slice(index * SnapshotFragments.PART_BYTES, (index + 1) * SnapshotFragments.PART_BYTES))
@rpc("any_peer", "call_remote", "unreliable_ordered", 1)
func submit_input(seq: int, command: Dictionary) -> void:
if not is_host or not active:
return
var id := multiplayer.get_remote_sender_id()
if not players.has(id):
return
var p: Fighter = players[id]
if seq <= p.last_sequence:
return
var axis: Variant = command.get("move", Vector2.ZERO)
var y: Variant = command.get("yaw", 0.0)
var tilt: Variant = command.get("pitch", 0.0)
if not axis is Vector2 or not axis.is_finite() or not (y is float or y is int) or not (tilt is float or tilt is int):
return
if not is_finite(float(y)) or not is_finite(float(tilt)):
return
p.last_sequence = seq
p.yaw = wrapf(float(y), -PI, PI)
p.pitch = clampf(float(tilt), -1.5, 1.5)
var clean := {"move": axis.limit_length()}
for flag in ["sprint", "crouch", "ads", "fire"]:
clean[flag] = command.get(flag, false) == true
p.input_data = clean
p.input_age = 0
@rpc("any_peer", "call_remote", "reliable", 3)
func submit_actions(seq: int, actions: Dictionary) -> void:
if not is_host or not active or match_over:
return
var p: Fighter = players.get(multiplayer.get_remote_sender_id())
if p == null or p.hp <= 0 or seq <= p.last_action_sequence:
return
p.last_action_sequence = seq
for action in ["jump", "reload", "grenade", "flash", "switch", "melee", "interact", "shield", "ultimate"]:
if actions.get(action, false) == true:
p.queued_actions[action] = true
func save_preferences() -> void:
if not headless and not dedicated:
if PlayerSettings.save(self) != OK and is_instance_valid(ui):
ui.message("Einstellungen konnten nicht gespeichert werden.")
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func receive_state_part(serial: int, index: int, count: int, bytes: PackedByteArray) -> void:
if not active: return
var packet := snapshot_fragments.accept(serial, index, count, bytes)
if not packet.is_empty(): receive_state(packet)
@rpc("authority", "call_remote", "unreliable_ordered", 2)
func receive_state(packet: PackedByteArray) -> void:
if not active:
return
var decoded: Array = bytes_to_var(packet.decompress_dynamic(65536, FileAccess.COMPRESSION_DEFLATE))
if decoded.size() < 7 or decoded.size() > 10:
return
var roster: Array = decoded[0]
history.record(roster)
var team_scores: Array = decoded[1]
var remaining: float = decoded[2]
var ended: bool = decoded[3]
var result: String = decoded[4]
var new_round := match_over and not ended
if new_round:
history.stop()
history.frames.clear()
var frags: Array = decoded[5]
burn_zones.sync(decoded[6])
if decoded.size() > 7: objectives.sync(decoded[7])
if decoded.size() > 8: streaks.sync(decoded[8])
if decoded.size() > 9: ammo_drops.sync(decoded[9])
scores = team_scores
time_left = remaining
match_over = ended
winner = result
var seen := []
for state: Dictionary in roster:
seen.append(state.id)
if not players.has(state.id):
var p := Fighter.new()
p.setup(self, state.id, state.name, state.team, state.bot, state.primary, str(state.get("operator", "steve")))
players[state.id] = p
add_child(p)
p.position = state.p
players[state.id].apply_snapshot(state)
if new_round and state.id == local_id:
players[state.id].global_position = state.p
players[state.id].velocity = Vector3.ZERO
players[state.id].yaw = state.yaw
players[state.id].pitch = state.pitch
for id in players.keys():
if not id in seen:
remove_player(id)
var frag_ids := []
for data: Dictionary in frags:
frag_ids.append(str(data.id))
if not grenades.has_node(str(data.id)):
create_grenade(data.id, data.owner, data.p, Vector3.ZERO, data.get("kind", "frag"))
grenades.get_node(str(data.id)).position = data.p
for g in grenades.get_children():
if not str(g.name) in frag_ids:
g.queue_free()
func check_win() -> void:
if streaks.nuke_left > 0 or match_over: return
if config.mode != "FFA":
if scores.max() >= config.score_limit:
finish_match()
else:
for p: Fighter in players.values():
if p.kills >= config.score_limit:
finish_match()
func finish_match() -> void:
match_over = true
if config.mode != "FFA":
winner = "DRAW" if scores[0] == scores[1] else ("TEAM RELAY WINS" if scores[0] > scores[1] else "TEAM EMBER WINS")
else:
var best := -1
var leaders := []
for p: Fighter in players.values():
if p.kills > best:
best = p.kills
leaders = [p.nickname]
elif p.kills == best:
leaders.append(p.nickname)
winner = "%s WINS" % leaders[0] if leaders.size() == 1 else "DRAW"
func spawn_grenade(owner_id: int, pos: Vector3, speed: Vector3, kind: String = "frag") -> void:
grenade_serial += 1
create_grenade(grenade_serial, owner_id, pos, speed, kind)
func _on_grenade_detonation(pos: Vector3, owner_id: int, kind: String, grenade_id: int) -> void:
if kind == "flash": combat.flash(pos)
else: combat.explode(pos, owner_id)
remove_grenade.rpc(grenade_id)
func create_grenade(id: int, owner_id: int, pos: Vector3, speed: Vector3, kind: String = "frag") -> void:
var g := FragGrenade.new()
g.game = self
g.detonation_requested.connect(_on_grenade_detonation)
g.owner_id = owner_id
g.kind = kind
g.fuse = 1.6 if kind == "flash" else 2.6
g.name = str(id)
grenades.add_child(g)
g.position = pos
g.linear_velocity = speed
g.angular_velocity = Vector3(4, 2, 3)
@rpc("authority", "call_local", "reliable")
func remove_grenade(id: int) -> void:
var g := grenades.get_node_or_null(str(id))
if g:
g.queue_free()
@rpc("authority", "call_local", "unreliable")
func fx(kind: String, pos: Vector3, id: int, variant: int) -> void:
audio.play_at(kind, pos, id == local_id, variant)
if kind == "shot": ShotFeedback.present(self, id, variant, false, false)
if kind == "flash" and not headless:
CombatVisuals.flash(effects, pos)
if kind == "explosion" and not headless:
CombatVisuals.explosion(effects, pos)
# One reliable event keeps flash, recoil, shot audio and confirmed hits together.
@rpc("authority", "call_local", "reliable")
func shot_event(pos: Vector3, id: int, weapon: int, hit: bool, head: bool) -> void:
audio.play_at("shot", pos, id == local_id, weapon, id)
ShotFeedback.present(self, id, weapon, hit, head)
@rpc("authority", "call_local", "reliable")
func detonate(pos: Vector3) -> void:
audio.play_at("explosion", pos, false)
if not headless:
CombatVisuals.explosion(effects, pos)
if players.has(local_id):
var p: Fighter = players[local_id]
p.blast_shake = maxf(p.blast_shake, clampf(1 - p.eye().distance_to(pos) / 28.0, 0, 1))
@rpc("authority", "call_local", "unreliable")
func impact(pos: Vector3) -> void:
if not headless:
CombatVisuals.impact(effects, pos)
@rpc("authority", "call_local", "unreliable")
func tracer(a: Vector3, b: Vector3) -> void:
if headless or a.distance_to(b) < 0.01:
return
var line := MeshInstance3D.new()
var mesh := BoxMesh.new()
mesh.size = Vector3(0.012, 0.012, a.distance_to(b))
line.mesh = mesh
var mat := StandardMaterial3D.new()
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mat.albedo_color = Color("ffe3a5")
line.material_override = mat
effects.add_child(line)
line.position = (a + b) / 2
line.look_at(b, Vector3.UP if absf((b - a).normalized().y) < 0.99 else Vector3.RIGHT)
get_tree().create_timer(0.045).timeout.connect(line.queue_free)
@rpc("authority", "call_local", "reliable")
func feedback(head: bool) -> void:
if active: hit_stop.trigger()
hud.hit_time = 0.18
hud.head_hit = head
audio.play_at("hit", Vector3.ZERO, true)
@rpc("authority", "call_local", "reliable")
func kill_notice(attacker: String, victim: String, weapon_name: String, head: bool) -> void:
hud.feed.push_front({"text": "%s %s %s%s" % [attacker, weapon_name, victim, " [HS]" if head else ""], "until": Time.get_ticks_msec() + 5500})
if hud.feed.size() > 5:
hud.feed.pop_back()
@rpc("authority", "call_local", "reliable")
func combat_notice(kind: String, actor_life: int, origin: Vector3, victim: String = "") -> void:
var p: Fighter = players.get(local_id)
if not active or p == null or p.life != actor_life:
return
hud.notice_life = actor_life
if kind == "hurt":
hud.hurt_origin = origin
hud.hurt_time = 0.9
elif kind == "elimination":
hud.elimination_name = victim
hud.elimination_time = 2.0
audio.play_at("confirm", p.eye(), true)
@rpc("authority", "call_local", "unreliable")
func muzzle_trace(id: int, start: Vector3, end: Vector3) -> void:
if headless: return
var p: Fighter = players.get(id)
if p != null:
start = ShotOrigin.visual(p)
var wall := get_world_3d().direct_space_state.intersect_ray(PhysicsRayQueryParameters3D.create(p.eye(), start, 1))
if not wall.is_empty(): start = wall.position
tracer(start, end)
@rpc("authority", "call_local", "reliable")
func nuke_effect() -> void:
streaks.flash_left = 3
if not headless:
var point := Vector3(0, 12, 0)
CombatVisuals.explosion(effects, point)
for i in 12:
var direction := Vector3(cos(i * TAU / 12), 0, sin(i * TAU / 12))
CombatVisuals.puff(effects, point + Vector3.UP * 14 + direction * 12, 16, "smoke", 7, direction * 18 + Vector3.UP * 12)
CombatVisuals.puff(effects, point + direction * 5, 12, "fireball", 2, direction * 12 + Vector3.UP * 8)
audio.play_at("explosion", menu_camera.global_position, true)
@rpc("authority", "call_remote", "reliable")
func receive_stickers(files: Dictionary) -> void:
WeaponStickers.accept(files)
WeaponSkins.revision += 1
for member: Fighter in players.values(): member.world_weapon = -1
func queue_local_class() -> void:
if not active: return
var skins := WeaponSkins.loadout_designs(loadout)
if is_host:
if players.has(local_id): players[local_id].pending_class = {"primary": loadout, "skins": skins}
var files := WeaponStickers.bundle(skins)
if not files.is_empty(): receive_stickers.rpc(files)
else:
request_class.rpc_id(1, loadout, skins, WeaponStickers.bundle(skins))
@rpc("any_peer", "call_remote", "reliable")
func request_class(selected: int, cosmetics: Dictionary, files: Dictionary) -> void:
var id := multiplayer.get_remote_sender_id()
if not is_host or not players.has(id): return
var p: Fighter = players[id]
var now := Time.get_ticks_msec()
p.class_update_time = now
var primary := Arsenal.primary_id(selected)
p.pending_class = {"primary": primary, "skins": WeaponSkins.clean_loadout(cosmetics, primary)}
var accepted := WeaponStickers.accept(files)
if not accepted.is_empty(): receive_stickers.rpc(accepted)
func _on_wreck_tick(origin: Vector3) -> void:
if not active or not is_host or match_over: return
for actor: Fighter in players.values():
if actor.hp > 0 and actor.eye().distance_to(origin) < 2 and visible_between(origin, actor.eye(), [actor.get_rid()]):
combat.damage(actor, actor, 5, "WRECK FIRE", false, origin)