Files
Blockline/scripts/game.gd
T

853 lines
30 KiB
GDScript
Raw Normal View History

2026-09-12 11:01:37 +02:00
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)