853 lines
30 KiB
GDScript
853 lines
30 KiB
GDScript
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)
|