feat: the rebuild gamemode of "Gauntlet"

This commit is contained in:
2026-06-10 02:12:25 +08:00
parent 2d857a490b
commit 5653473c12
28 changed files with 1313 additions and 254 deletions
+8
View File
@@ -48,6 +48,8 @@ GachaManager="*res://scripts/managers/gacha_manager.gd"
BackendService="*res://scripts/services/backend_service.gd"
FriendManager="*res://scripts/managers/friend_manager.gd"
MailManager="*res://scripts/managers/mail_manager.gd"
EventBus="*res://scripts/event_bus.gd"
SessionManager="*res://scripts/managers/session_manager.gd"
[display]
@@ -137,6 +139,12 @@ use_powerup={
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":3,"pressure":0.0,"pressed":false,"script":null)
]
}
use_cleanser={
"deadzone": 0.2,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":69,"key_label":0,"unicode":101,"location":0,"echo":false,"script":null)
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":2,"pressure":0.0,"pressed":false,"script":null)
]
}
action_grab_tekton={
"deadzone": 0.5,
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":71,"physical_keycode":0,"key_label":0,"unicode":103,"location":0,"echo":false,"script":null)
+7
View File
@@ -179,6 +179,7 @@ func _ready():
# Setup Game Mode specific UI dynamically
_create_custom_settings_ui()
_setup_game_modes()
# Initial UI update
_sync_room_profile_card()
@@ -504,6 +505,12 @@ func _sync_room_profile_card() -> void:
if room_player_rank:
room_player_rank.text = "Rank: %s" % (_local_player_rank if _local_player_rank > 0 else "-")
# Sync currency labels
if gold_label:
gold_label.text = str(UserProfileManager.wallet.get("gold", 0))
if star_label:
star_label.text = str(UserProfileManager.wallet.get("star", 0))
var avatar_path = UserProfileManager.get_avatar_url()
if not avatar_path.is_empty() and ResourceLoader.exists(avatar_path):
var tex = load(avatar_path)
+4 -5
View File
@@ -19,13 +19,13 @@
[ext_resource type="Theme" uid="uid://cxab3xxy00" path="res://assets/themes/GUI_Tekton.tres" id="14_2630d"]
[ext_resource type="FontFile" uid="uid://c2tryhyhlyb1u" path="res://assets/fonts/Supercell-Magic Regular.ttf" id="14_vwf6o"]
[ext_resource type="Texture2D" uid="uid://kflvrkha1jwy" path="res://assets/graphics/gui/mainmenu/chat_enter.png" id="15_iwi7x"]
[ext_resource type="Texture2D" uid="uid://q8vw41qd00lx" path="res://assets/graphics/gui/mainmenu/button_room.png" id="17_wjff0"]
[ext_resource type="Texture2D" uid="uid://dq8ll26kexu3b" path="res://assets/graphics/gui/mainmenu/button_room.png" id="17_wjff0"]
[ext_resource type="Texture2D" uid="uid://bcgu0jku4ntcw" path="res://assets/graphics/gui/mainmenu/button_room_interact.png" id="18_0jb4q"]
[ext_resource type="Texture2D" uid="uid://dllwxub5n4361" path="res://assets/graphics/gui/mainmenu/button_play.png" id="18_h1rib"]
[ext_resource type="Texture2D" uid="uid://l03huy5c0vvy" path="res://assets/graphics/gui/lobby/profile.png" id="18_u7tfn"]
[ext_resource type="Texture2D" uid="uid://jted80o4uarv" path="res://assets/graphics/gui/lobby/leaderboards.png" id="19_2630d"]
[ext_resource type="Texture2D" uid="uid://bvugtpcgc2qkx" path="res://assets/graphics/gui/lobby/shop.png" id="20_835bk"]
[ext_resource type="Texture2D" uid="uid://c6ahbdxx23e3b" path="res://assets/graphics/gui/mainmenu/button_play_interact.png" id="20_vwf6o"]
[ext_resource type="Texture2D" uid="uid://cdupokfxuufpl" path="res://assets/graphics/gui/mainmenu/button_play_interact.png" id="20_vwf6o"]
[ext_resource type="Texture2D" uid="uid://6agwwbc1l4g3" path="res://assets/graphics/gui/play/selection_play0.png" id="21_h1rib"]
[ext_resource type="Texture2D" uid="uid://bmmajc7h7o4dg" path="res://assets/graphics/gui/lobby/dailylogin.png" id="21_ucbax"]
[ext_resource type="Texture2D" uid="uid://dv782w5t0xlcc" path="res://assets/graphics/gui/lobby/friends.png" id="22_1x1aw"]
@@ -724,10 +724,11 @@ grow_vertical = 2
texture = ExtResource("23_835bk")
expand_mode = 3
[node name="MailBadge" type="Label" parent="MainMenuPanel/MainMargin/MainHBox/RightCol/TopRightPanel/MailboxBtn"]
[node name="MailBadge" type="Label" parent="MainMenuPanel/MainMargin/MainHBox/RightCol/TopRightPanel/MailboxBtn" unique_id=156491696]
unique_name_in_owner = true
visible = false
layout_mode = 1
anchors_preset = 1
anchor_left = 1.0
anchor_right = 1.0
offset_left = -18.0
@@ -735,12 +736,10 @@ offset_top = -2.0
offset_right = 2.0
offset_bottom = 16.0
grow_horizontal = 0
grow_vertical = 1
theme_override_colors/font_color = Color(1, 1, 1, 1)
theme_override_font_sizes/font_size = 10
horizontal_alignment = 1
vertical_alignment = 1
text = ""
[node name="SocialBtn" type="Button" parent="MainMenuPanel/MainMargin/MainHBox/RightCol/TopRightPanel" unique_id=82719328]
unique_name_in_owner = true
+1 -1
View File
@@ -1857,7 +1857,7 @@ func randomize_item_at_position(grid_position: Vector2i):
var new_item = 7
var get_mode_specific_tile = func():
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors":
if LobbyManager.game_mode != "Stop n Go" and LobbyManager.game_mode != "Tekton Doors" and LobbyManager.game_mode != "Candy Cannon Survival":
# 60% Chance for Common (7-10), 40% for PowerUp
if randf() <= 0.6:
return [7, 8, 9, 10].pick_random()
+12
View File
@@ -0,0 +1,12 @@
*** Begin Patch
*** Update File: scenes/player.gd
@@ -37,6 +37,11 @@
if is_stop_frozen == value: return
is_stop_frozen = value
_refresh_player_visuals()
+
+@rpc("any_peer", "call_local", "reliable")
+func sync_modulate(color: Color) -> void:
+ _apply_tint_recursive(self, color, 1.0)
+
*** End Patch
+3 -3
View File
@@ -146,9 +146,9 @@ func _on_chat_send_pressed() -> void:
_refresh_chat_display()
if _chat_channel and NakamaManager.session and NakamaManager.client:
var payload = JSON.stringify({"channel_id": _chat_channel.id})
var rpc_result = await NakamaManager.client.rpc_async(NakamaManager.session, "admin_clear_global_chat", payload)
if rpc_result.is_exception():
push_warning("[Chat] admin_clear_global_chat RPC failed: " + rpc_result.get_exception().message)
var rpc_result = await BackendService.admin_clear_global_chat(payload)
if rpc_result.get("success", false) == false:
push_warning("[Chat] admin_clear_global_chat RPC failed: " + str(rpc_result.get("message", "")))
else:
_inject_local_message("[SYSTEM] : Global chat cleared by admin.")
else:
+73
View File
@@ -0,0 +1,73 @@
extends Node
# EventBus - Centralized Observer Pattern for inter-manager communication
# Replaces direct cross-references between managers
# =============================================================================
# Events Registry
# =============================================================================
# Player Events
const EVENT_PLAYER_JOINED = "player_joined"
const EVENT_PLAYER_LEFT = "player_left"
const EVENT_PLAYER_READY = "player_ready"
# Match Events
const EVENT_MATCH_STARTED = "match_started"
const EVENT_MATCH_ENDED = "match_ended"
const EVENT_GAME_MODE_CHANGED = "game_mode_changed"
# Economy Events
const EVENT_CURRENCY_CHANGED = "currency_changed"
const EVENT_ITEM_PURCHASED = "item_purchased"
const EVENT_GACHA_PULL = "gacha_pull"
# Profile Events
const EVENT_PROFILE_LOADED = "profile_loaded"
const EVENT_PROFILE_UPDATED = "profile_updated"
const EVENT_AVATAR_CHANGED = "avatar_changed"
# Session Events
const EVENT_SESSION_REFRESHED = "session_refreshed"
const EVENT_SESSION_EXPIRED = "session_expired"
# =============================================================================
# Signal Bus
# =============================================================================
signal event_emitted(event_name: String, data: Variant)
# =============================================================================
# Internal Registry
# =============================================================================
var _listeners: Dictionary = {} # event_name -> Array[Callable]
# =============================================================================
# Public API
# =============================================================================
func emit(event_name: String, data: Variant = null) -> void:
"""Emit an event to all registered listeners."""
if _listeners.has(event_name):
for callback in _listeners[event_name]:
if data != null:
callback.call(data)
else:
callback.call()
event_emitted.emit(event_name, data)
func on(event_name: String, callback: Callable) -> void:
"""Subscribe to an event."""
if not _listeners.has(event_name):
_listeners[event_name] = []
_listeners[event_name].append(callback)
func off(event_name: String, callback: Callable) -> void:
"""Unsubscribe from an event."""
if _listeners.has(event_name):
_listeners[event_name].erase(callback)
func clear() -> void:
"""Remove all listeners. Useful for cleanup between scenes."""
_listeners.clear()
+1
View File
@@ -0,0 +1 @@
uid://cph7fr22ohbwy
+6 -8
View File
@@ -130,9 +130,9 @@ func add_friend_by_id(user_id: String) -> bool:
print("[FriendManager] add_friend_by_id: add_friends_async OK, sending notification RPC...")
# Step 2: Notify the target via RPC
var payload = JSON.stringify({"user_id": user_id})
var rpc_result = await NakamaManager.client.rpc_async(NakamaManager.session, "send_friend_request", payload)
if rpc_result.is_exception():
push_error("[FriendManager] rpcSendFriendRequest failed: " + rpc_result.get_exception().message)
var rpc_result = await BackendService.send_friend_request(user_id)
if rpc_result.get("success", false) == false:
push_error("[FriendManager] rpcSendFriendRequest failed: " + str(rpc_result.get("error", "")))
else:
print("[FriendManager] rpcSendFriendRequest OK: " + str(rpc_result.payload))
@@ -169,11 +169,9 @@ func remove_friend(user_id: String) -> bool:
func send_lobby_invite(to_user_id: String, match_id: String) -> void:
if not NakamaManager.session:
return
var payload = JSON.stringify({"to_user_id": to_user_id, "match_id": match_id})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session, "send_lobby_invite", payload)
if result.is_exception():
push_warning("[FriendManager] send_lobby_invite failed: " + result.get_exception().message)
var result = await BackendService.send_lobby_invite(to_user_id, match_id)
if result.get("success", false) == false:
push_warning("[FriendManager] send_lobby_invite failed: " + str(result.get("error", "")))
func _on_notification_received(notification) -> void:
print("[FriendManager] _on_notification_received: code=%d sender=%s" % [notification.code, notification.sender_id])
+4 -8
View File
@@ -37,18 +37,14 @@ func pull(banner_id: String, count: int) -> Array:
"count": count
})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"perform_gacha_pull",
payload
)
var result = await BackendService.perform_gacha_pull(banner_id, count)
if result.is_exception():
var msg = result.get_exception().message
if result.get("success", false) == false:
var msg = str(result.get("error", "Unknown error"))
push_error("[GachaManager] Gacha pull failed: " + msg)
return []
var parsed = JSON.parse_string(result.payload)
var parsed = result.get("data", {})
if typeof(parsed) != TYPE_DICTIONARY or not parsed.has("results"):
return []
+570 -18
View File
@@ -62,6 +62,32 @@ var phase_configs: Array = [
# Smack State (per-player)
# =============================================================================
func has_smack_charged(pid: int) -> bool:
if smack_charged.has(pid) and smack_charged[pid] > 0:
return true
return false
@rpc("any_peer", "call_local", "reliable")
func consume_smack(pid: int) -> void:
# Local state reset
smack_charged[pid] = 0.0
smack_cooldowns[pid] = SMACK_COOLDOWN
# Play smack sound
if SfxManager:
SfxManager.rpc("play_rpc", "attack_mode") if _can_rpc() else SfxManager.play("attack_mode")
var all_players = get_tree().get_nodes_in_group("Players")
for player in all_players:
var curr_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
if curr_pid == pid:
if player.has_method("sync_modulate"):
if _can_rpc():
player.rpc("sync_modulate", Color.WHITE)
else:
player.sync_modulate(Color.WHITE)
break
var smack_cooldowns: Dictionary = {} # player_id → float (time remaining)
var smack_charged: Dictionary = {} # player_id → float (charge window remaining)
const SMACK_COOLDOWN: float = 8.0
@@ -80,6 +106,16 @@ var player_cleansers: Dictionary = {} # player_id → int (0 or 1)
var trapped_players: Dictionary = {} # player_id → true
# =============================================================================
# Slow-Mo Effect
# =============================================================================
var slowmo_active: bool = false
var slowmo_timer: float = 0.0
var slowmo_duration: float = 4.0
const SLOWMO_SCALE: float = 0.25 # 1/4 speed
var slowmo_overlay: ColorRect = null
# =============================================================================
# References
# =============================================================================
@@ -93,6 +129,8 @@ var cannon_instance: Node3D = null
var hud_layer: CanvasLayer
var phase_label: Label
var cleanser_label: Label
var cleanser_icon: TextureRect
var cleanser_count: int = 0
# =============================================================================
# Lifecycle
@@ -102,11 +140,23 @@ func _ready():
set_process(false)
_setup_hud()
func _exit_tree():
# Ensure time_scale is always restored when leaving Gauntlet mode
Engine.time_scale = 1.0
func initialize(main: Node, grid: Node) -> void:
main_scene = main
gridmap = grid
print("[Gauntlet] Initialized with gridmap: ", gridmap.name if gridmap else "null")
# Connect to GoalsCycleManager for scoring and mission tracking
if main_scene:
var gcm = main_scene.get_node_or_null("GoalsCycleManager")
if gcm:
gcm.goal_count_updated.connect(_on_goal_count_updated)
gcm.score_updated.connect(_on_score_updated)
print("[Gauntlet] Connected to GoalsCycleManager")
func _process(delta: float) -> void:
if not is_active:
return
@@ -116,13 +166,54 @@ func _process(delta: float) -> void:
# Phase escalation
_check_phase_transition()
# Cannon timer (server only)
# Server only logic
if multiplayer.is_server():
# Cannon timer
cannon_timer -= delta
if cannon_timer <= 0.0:
_fire_volley()
cannon_timer = cannon_interval
# Smack mechanic update (ALL PEERS)
var all_players = get_tree().get_nodes_in_group("Players")
for player in all_players:
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
# Allow local peer to predict setup
if not smack_cooldowns.has(pid) and not smack_charged.has(pid):
smack_cooldowns[pid] = SMACK_COOLDOWN
smack_charged[pid] = 0.0
if smack_cooldowns[pid] > 0:
smack_cooldowns[pid] -= delta
if smack_cooldowns[pid] <= 0:
smack_cooldowns[pid] = 0.0
smack_charged[pid] = SMACK_CHARGE_WINDOW
if player.has_method("sync_modulate"):
if multiplayer.is_server() and _can_rpc():
player.rpc("sync_modulate", Color.PINK)
elif not multiplayer.is_server():
player.sync_modulate(Color.PINK)
elif smack_charged[pid] > 0:
smack_charged[pid] -= delta
if smack_charged[pid] <= 0:
smack_charged[pid] = 0.0
smack_cooldowns[pid] = SMACK_COOLDOWN
if player.has_method("sync_modulate"):
if multiplayer.is_server() and _can_rpc():
player.rpc("sync_modulate", Color.WHITE)
elif not multiplayer.is_server():
player.sync_modulate(Color.WHITE)
# Cleanser input (local player only)
if Input.is_action_just_pressed("use_cleanser"):
_try_use_cleanser()
# Slow-mo timer (all peers for visual consistency)
if slowmo_active:
slowmo_timer -= delta
if slowmo_timer <= 0:
_end_slowmo()
# =============================================================================
# Game Mode Start
# =============================================================================
@@ -243,8 +334,13 @@ func _apply_arena_setup() -> void:
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
continue
# Outer edge (row 0, row 19, col 0, col 19) — cannon spawn positions
# These are walkable but used as spawn reference
# Boundary walls: perimeter (row 0, row 19, col 0, col 19)
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_OBSTACLE)
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
continue
# Interior: walkable floor
gridmap.set_cell_item(Vector3i(x, 0, z), TILE_WALKABLE)
gridmap.set_cell_item(Vector3i(x, 1, z), -1)
@@ -260,8 +356,8 @@ func _apply_arena_setup() -> void:
cannon_instance.position = Vector3(cx, 0, cz)
main_scene.add_child(cannon_instance)
print("[Gauntlet] Arena setup complete. Center NPC at (%d,%d), size %dx%d" % [
NPC_CENTER.x, NPC_CENTER.y, NPC_SIZE, NPC_SIZE
print("[Gauntlet] Arena setup complete. Boundary walls at perimeter. Center NPC at (%d,%d)" % [
NPC_CENTER.x, NPC_CENTER.y
])
func _is_npc_zone(pos: Vector2i) -> bool:
@@ -271,6 +367,36 @@ func _is_npc_zone(pos: Vector2i) -> bool:
var max_coord = NPC_CENTER + Vector2i(half, half) # (10, 10)
return pos.x >= min_coord.x and pos.x <= max_coord.x and pos.y >= min_coord.y and pos.y <= max_coord.y
func get_spawn_points(player_count: int) -> Array[Vector2i]:
"""Return spawn positions based on player count. Inside boundary walls."""
# 4 players: inner corners
var spawns_4: Array[Vector2i] = [
Vector2i(1, 1), # Top-left
Vector2i(18, 1), # Top-right
Vector2i(1, 18), # Bottom-left
Vector2i(18, 18), # Bottom-right
]
# 6 players: corners + mid-edges (top/bottom)
var spawns_6: Array[Vector2i] = spawns_4.duplicate()
spawns_6.append(Vector2i(10, 1)) # Top-mid
spawns_6.append(Vector2i(10, 18)) # Bottom-mid
# 8 players: corners + all mid-edges
var spawns_8: Array[Vector2i] = spawns_6.duplicate()
spawns_8.append(Vector2i(1, 10)) # Left-mid
spawns_8.append(Vector2i(18, 10)) # Right-mid
match player_count:
4:
return spawns_4
5, 6:
return spawns_6
_, 7, 8:
return spawns_8
_:
return spawns_4
# =============================================================================
# Tile Spawning & Mission System (Task #3)
# =============================================================================
@@ -332,7 +458,7 @@ func _spawn_mission_tiles() -> void:
# =============================================================================
func _fire_volley() -> void:
"""Select target cells, telegraph, then apply sticky after delay."""
"""Select target cells, highlight, telegraph, then apply sticky after delay."""
if not multiplayer.is_server():
return
@@ -342,20 +468,31 @@ func _fire_volley() -> void:
var config = phase_configs[int(current_phase)]
var telegraph_time = config["telegraph_time"]
var highlight_time: float = 0.8 # Floor highlight duration before telegraph
# Telegraph phase — show warning
# Highlight phase — show pulsing floor warning BEFORE telegraph
if _can_rpc():
rpc("sync_telegraph_highlight", targets)
await get_tree().create_timer(highlight_time).timeout
# Telegraph phase — show warning overlay
if _can_rpc():
rpc("sync_telegraph", targets)
# Shoot projectiles visually
# Shoot projectiles visually with 0.1s offset between shots
if cannon_instance and cannon_instance.has_method("spawn_projectile_rpc") and cannon_instance.can_rpc():
var cs = gridmap.cell_size
for target in targets:
for i in range(targets.size()):
var target = targets[i]
var target_pos = Vector3(target.x * cs.x + cs.x / 2.0, 0, target.y * cs.z + cs.z / 2.0)
# Stagger shots: 0.1s offset per projectile
await get_tree().create_timer(i * 0.1).timeout
cannon_instance.rpc("spawn_projectile_rpc", target_pos, telegraph_time)
# Wait telegraph duration, then apply impact
await get_tree().create_timer(telegraph_time).timeout
# Wait remaining telegraph duration, then apply impact
var remaining_time = telegraph_time - (targets.size() - 1) * 0.1
if remaining_time > 0:
await get_tree().create_timer(remaining_time).timeout
if _can_rpc():
rpc("sync_impact", targets)
@@ -424,13 +561,98 @@ func _get_nearby_valid_cells(center: Vector2i, radius: int, valid: Array) -> Arr
# =============================================================================
@rpc("authority", "call_local", "reliable")
func sync_telegraph(targets: Array) -> void:
"""Show warning overlay on target cells."""
func sync_telegraph_highlight(targets: Array) -> void:
"""Show pulsing floor highlight on target cells BEFORE the telegraph drop."""
if not gridmap: return
# Create programmatic highlight overlays (pulsing circles on floor)
for target in targets:
var pos = target as Vector2i
var cs = gridmap.cell_size
var world_pos = Vector3(pos.x * cs.x + cs.x / 2.0, 0.15, pos.y * cs.z + cs.z / 2.0)
# Create a flat pulsing indicator mesh
var mesh_inst = MeshInstance3D.new()
var box = BoxMesh.new()
box.size = Vector3(cs.x * 0.8, 0.02, cs.z * 0.8)
mesh_inst.mesh = box
mesh_inst.position = world_pos
var mat = StandardMaterial3D.new()
mat.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA
mat.albedo_color = Color(1.0, 0.3, 0.5, 0.4) # Pink warning glow
mat.emission_enabled = true
mat.emission = Color(1.0, 0.3, 0.5)
mat.emission_energy_multiplier = 2.0
mat.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED
mesh_inst.material_override = mat
# Add to scene tree under main
var main = get_node_or_null("/root/Main")
if main:
main.add_child(mesh_inst)
# Pulse animation
var tween = create_tween().set_loops()
tween.tween_method(func(a): mat.albedo_color.a = a, 0.4, 0.1, 0.2)
tween.tween_method(func(a): mat.albedo_color.a = a, 0.1, 0.4, 0.2)
# Auto-remove after highlight duration
var remove_timer = get_tree().create_timer(0.8)
remove_timer.timeout.connect(func():
if is_instance_valid(mesh_inst):
mesh_inst.queue_free()
)
# Play warning sound
if SfxManager:
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
@rpc("authority", "call_local", "reliable")
func sync_telegraph(targets: Array) -> void:
"""Show warning overlay on target cells with multi-stage animation."""
if not gridmap: return
# Place telegraph tiles
for target in targets:
var pos = target as Vector2i
gridmap.set_cell_item(Vector3i(pos.x, 2, pos.y), TILE_TELEGRAPH)
# Animate telegraph with Tween (build-up phase)
_animate_telegraph(targets)
func _animate_telegraph(targets: Array) -> void:
"""Tween animation for telegraph: fade in, flash, then transition to sticky."""
var config = phase_configs[int(current_phase)]
var telegraph_time = config["telegraph_time"]
var build_up_time = telegraph_time * 0.8 # 80% for build-up
var flash_time = telegraph_time * 0.2 # 20% for flash
# Create tween for visual feedback
var tween = create_tween()
tween.set_parallel(true)
# Phase 1: Fade in (alpha 0 -> 1) during build-up
# Note: GridMap tiles don't support alpha directly, so we use modulation
# We'll animate the gridmap overlay opacity conceptually
for target in targets:
var pos = target as Vector2i
# Tween the cell brightness by swapping between telegraph variants
tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.0)
tween.tween_callback(_flash_telegraph.bind(targets, 1)).set_delay(0.4)
tween.tween_callback(_flash_telegraph.bind(targets, 0)).set_delay(0.8)
# Audio: rising pitch during build-up
if SfxManager:
SfxManager.rpc("play_rpc", "generate_tile") if _can_rpc() else SfxManager.play("generate_tile")
await get_tree().create_timer(1.0).timeout
func _flash_telegraph(targets: Array, brightness: int) -> void:
"""Flicker telegraph tiles between normal and bright."""
if not gridmap: return
# Toggle visual feedback - in full implementation would modify material/overlay
# For now, this provides the timing structure for the animation
pass
@rpc("authority", "call_local", "reliable")
func sync_impact(targets: Array) -> void:
"""Apply sticky cells at target positions."""
@@ -443,11 +665,61 @@ func sync_impact(targets: Array) -> void:
# Screen shake for impact
if main_scene and main_scene.get("screen_shake_manager"):
main_scene.screen_shake_manager.shake(0.15, 4.0)
main_scene.screen_shake_manager.shake(0.15, 0.4)
# Audio: impact splat sound
if SfxManager:
SfxManager.rpc("play_rpc", "tile_scatter") if _can_rpc() else SfxManager.play("tile_scatter")
# Spawn candy splash particles at impact locations
_spawn_impact_particles(targets)
# Check if any player is now trapped
_check_all_players_trapped()
func _spawn_impact_particles(targets: Array) -> void:
"""Spawn candy splash particles at impact locations."""
if not main_scene:
return
for target in targets:
var pos = target as Vector2i
var world_pos = Vector3(
pos.x * gridmap.cell_size.x + gridmap.cell_size.x / 2.0,
0.5, # Slightly above floor
pos.y * gridmap.cell_size.z + gridmap.cell_size.z / 2.0
)
# Create a simple particle effect (GPUParticles3D)
var particles = GPUParticles3D.new()
particles.emitting = true
particles.one_shot = true
particles.amount = 8
particles.lifetime = 0.5
particles.explosiveness = 1.0
# Candy pink color
var material = ParticleProcessMaterial.new()
material.emission_shape = ParticleProcessMaterial.EMISSION_SHAPE_SPHERE
material.emission_sphere_radius = 0.2
material.direction = Vector3(0, 1, 0)
material.spread = 45.0
material.initial_velocity_min = 2.0
material.initial_velocity_max = 4.0
material.gravity = Vector3(0, -9.8, 0)
material.scale_min = 0.1
material.scale_max = 0.3
particles.process_material = material
particles.position = world_pos
main_scene.add_child(particles)
# Auto-remove after particles finish
await get_tree().create_timer(1.0).timeout
if particles and is_instance_valid(particles):
particles.queue_free()
# =============================================================================
# Sticky / Trap System
# =============================================================================
@@ -489,6 +761,139 @@ func clear_sticky_cell(pos: Vector2i) -> void:
if main_scene and _can_rpc():
main_scene.rpc("sync_grid_item", pos.x, 2, pos.y, -1)
func _try_use_cleanser() -> void:
"""Local player attempts to use Cleanser on adjacent sticky cells."""
var local_pid = multiplayer.get_unique_id()
var count = player_cleansers.get(local_pid, 0)
if count <= 0:
return
# Find local player
var all_players = get_tree().get_nodes_in_group("Players")
var local_player = null
for p in all_players:
var pid = p.get("peer_id") if "peer_id" in p else p.name.to_int()
if pid == local_pid:
local_player = p
break
if not local_player or not gridmap:
return
# Get player grid position
var player_pos = local_player.global_position
var grid_pos = Vector2i(int(player_pos.x), int(player_pos.z))
# Clear sticky cells in 3x3 area around player
var cleared_any = false
for dx in range(-1, 2):
for dz in range(-1, 2):
var check_pos = grid_pos + Vector2i(dx, dz)
if sticky_cells.has(check_pos):
if multiplayer.is_server():
clear_sticky_cell(check_pos)
else:
rpc("rpc_use_cleanser", check_pos)
cleared_any = true
if cleared_any:
# Consume cleanser
player_cleansers[local_pid] = 0
update_cleanser_ui(0)
# Trigger slow-mo for dramatic effect
if multiplayer.is_server():
trigger_slowmo()
else:
rpc("rpc_trigger_slowmo")
# Notify server if we're a client
if not multiplayer.is_server() and _can_rpc():
rpc("rpc_consume_cleanser", local_pid)
elif multiplayer.is_server():
# Sync to all clients
if _can_rpc():
rpc("sync_cleanser_count", local_pid, 0)
@rpc("any_peer", "call_local", "reliable")
func rpc_use_cleanser(pos: Vector2i) -> void:
"""RPC for clients to clear a sticky cell via Cleanser."""
if multiplayer.is_server():
clear_sticky_cell(pos)
@rpc("any_peer", "call_local", "reliable")
func rpc_consume_cleanser(pid: int) -> void:
"""RPC for clients to report Cleanser consumption to server."""
if multiplayer.is_server():
player_cleansers[pid] = 0
if _can_rpc():
rpc("sync_cleanser_count", pid, 0)
@rpc("any_peer", "reliable")
func rpc_trigger_slowmo() -> void:
"""RPC for clients to request slow-mo from server."""
if multiplayer.is_server():
trigger_slowmo()
# =============================================================================
# Slow-Mo Effect
# =============================================================================
func trigger_slowmo(duration: float = 4.0) -> void:
"""Trigger slow-motion effect at 1/4 speed. Server-authoritative."""
if slowmo_active:
return
slowmo_active = true
slowmo_timer = duration
slowmo_duration = duration
Engine.time_scale = SLOWMO_SCALE
# Show visual overlay
if main_scene and main_scene.has_node("Camera3D200"):
_show_slowmo_overlay()
if _can_rpc():
rpc("sync_slowmo_start", duration)
func _end_slowmo() -> void:
slowmo_active = false
Engine.time_scale = 1.0
_hide_slowmo_overlay()
if _can_rpc():
rpc("sync_slowmo_end")
func _show_slowmo_overlay() -> void:
if slowmo_overlay:
return
slowmo_overlay = ColorRect.new()
slowmo_overlay.color = Color(0.3, 0.5, 1.0, 0.1) # Subtle blue tint
slowmo_overlay.set_anchors_preset(Control.PRESET_FULL_RECT)
slowmo_overlay.mouse_filter = Control.MOUSE_FILTER_IGNORE
var cam = main_scene.get_node_or_null("Camera3D200")
if cam:
# Find or create a CanvasLayer for the overlay
var canvas = CanvasLayer.new()
canvas.layer = 4
main_scene.add_child(canvas)
canvas.add_child(slowmo_overlay)
# Fade in
slowmo_overlay.color.a = 0.0
var tween = create_tween()
tween.tween_property(slowmo_overlay, "color:a", 0.1, 0.3)
func _hide_slowmo_overlay() -> void:
if slowmo_overlay:
var tween = create_tween()
tween.tween_property(slowmo_overlay, "color:a", 0.0, 0.3)
tween.tween_callback(slowmo_overlay.get_parent().queue_free)
slowmo_overlay = null
@rpc("authority", "call_local", "reliable")
func sync_slowmo_start(duration: float) -> void:
slowmo_active = true
slowmo_timer = duration
Engine.time_scale = SLOWMO_SCALE
_show_slowmo_overlay()
@rpc("authority", "call_local", "reliable")
func sync_slowmo_end() -> void:
_end_slowmo()
# =============================================================================
# HUD
# =============================================================================
@@ -519,7 +924,7 @@ func _setup_hud() -> void:
phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink
top_container.add_child(phase_label)
# Cleanser label (bottom-center)
# Cleanser HUD (bottom-center) with icon
var bottom_container = CenterContainer.new()
bottom_container.set_anchors_preset(Control.PRESET_CENTER_BOTTOM)
bottom_container.grow_horizontal = Control.GROW_DIRECTION_BOTH
@@ -527,14 +932,42 @@ func _setup_hud() -> void:
bottom_container.offset_bottom = -50
hud_layer.add_child(bottom_container)
var cleanser_hbox = HBoxContainer.new()
cleanser_hbox.add_theme_constant_override("separation", 6)
bottom_container.add_child(cleanser_hbox)
# Cleanser icon (colored square as visual indicator)
cleanser_icon = TextureRect.new()
cleanser_icon.custom_minimum_size = Vector2(20, 20)
cleanser_icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED
# Generate a simple colored texture for the cleanser icon
var icon_img = Image.create(16, 16, false, Image.FORMAT_RGBA8)
icon_img.fill(Color(0.4, 0.9, 1.0)) # Cyan/teal for cleanser
icon_img.blend_rect(icon_img, Rect2i(2, 2, 12, 12), Vector2i(1, 1))
# Add a darker border
for x in range(16):
icon_img.set_pixel(x, 0, Color(0.2, 0.6, 0.7))
icon_img.set_pixel(x, 15, Color(0.2, 0.6, 0.7))
for y in range(16):
icon_img.set_pixel(0, y, Color(0.2, 0.6, 0.7))
icon_img.set_pixel(15, y, Color(0.2, 0.6, 0.7))
# Add a cross/sparkle pattern for "cleansing" effect
for i in range(4, 12):
icon_img.set_pixel(i, 7, Color(1.0, 1.0, 1.0, 0.8))
icon_img.set_pixel(7, i, Color(1.0, 1.0, 1.0, 0.8))
var icon_tex = ImageTexture.create_from_image(icon_img)
cleanser_icon.texture = icon_tex
cleanser_hbox.add_child(cleanser_icon)
# Cleanser text label
cleanser_label = Label.new()
cleanser_label.text = "🧹 Cleanser: 0"
cleanser_label.text = "Cleanser: 0"
cleanser_label.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER
if custom_font: cleanser_label.add_theme_font_override("font", custom_font)
cleanser_label.add_theme_font_size_override("font_size", 20)
cleanser_label.add_theme_color_override("font_outline_color", Color.BLACK)
cleanser_label.add_theme_constant_override("outline_size", 6)
bottom_container.add_child(cleanser_label)
cleanser_hbox.add_child(cleanser_label)
func _update_hud_phase(phase_name: String) -> void:
if phase_label:
@@ -550,9 +983,128 @@ func _update_hud_phase(phase_name: String) -> void:
phase_label.add_theme_color_override("font_color", Color(1.0, 0.6, 0.8)) # Candy pink
phase_label.text = "%s %s" % [icon, phase_name.to_upper()]
# Animate phase label with bounce effect
_animate_phase_label()
func update_cleanser_ui(count: int) -> void:
cleanser_count = count
if cleanser_label:
cleanser_label.text = "🧹 Cleanser: %d" % count
cleanser_label.text = "Cleanser: %d" % count
# Show/hide icon based on availability
if cleanser_icon:
cleanser_icon.visible = count > 0
if count > 0:
# Pulse animation when cleanser is available
var tween = create_tween()
tween.set_loops(2)
tween.tween_property(cleanser_icon, "modulate", Color(1.5, 1.5, 1.5, 1), 0.3)
tween.tween_property(cleanser_icon, "modulate", Color.WHITE, 0.3)
func _animate_phase_label() -> void:
"""Animate phase label with bounce effect."""
if not phase_label:
return
# Create tween for bounce animation
var tween = create_tween()
tween.set_ease(Tween.EASE_OUT)
tween.set_trans(Tween.TRANS_ELASTIC)
# Scale up then back to normal
var original_scale = phase_label.scale
tween.tween_property(phase_label, "scale", original_scale * 1.2, 0.1)
tween.tween_property(phase_label, "scale", original_scale, 0.2)
# Flash effect
tween.tween_property(phase_label, "modulate", Color(2, 2, 2, 1), 0.1)
tween.tween_property(phase_label, "modulate", Color.WHITE, 0.2)
# =============================================================================
# GoalsCycleManager Integration
# =============================================================================
func _on_goal_count_updated(peer_id: int, count: int) -> void:
"""Called when a player completes a goal cycle. Grant cleanser every 2 missions."""
if not multiplayer.is_server():
return
# Track mission completions per player
if not player_mission_completions.has(peer_id):
player_mission_completions[peer_id] = 0
player_mission_completions[peer_id] += 1
# Grant cleanser every 2 missions (max 1)
var completions = player_mission_completions[peer_id]
if completions % 2 == 0:
if not player_cleansers.has(peer_id):
player_cleansers[peer_id] = 0
if player_cleansers[peer_id] < 1:
player_cleansers[peer_id] = 1
print("[Gauntlet] Player %d granted Cleanser (mission %d)" % [peer_id, completions])
# Respawn mission tiles in non-sticky locations
_respawn_mission_tiles()
# Sync cleanser count to HUD
rpc("sync_cleanser_count", peer_id, player_cleansers.get(peer_id, 0))
func _on_score_updated(peer_id: int, new_score: int) -> void:
"""Called when a player's score is updated."""
pass # Score sync handled by GoalsCycleManager
func _respawn_mission_tiles() -> void:
"""Respawn mission tiles in non-sticky locations after mission completion."""
if not gridmap:
gridmap = get_parent().get_node_or_null("EnhancedGridMap")
if not gridmap:
gridmap = get_node_or_null("/root/Main/EnhancedGridMap")
if not gridmap: return
# Goal items: Heart(7), Diamond(8), Star(9), Coin(10)
var goal_items = [7, 8, 9, 10]
var tiles_spawned: int = 0
# Find empty non-sticky cells to place new tiles
var empty_cells: Array = []
for x in range(ARENA_COLUMNS):
for z in range(ARENA_ROWS):
var pos = Vector2i(x, z)
if _is_npc_zone(pos):
continue
# Skip boundary walls
if x == 0 or x == ARENA_COLUMNS - 1 or z == 0 or z == ARENA_ROWS - 1:
continue
# Skip sticky cells
if sticky_cells.has(pos):
continue
# Check if cell is empty on Layer 1
var current_item = gridmap.get_cell_item(Vector3i(x, 1, z))
if current_item == -1:
empty_cells.append(pos)
# Shuffle and place tiles
empty_cells.shuffle()
var tiles_to_place = min(empty_cells.size(), 20) # Limit respawn count
for i in range(tiles_to_place):
var pos = empty_cells[i]
var tile_type = goal_items[randi() % goal_items.size()]
gridmap.set_cell_item(Vector3i(pos.x, 1, pos.z), tile_type)
tiles_spawned += 1
# Sync to clients
if main_scene:
main_scene.rpc("sync_grid_item", pos.x, 1, pos.z, tile_type)
print("[Gauntlet] Respawned %d mission tiles" % tiles_spawned)
@rpc("authority", "call_local", "reliable")
func sync_cleanser_count(peer_id: int, count: int) -> void:
"""Sync cleanser count to HUD for specific player."""
# Update local player's cleanser UI
var local_pid = multiplayer.get_unique_id()
if peer_id == local_pid:
update_cleanser_ui(count)
# =============================================================================
# Utility
+48
View File
@@ -31,6 +31,11 @@ signal doors_swap_time_changed(time: int)
signal doors_refresh_time_changed(time: int)
signal doors_required_goals_changed(goals: int)
# Gauntlet settings signals
signal gauntlet_round_duration_changed(duration: int)
signal gauntlet_cannon_interval_changed(interval: int)
signal gauntlet_volley_size_changed(size: int)
# Room data structure
var current_room: Dictionary = {}
var players_in_room: Array = [] # [{id, name, is_ready}]
@@ -74,6 +79,11 @@ var doors_swap_time: int = 15
var doors_refresh_time: int = 25
var doors_required_goals: int = 8
# Gauntlet settings
var gauntlet_round_duration: int = 180
var gauntlet_cannon_interval: int = 5
var gauntlet_volley_size: int = 5
# Rematch tracking
var rematch_votes: Array = [] # [player_id, ...]
@@ -539,6 +549,37 @@ func sync_doors_required_goals(goals: int) -> void:
doors_required_goals = goals
emit_signal("doors_required_goals_changed", goals)
# =============================================================================
# Gauntlet Settings
# =============================================================================
func set_gauntlet_round_duration(duration: int) -> void:
gauntlet_round_duration = duration
if is_host: rpc("sync_gauntlet_round_duration", duration)
@rpc("authority", "call_local", "reliable")
func sync_gauntlet_round_duration(duration: int) -> void:
gauntlet_round_duration = duration
emit_signal("gauntlet_round_duration_changed", duration)
func set_gauntlet_cannon_interval(interval: int) -> void:
gauntlet_cannon_interval = interval
if is_host: rpc("sync_gauntlet_cannon_interval", interval)
@rpc("authority", "call_local", "reliable")
func sync_gauntlet_cannon_interval(interval: int) -> void:
gauntlet_cannon_interval = interval
emit_signal("gauntlet_cannon_interval_changed", interval)
func set_gauntlet_volley_size(size: int) -> void:
gauntlet_volley_size = size
if is_host: rpc("sync_gauntlet_volley_size", size)
@rpc("authority", "call_local", "reliable")
func sync_gauntlet_volley_size(size: int) -> void:
gauntlet_volley_size = size
emit_signal("gauntlet_volley_size_changed", size)
# =============================================================================
# Character Selection
# =============================================================================
@@ -717,6 +758,10 @@ func start_game(force: bool = false) -> void:
rpc("sync_doors_swap_time", doors_swap_time)
rpc("sync_doors_refresh_time", doors_refresh_time)
rpc("sync_doors_required_goals", doors_required_goals)
# Sync gauntlet settings
rpc("sync_gauntlet_round_duration", gauntlet_round_duration)
rpc("sync_gauntlet_cannon_interval", gauntlet_cannon_interval)
rpc("sync_gauntlet_volley_size", gauntlet_volley_size)
# Sync game mode
rpc("sync_game_mode", game_mode)
@@ -792,6 +837,9 @@ func request_room_info(requester_id: int, requester_name: String, requester_char
rpc_id(requester_id, "sync_doors_swap_time", doors_swap_time)
rpc_id(requester_id, "sync_doors_refresh_time", doors_refresh_time)
rpc_id(requester_id, "sync_doors_required_goals", doors_required_goals)
rpc_id(requester_id, "sync_gauntlet_round_duration", gauntlet_round_duration)
rpc_id(requester_id, "sync_gauntlet_cannon_interval", gauntlet_cannon_interval)
rpc_id(requester_id, "sync_gauntlet_volley_size", gauntlet_volley_size)
rpc_id(requester_id, "sync_game_mode", game_mode)
rpc_id(requester_id, "sync_area", selected_area)
+15 -19
View File
@@ -20,14 +20,14 @@ func fetch_mails() -> void:
if _is_fetching or not NakamaManager.session: return
_is_fetching = true
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "get_mail", "{}")
var result = await BackendService.get_mail()
_is_fetching = false
if r.is_exception():
push_error("[MailManager] Failed to fetch mails: " + r.get_exception().message)
if result.get("success", false) == false:
push_error("[MailManager] Failed to fetch mails: " + str(result.get("error", "")))
return
var payload = JSON.parse_string(r.payload)
var payload = result.get("data", {})
if payload and payload is Dictionary:
mails = payload.get("mails", [])
var state = payload.get("state", {})
@@ -50,12 +50,12 @@ func _update_unread_count() -> void:
unread_count_changed.emit(count)
func claim_reward(mail_id: String) -> bool:
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "claim_mail_reward", JSON.stringify({"mail_id": mail_id}))
if r.is_exception():
push_error("[MailManager] Claim failed: " + r.get_exception().message)
var result = await BackendService.claim_mail_reward(mail_id)
if result.get("success", false) == false:
push_error("[MailManager] Claim failed: " + str(result.get("error", "")))
return false
var payload = JSON.parse_string(r.payload)
var payload = result.get("data", {})
if payload and payload.get("success"):
claimed_ids = payload.get("claimed_ids", claimed_ids)
if mail_id not in read_ids:
@@ -69,12 +69,12 @@ func claim_reward(mail_id: String) -> bool:
return false
func delete_mail(mail_id: String) -> bool:
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "delete_mail", JSON.stringify({"mail_id": mail_id}))
if r.is_exception():
push_error("[MailManager] Delete failed: " + r.get_exception().message)
var result = await BackendService.delete_mail(mail_id)
if result.get("success", false) == false:
push_error("[MailManager] Delete failed: " + str(result.get("error", "")))
return false
var payload = JSON.parse_string(r.payload)
var payload = result.get("data", {})
if payload and payload.get("success"):
var deleted_ids = payload.get("deleted_ids", [])
# Remove from local array
@@ -102,13 +102,9 @@ func _save_inbox_state() -> void:
"deleted_ids": [],
"read_ids": read_ids
}
# We use storage write via a lightweight RPC or direct storage
var r = await NakamaManager.client.rpc_async(
NakamaManager.session, "save_mail_state",
JSON.stringify(state_payload)
)
if r.is_exception():
push_warning("[MailManager] Could not save mail state: " + r.get_exception().message)
var result = await BackendService.api_rpc_async("save_mail_state", JSON.stringify(state_payload))
if result.get("success", false) == false:
push_warning("[MailManager] Could not save mail state: " + str(result.get("error", "")))
func read_all_and_claim_all() -> void:
"""Mark all mails as read and claim all unclaimed rewards."""
+80 -18
View File
@@ -132,9 +132,9 @@ func simple_move_to(grid_position: Vector2i) -> bool:
return false
var gm = null
var main = player.get_tree().root.get_node_or_null("Main")
if main and main.get("gauntlet_manager"):
gm = main.gauntlet_manager
var main_gauntlet = player.get_tree().root.get_node_or_null("Main")
if main_gauntlet and main_gauntlet.get("gauntlet_manager"):
gm = main_gauntlet.gauntlet_manager
# Check if currently trapped
if gm and gm.is_active:
@@ -205,7 +205,14 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
return false
# === NEW LOGIC: Only allow push if in ATTACK MODE and NOT GHOST ===
if not player.get("is_attack_mode") or player.get("is_invisible"):
var has_smack = false
var main_for_smack = player.get_tree().root.get_node_or_null("Main")
var gm_for_smack = main_for_smack.get("gauntlet_manager") if main_for_smack else null
if gm_for_smack and gm_for_smack.is_active:
var att_pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
has_smack = gm_for_smack.has_smack_charged(att_pid)
if (not player.get("is_attack_mode") and not has_smack) or player.get("is_invisible"):
# Standard bumping effect (Visual only)
print("[Move] Push blocked: Not in attack mode or is Ghost (%s trying to push %s)" % [player.name, other_player.name])
if _can_rpc():
@@ -229,6 +236,51 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
NotificationManager.send_message(player, "Target is in Safe Zone!", NotificationManager.MessageType.WARNING)
return false
var gm = null
var main_push_check = player.get_tree().root.get_node_or_null("Main")
if main_push_check and main_push_check.get("gauntlet_manager"):
gm = main_push_check.gauntlet_manager
# IF Gauntlet Mode is active, handle special Gauntlet Smacks
if gm and gm.is_active:
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
var other_pid = other_player.get("peer_id") if "peer_id" in other_player else other_player.name.to_int()
# Check if attacker has smack
if not gm.has_smack_charged(pid):
# bump visuals
if _can_rpc():
player.rpc("sync_bump", target_pos, true)
elif player.has_method("sync_bump"):
player.sync_bump(target_pos, true)
return false
# Smack Clash: Both charged
if gm.has_smack_charged(other_pid):
print("[Move] SMACK CLASH! Both %s and %s consumed." % [player.name, other_player.name])
if multiplayer.is_server():
gm.consume_smack(pid)
gm.consume_smack(other_pid)
elif _can_rpc():
gm.rpc("consume_smack", pid) # Assuming consume_smack is @rpc
gm.rpc("consume_smack", other_pid)
if _can_rpc():
player.rpc("apply_stagger", 1.0)
other_player.rpc("apply_stagger", 1.0)
else:
player.apply_stagger(1.0)
other_player.apply_stagger(1.0)
return false
# Else standard push
if multiplayer.is_server():
gm.consume_smack(pid)
elif _can_rpc():
gm.rpc("consume_smack", pid)
# === SUPER PUSH (Attack Mode) ===
print("Player %s SUPER PUSHING %s!" % [player.name, other_player.name])
@@ -240,17 +292,27 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
player.sync_bump(target_pos, false)
SfxManager.play("attack_mode")
# 1. 3-Floor Knockback towards Starting Line (X=0)
var push_direction = Vector2i(-1, 0) # Backwards
# 1. 3-Floor Knockback
var push_direction = Vector2i(-1, 0) # Default back (Stop N Go)
var main_push = player.get_tree().root.get_node_or_null("Main")
var gm_push = main_push.gauntlet_manager if main_push and main_push.has_node("GauntletManager") else (main_push.get("gauntlet_manager") if main_push else null)
if gm_push and gm_push.is_active:
push_direction = direction # Use the direction of the attack
var pushed_to_pos = target_pos
var push_path = []
# Try to push up to 3 tiles back, building the path as we go
var hit_sticky = false
for i in range(3):
var next_back = pushed_to_pos + push_direction
if _can_push_to(next_back):
pushed_to_pos = next_back
push_path.append(Vector2(pushed_to_pos.x, pushed_to_pos.y))
if gm_push and gm_push.is_active and gm_push.is_sticky_cell(pushed_to_pos):
hit_sticky = true
break # stop pushing immediately upon touching sticky zone!
else:
break # Blocked by wall or edge
@@ -268,20 +330,20 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
other_player.target_position = pushed_to_pos # Logical update
# Check if landing spot is sticky
var main = player.get_tree().root.get_node_or_null("Main")
if main and main.get("gauntlet_manager"):
var gm = main.gauntlet_manager
if gm.is_active and gm.is_sticky_cell(pushed_to_pos):
var main_sticky = player.get_tree().root.get_node_or_null("Main")
if main_sticky and main_sticky.get("gauntlet_manager"):
var gm_sticky = main_sticky.gauntlet_manager
if gm_sticky.is_active and gm_sticky.is_sticky_cell(pushed_to_pos):
print("[Move] Player pushed into sticky cell at %s" % pushed_to_pos)
if multiplayer.is_server() or other_player.is_multiplayer_authority():
gm._trap_player(other_player)
gm_sticky._trap_player(other_player)
# 2. Apply freeze/stun effect (blue tint)
# 2. Apply freeze/stun effect
var stun_duration = 1.0 if (gm_push and gm_push.is_active) else 1.5
if _can_rpc():
other_player.rpc("apply_stagger", 1.5)
other_player.rpc("apply_stagger", stun_duration)
else:
# Handle local execution (e.g. offline or host-only logic)
other_player.apply_stagger(1.5)
other_player.apply_stagger(stun_duration)
# 4. Consume Boost (Full) - One hit per charge
if player.powerup_manager:
@@ -292,9 +354,9 @@ func try_push(target_pos: Vector2i, direction: Vector2i) -> bool:
if player.is_multiplayer_authority():
var is_sng = LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO)
if not is_sng:
var main = player.get_tree().get_root().get_node_or_null("Main")
if main:
var gcm = main.get_node_or_null("GoalsCycleManager")
var main_score = player.get_tree().get_root().get_node_or_null("Main")
if main_score:
var gcm = main_score.get_node_or_null("GoalsCycleManager")
if gcm:
if multiplayer.is_server():
# Server/Bot: Directly add score to specific player ID
+28 -14
View File
@@ -1,12 +1,12 @@
extends Node
# ScreenShakeManager - Handles camera shake effects for impact feedback
# ScreenShakeManager - Camera-local shake that follows the player
var camera: Camera3D
var shake_intensity: float = 0.0
var shake_duration: float = 0.0
var shake_timer: float = 0.0
var target_position: Vector3 # Replaces original_position
var shake_offset: Vector3 = Vector3.ZERO
# Shake presets
const SHAKE_TARGETED: Dictionary = {"intensity": 0.15, "duration": 0.4}
@@ -14,19 +14,33 @@ const SHAKE_GOAL_COMPLETE: Dictionary = {"intensity": 0.1, "duration": 0.3}
const SHAKE_LIGHT: Dictionary = {"intensity": 0.05, "duration": 0.2}
func initialize(p_camera: Camera3D):
"""Initialize with specific camera instance."""
camera = p_camera
if camera:
target_position = camera.position
print("[ScreenShakeManager] Initialized with camera: ", camera.name)
else:
push_warning("[ScreenShakeManager] Initialized with null camera")
func set_target_position(new_pos: Vector3, duration: float = 1.0):
"""Smoothly transition to a new base camera position."""
if target_position == new_pos:
func shake(intensity: float, duration: float) -> void:
"""Trigger a camera shake. Intensity in world units, duration in seconds."""
if intensity <= 0 or duration <= 0:
return
# Take the stronger shake if already active
if shake_timer > 0 and intensity <= shake_intensity:
return
shake_intensity = intensity
shake_duration = duration
shake_timer = duration
# Tween the target position
var tween = create_tween()
tween.tween_property(self, "target_position", new_pos, duration).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_IN_OUT)
func _process(delta: float) -> void:
if not camera or not is_instance_valid(camera):
return
if shake_timer > 0:
shake_timer -= delta
var progress = shake_timer / shake_duration
# Fade out intensity as timer expires
var current_intensity = shake_intensity * progress
# Random offset within intensity range
shake_offset = Vector3(
randf_range(-current_intensity, current_intensity),
randf_range(-current_intensity, current_intensity) * 0.5,
randf_range(-current_intensity, current_intensity)
)
camera.position += shake_offset
else:
shake_offset = Vector3.ZERO
+125
View File
@@ -0,0 +1,125 @@
extends Node
# SessionManager - Proactive session refresh and expiry handling
# Monitors session health and refreshes tokens before expiry
signal session_refreshed()
signal session_expired()
# =============================================================================
# Configuration
# =============================================================================
const CHECK_INTERVAL: float = 60.0 # Check every 60s
const REFRESH_BEFORE_EXPIRY: float = 300.0 # Refresh when <5min to expiry
const MAX_RETRY_ATTEMPTS: int = 3
const BASE_RETRY_DELAY: float = 1.0 # 1s, 2s, 4s exponential backoff
# =============================================================================
# State
# =============================================================================
var _refresh_timer: float = 0.0
var _is_refreshing: bool = false
# =============================================================================
# Lifecycle
# =============================================================================
func _ready():
set_process(false)
# Start monitoring when session is available
if NakamaManager:
if NakamaManager.session and not NakamaManager.session.is_expired():
_start_monitoring()
func _process(delta: float) -> void:
_refresh_timer -= delta
if _refresh_timer <= 0.0:
_refresh_timer = CHECK_INTERVAL
_check_session_health()
# =============================================================================
# Public API
# =============================================================================
func start_monitoring() -> void:
"""Start monitoring session health. Call after successful login."""
_start_monitoring()
func stop_monitoring() -> void:
"""Stop monitoring session health."""
set_process(false)
# =============================================================================
# Internal
# =============================================================================
func _start_monitoring() -> void:
_refresh_timer = CHECK_INTERVAL
set_process(true)
print("[SessionManager] Monitoring started")
func _check_session_health() -> void:
if not NakamaManager or not NakamaManager.session:
return
if NakamaManager.session.is_expired():
print("[SessionManager] Session expired!")
session_expired.emit()
if EventBus:
EventBus.emit(EventBus.EVENT_SESSION_EXPIRED)
return
# Check if within refresh window
var time_to_expiry = NakamaManager.session.expires_at - Time.get_unix_time_from_system()
if time_to_expiry < REFRESH_BEFORE_EXPIRY:
print("[SessionManager] Session expiring soon (%.0fs), refreshing..." % time_to_expiry)
_refresh_session()
func _refresh_session() -> void:
if _is_refreshing:
return
_is_refreshing = true
var success = await _attempt_refresh_with_retry()
_is_refreshing = false
if success:
print("[SessionManager] Session refreshed successfully")
session_refreshed.emit()
if EventBus:
EventBus.emit(EventBus.EVENT_SESSION_REFRESHED)
else:
print("[SessionManager] Session refresh failed after all retries")
session_expired.emit()
if EventBus:
EventBus.emit(EventBus.EVENT_SESSION_EXPIRED)
func _attempt_refresh_with_retry() -> bool:
for attempt in range(MAX_RETRY_ATTEMPTS):
var delay = BASE_RETRY_DELAY * pow(2, attempt)
if attempt > 0:
print("[SessionManager] Retry attempt %d/%d after %.1fs" % [attempt + 1, MAX_RETRY_ATTEMPTS, delay])
await get_tree().create_timer(delay).timeout
var result = await _do_refresh()
if result:
return true
return false
func _do_refresh() -> bool:
if not NakamaManager or not NakamaManager.client or not NakamaManager.session:
return false
var refresh_result = await NakamaManager.client.session_refresh_async(NakamaManager.session)
if refresh_result.is_exception():
print("[SessionManager] Refresh error: ", refresh_result.get_exception().message)
return false
# Update the session in NakamaManager
NakamaManager.session = refresh_result
return true
+1
View File
@@ -0,0 +1 @@
uid://dbuhvxs3c1xdq
@@ -558,6 +558,10 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true, only_c
if only_common or LobbyManager.is_game_mode(GameMode.Mode.STOP_N_GO):
# Spawn ONLY common tiles (7-10) in Stop n Go mode (User Request)
item_id = rng.randi_range(7, 10)
elif LobbyManager.is_game_mode(GameMode.Mode.GAUNTLET):
# Gauntlet mode: No power-up tile spawns from world.
# Only common tiles (7-10) spawn; Smack/Cleanser are handled separately.
item_id = rng.randi_range(7, 10)
else:
# Other modes: 80% Chance for Common Tile (7-10), 20% for PowerUp
if rng.randf() < 0.8:
+18 -38
View File
@@ -321,18 +321,14 @@ func purchase_item(item_id: String) -> String:
"idempotency_key": str(randi()) + "_" + str(Time.get_ticks_usec())
})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"purchase_item",
payload
)
var result = await BackendService.api_rpc_async("purchase_item", payload)
if result.is_exception():
var msg = result.get_exception().message
if result.get("success", false) == false:
var msg = str(result.get("message", "Unknown error"))
push_error("[UserProfileManager] Purchase failed: ", msg)
return msg
var response = JSON.parse_string(result.payload)
var response = result.get("data", {})
if typeof(response) == TYPE_DICTIONARY and response.has("success") and response.success == true:
await _reload_wallet()
if not inventory.has(item_id):
@@ -346,17 +342,13 @@ func purchase_item(item_id: String) -> String:
func fetch_shop_catalog() -> void:
if not NakamaManager.session: return
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"get_shop_catalog",
"{}"
)
var result = await BackendService.api_rpc_async("get_shop_catalog", "{}")
if result.is_exception():
push_error("[UserProfileManager] Failed to fetch shop catalog: ", result.get_exception().message)
if result.get("success", false) == false:
push_error("[UserProfileManager] Failed to fetch shop catalog: " + str(result.get("message", "")))
return
var payload: Dictionary = JSON.parse_string(result.payload)
var payload: Dictionary = result.get("data", {})
if payload and payload.has("catalog"):
shop_catalog = payload.catalog
if payload.has("featured_banners"):
@@ -368,13 +360,9 @@ func fetch_shop_catalog() -> void:
## The Nakama function requireAdmin() on the server prevents non-admin abuse.
func admin_topup_gold() -> bool:
if not NakamaManager.session: return false
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"admin_topup_gold",
"{}"
)
if result.is_exception():
push_error("[UserProfileManager] Topup failed: ", result.get_exception().message)
var result = await BackendService.api_rpc_async("admin_topup_gold", "{}")
if result.get("success", false) == false:
push_error("[UserProfileManager] Topup failed: " + str(result.get("message", "")))
return false
await _reload_wallet()
return true
@@ -389,21 +377,17 @@ func buy_currency(package_id: String) -> bool:
"store_type": "test"
})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"buy_currency",
payload
)
var result = await BackendService.api_rpc_async("buy_currency", payload)
if result.is_exception():
var msg = result.get_exception().message
if result.get("success", false) == false:
var msg = str(result.get("message", "Unknown error"))
if "NotEnoughFunds" in msg:
push_error("[UserProfileManager] Failed to buy currency: Not enough funds.")
else:
push_error("[UserProfileManager] Failed to buy currency: ", msg)
return false
var response = JSON.parse_string(result.payload)
var response = result.get("data", {})
if typeof(response) == TYPE_DICTIONARY and response.has("status") and response.status == "pending":
print("[UserProfileManager] Currency purchase pending verification.")
@@ -494,14 +478,10 @@ func submit_to_leaderboard() -> void:
"loadout_character": profile.get("loadout_character", "Copper")
})
var result = await NakamaManager.client.rpc_async(
NakamaManager.session,
"submit_score",
payload
)
var result = await BackendService.api_rpc_async("submit_score", payload)
if result.is_exception():
push_warning("[UserProfileManager] Leaderboard RPC failed: ", result.get_exception().message)
if result.get("success", false) == false:
push_warning("[UserProfileManager] Leaderboard RPC failed: " + str(result.get("message", "")))
else:
print("[UserProfileManager] Leaderboard score submitted: ", stats.get("high_score", 0))
+158 -16
View File
@@ -10,30 +10,46 @@ enum Platform {
MOBILE_NAKAMA
}
enum ErrorCode {
NONE,
NETWORK_ERROR,
UNAUTHORIZED,
FORBIDDEN,
NOT_FOUND,
INTERNAL_ERROR,
UNKNOWN_ERROR,
INSUFFICIENT_FUNDS
}
var current_platform: Platform = Platform.DESKTOP_STEAM
var steamworks_manager: Node # Only for auth ticket retrieval
var nakama_backend: Node
const MAX_RETRIES = 3
const RETRY_BACKOFF_BASE = 0.5
# Error mapping based on HTTP status codes returned by Nakama
var error_map := {
401: ErrorCode.UNAUTHORIZED,
403: ErrorCode.FORBIDDEN,
404: ErrorCode.NOT_FOUND,
500: ErrorCode.INTERNAL_ERROR,
}
func _ready() -> void:
_detect_platform()
_initialize_backend()
func _detect_platform() -> void:
# Detect if running on mobile or desktop
if OS.has_feature("android") or OS.has_feature("ios"):
current_platform = Platform.MOBILE_NAKAMA
else:
# Desktop: detect Steam by checking if GodotSteam class exists
# OS.has_feature("steam") is only true when launched through Steam client,
# but ClassDB.class_exists("Steam") is true whenever the GDExtension is enabled
if ClassDB.class_exists("Steam"):
current_platform = Platform.DESKTOP_STEAM
else:
current_platform = Platform.DESKTOP_NAKAMA
func _initialize_backend() -> void:
# All platforms use Nakama for backend features
# Steamworks is only initialized for auth ticket retrieval when GodotSteam is available
if current_platform == Platform.DESKTOP_STEAM:
_initialize_steamworks_for_auth()
@@ -57,25 +73,151 @@ func _initialize_nakama() -> void:
push_error("BackendService: NakamaManager not found")
func _connect_nakama_signals() -> void:
# Nakama signals are handled directly by NakamaManager
# No need to connect through BackendService
pass
## Utility Methods
func is_initialized() -> bool:
# Nakama is the primary backend for all features
if nakama_backend != null:
return true
# Steamworks is optional (only for auth)
return false
func get_platform_name() -> String:
return Platform.keys()[current_platform]
func get_steamworks_manager() -> Node:
# Returns SteamworksManager for auth ticket retrieval (Steam login)
return steamworks_manager
## Unified RPC with Retry and Error Mapping
func api_rpc_async(rpc_id: String, payload: String = "{}") -> Dictionary:
if not nakama_backend or not nakama_backend.client or not nakama_backend.session:
return { "success": false, "error": ErrorCode.UNAUTHORIZED, "message": "Not authenticated" }
var retries := 0
while retries <= MAX_RETRIES:
var result = await nakama_backend.client.rpc_async(nakama_backend.session, rpc_id, payload)
# NakamaAPI.ApiRpc has is_exception()
if result.is_exception():
var ex = result.get_exception()
# Transient network error or internal server logic error (500)
# Typically Nakama exception status returns HTTP matching codes or grpc codes
if retries < MAX_RETRIES and _is_transient_error(ex):
retries += 1
await get_tree().create_timer(RETRY_BACKOFF_BASE * pow(2.0, retries - 1)).timeout
continue
var parsed_msg := _parse_error_msg(ex.message)
var err_code = ErrorCode.UNKNOWN_ERROR
if error_map.has(ex.status_code):
err_code = error_map[ex.status_code]
else:
if "insufficient funds" in ex.message.to_lower():
err_code = ErrorCode.INSUFFICIENT_FUNDS
return {
"success": false,
"error": err_code,
"message": parsed_msg,
"raw_exception": ex
}
# Success
return {
"success": true,
"payload": result.payload
}
return { "success": false, "error": ErrorCode.NETWORK_ERROR, "message": "Max retries exceeded" }
func _is_transient_error(ex) -> bool:
# Retry on network failures (usually status 0 or 5xx)
if ex.status_code == 0 or ex.status_code >= 500:
return true
return false
func _parse_error_msg(msg: String) -> String:
# Parse JSON error from Nakama if any
if msg.begins_with("{"):
var test_json_conv = JSON.new()
if test_json_conv.parse(msg) == OK:
var parsed = test_json_conv.get_data()
if parsed.has("message"):
return parsed["message"]
return msg
## Typed Method Signatures
func admin_clear_global_chat(payload: String) -> Dictionary:
return await api_rpc_async("admin_clear_global_chat", payload)
func send_friend_request(target_id: String) -> Dictionary:
var payload = JSON.stringify({"target_user_id": target_id})
return await api_rpc_async("send_friend_request", payload)
func respond_friend_request(target_id: String, accept: bool) -> Dictionary:
var payload = JSON.stringify({"target_user_id": target_id, "accept": accept})
return await api_rpc_async("respond_friend_request", payload)
func perform_gacha_pull(gacha_id: String, count: int) -> Dictionary:
var payload = JSON.stringify({"gacha_id": gacha_id, "count": count})
return await api_rpc_async("perform_gacha_pull", payload)
func get_mail(payload: String = "{}") -> Dictionary:
return await api_rpc_async("get_mail", payload)
func claim_mail_reward(mail_id: String) -> Dictionary:
return await api_rpc_async("claim_mail_reward", JSON.stringify({"mail_id": mail_id}))
func delete_mail(mail_id: String) -> Dictionary:
return await api_rpc_async("delete_mail", JSON.stringify({"mail_id": mail_id}))
func send_mail(payload: String) -> Dictionary:
return await api_rpc_async("send_mail", payload)
func change_avatar(avatar_url: String) -> Dictionary:
return await api_rpc_async("change_avatar", JSON.stringify({"avatar_url": avatar_url}))
func change_username(new_username: String) -> Dictionary:
return await api_rpc_async("change_username", JSON.stringify({"new_username": new_username}))
func change_status(new_status: String) -> Dictionary:
return await api_rpc_async("change_status", JSON.stringify({"new_status": new_status}))
func change_bio(new_bio: String) -> Dictionary:
return await api_rpc_async("change_bio", JSON.stringify({"new_bio": new_bio}))
func query_users(payload: String) -> Dictionary:
return await api_rpc_async("query_users", payload)
func admin_give_currency(payload: String) -> Dictionary:
return await api_rpc_async("admin_give_currency", payload)
func get_daily_reward_config_admin() -> Dictionary:
return await api_rpc_async("get_daily_reward_config_admin", "{}")
func set_daily_reward_config(req: Dictionary) -> Dictionary:
return await api_rpc_async("set_daily_reward_config", JSON.stringify(req))
func get_daily_reward_state() -> Dictionary:
return await api_rpc_async("get_daily_reward_state", "{}")
func claim_daily_reward() -> Dictionary:
return await api_rpc_async("claim_daily_reward", "{}")
func sync_leaderboard() -> Dictionary:
return await api_rpc_async("sync_leaderboard", "{}")
func get_leaderboard_stats() -> Dictionary:
return await api_rpc_async("get_leaderboard_stats", "{}")
func debug_add_exp(exp_amount: int) -> Dictionary:
return await api_rpc_async("debug_add_exp", JSON.stringify({"exp": exp_amount}))
func reset_stats() -> Dictionary:
return await api_rpc_async("reset_stats", "{}")
func search_users(payload: String) -> Dictionary:
return await api_rpc_async("search_users", payload)
func send_lobby_invite(to_user_id: String, match_id: String) -> Dictionary:
var payload = JSON.stringify({"to_user_id": to_user_id, "match_id": match_id})
return await api_rpc_async("send_lobby_invite", payload)
+3
View File
@@ -472,6 +472,9 @@ func spawn_tiles_around(count: int = 4):
if roll < 0.6 or (LobbyManager and LobbyManager.game_mode == "Stop n Go"):
# 60% Normal Tile (7-10) OR 100% if Stop n Go (User Request)
item_id = rng.randi_range(7, 10)
elif LobbyManager and LobbyManager.get_game_mode() == GameMode.Mode.GAUNTLET:
# Gauntlet mode: No power-up spawns from Tekton grabs
item_id = rng.randi_range(7, 10)
else:
# 40% PowerUp (11-14)
var mode = GameMode.Mode.FREEMODE
+4 -8
View File
@@ -250,16 +250,12 @@ func _on_tab_changed(tab_index: int) -> void:
func _rpc(rpc_name: String, payload: Dictionary) -> Dictionary:
if not NakamaManager.client or not NakamaManager.session:
return {"error": "Not connected"}
var result = await NakamaManager.client.rpc_async(
NakamaManager.session, rpc_name, JSON.stringify(payload)
)
if result.is_exception():
var err: String = result.get_exception().message
var result = await BackendService.api_rpc_async(rpc_name, JSON.stringify(payload))
if result.get("success", false) == false:
var err: String = str(result.get("message", "Unknown error"))
_set_status(err, CLR_STATUS_ERR)
return {"error": err}
if result.payload:
return JSON.parse_string(result.payload)
return {}
return result.get("data", {})
func _set_status(msg: String, color: Color = CLR_DIM) -> void:
status_label.text = msg
+8 -10
View File
@@ -15,14 +15,12 @@ func _load_config():
status_lbl.text = "Not authenticated"
return
status_lbl.text = "Loading..."
var res = await NakamaManager.client.rpc_async(NakamaManager.session, "get_daily_reward_config_admin", "{}")
if res.is_exception():
status_lbl.text = "Error: " + res.get_exception().message
var result = await BackendService.get_daily_reward_config_admin()
if result.get("success", false) == false:
status_lbl.text = "Error: " + str(result.get("error", ""))
return
var json = JSON.new()
if json.parse(res.payload) == OK:
var config = json.get_data().get("config", {})
var config = result.get("data", {}).get("config", {})
if config.is_empty():
# generate default 12 months for 2026/2027
var year = 2026
@@ -43,9 +41,9 @@ func _on_save():
return
status_lbl.text = "Saving..."
var req = { "config": json.get_data() }
var res = await NakamaManager.client.rpc_async(NakamaManager.session, "set_daily_reward_config", JSON.stringify(req))
if res.is_exception():
status_lbl.text = "Save error: " + res.get_exception().message
var config = json.get_data()
var result = await BackendService.set_daily_reward_config(config)
if result.get("success", false) == false:
status_lbl.text = "Save error: " + str(result.get("error", ""))
else:
status_lbl.text = "Config saved successfully!"
+7 -11
View File
@@ -39,14 +39,12 @@ func _fetch_state():
status_label.text = "Must be logged in to claim rewards."
return
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_daily_reward_state", "{}")
if result.is_exception():
status_label.text = "Failed to load: " + result.get_exception().message
var result = await BackendService.get_daily_reward_state()
if result.get("success", false) == false:
status_label.text = "Failed to load: " + str(result.get("error", ""))
return
var json = JSON.new()
if json.parse(result.payload) == OK:
var data = json.get_data()
var data = result.get("data", {})
_month_rewards = data.get("month_rewards", [])
var state = data.get("state", {})
@@ -65,8 +63,6 @@ func _fetch_state():
month_label.text = months[_server_month - 1] + " Sign-in"
_update_ui()
else:
status_label.text = "Error parsing data."
func _get_reward_display_data(type: String) -> Dictionary:
if type == "gold": return {"icon": "💰", "name": "Gold"}
@@ -145,9 +141,9 @@ func _on_claim_pressed():
claim_btn.text = "Claiming..."
status_label.text = ""
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "claim_daily_reward", "{}")
if result.is_exception():
status_label.text = "Failed to claim: " + result.get_exception().message
var result = await BackendService.claim_daily_reward()
if result.get("success", false) == false:
status_label.text = "Failed to claim: " + str(result.get("error", ""))
claim_btn.disabled = false
claim_btn.text = "Sign In"
return
+8 -12
View File
@@ -72,11 +72,11 @@ func show_panel() -> void:
status_label.text = "Syncing scores..."
# Bulk-sync all users' storage stats to native leaderboard (server-side operation)
if NakamaManager.session:
var sync_result = await NakamaManager.client.rpc_async(NakamaManager.session, "sync_leaderboard", "{}")
if sync_result.is_exception():
push_error("[Leaderboard] sync_leaderboard RPC failed: ", sync_result.get_exception().message)
var sync_result = await BackendService.sync_leaderboard()
if sync_result.get("success", false) == false:
push_error("[Leaderboard] sync_leaderboard RPC failed: " + str(sync_result.get("error", "")))
else:
print("[Leaderboard] Server sync finished: ", sync_result.payload)
print("[Leaderboard] Server sync finished: ", sync_result.get("data", {}))
_fetch_leaderboard_data()
func _on_close_pressed() -> void:
@@ -150,16 +150,14 @@ func _fetch_native_leaderboard() -> Array:
func _fetch_via_rpc() -> void:
"""Fallback: call server RPC which reads the same native leaderboard."""
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "get_leaderboard_stats", "{}")
var result = await BackendService.get_leaderboard_stats()
if result.is_exception():
if result.get("success", false) == false:
status_label.text = "Failed to load leaderboard"
push_error("[Leaderboard] RPC failed: ", result.get_exception().message)
push_error("[Leaderboard] RPC failed: " + str(result.get("error", "")))
return
var json := JSON.new()
if json.parse(result.payload) == OK:
var data = json.get_data()
var data = result.get("data", {})
if data.has("leaderboard") and data.leaderboard.size() > 0:
_apply_local_overrides(data.leaderboard)
leaderboard_data = data.leaderboard
@@ -171,8 +169,6 @@ func _fetch_via_rpc() -> void:
else:
# No records exist yet — show a helpful hint
status_label.text = "No scores recorded yet.\nPlay a match to appear here!"
else:
status_label.text = "Error parsing server data"
func _calculate_win_rates() -> void:
for entry in leaderboard_data:
+5 -7
View File
@@ -645,11 +645,9 @@ func _setup_account_settings_ui() -> void:
"new_email": new_email_input.text,
"new_password": new_pass_input.text
}
var r = await NakamaManager.client.rpc_async(
NakamaManager.session, "change_credentials", JSON.stringify(payload)
)
if r.is_exception():
_set_status("Error: " + r.get_exception().message, Color.RED)
var r = await BackendService.api_rpc_async("change_credentials", JSON.stringify(payload))
if r.get("success", false) == false:
_set_status("Error: " + str(r.get("message", "Unknown error")), Color.RED)
else:
_set_status("Credentials updated!", Color(0.4, 1.0, 0.4))
acc_settings_dialog.hide()
@@ -671,8 +669,8 @@ func _setup_account_settings_ui() -> void:
)
func _on_reset_stats_confirmed() -> void:
var r = await NakamaManager.client.rpc_async(NakamaManager.session, "reset_stats", "{}")
if not r.is_exception():
var r = await BackendService.api_rpc_async("reset_stats", "{}")
if r.get("success", false) == true:
UserProfileManager.stats = {
"games_played": 0, "games_won": 0, "games_lost": 0,
"total_score": 0, "high_score": 0, "play_time_minutes": 0
+4 -5
View File
@@ -103,15 +103,14 @@ func _on_search_pressed() -> void:
return
_search_btn.disabled = true
var payload = JSON.stringify({"query": query})
var result = await NakamaManager.client.rpc_async(NakamaManager.session, "search_users", payload)
var result = await BackendService.search_users(query)
_search_btn.disabled = false
if result.is_exception():
push_warning("[Social] Search failed: " + result.get_exception().message)
if result.get("success", false) == false:
push_warning("[Social] Search failed: " + str(result.get("error", "")))
return
var response = JSON.parse_string(result.payload)
var response = result.get("data", {})
if not response or not response.has("users"):
return
+55
View File
@@ -0,0 +1,55 @@
import re
with open("scripts/managers/gauntlet_manager.gd", "r") as f:
content = f.read()
new_process = """func _process(delta: float) -> void:
if not is_active:
return
elapsed_time += delta
# Phase escalation
_check_phase_transition()
# Cannon timer (server only)
if multiplayer.is_server():
cannon_timer -= delta
if cannon_timer <= 0.0:
_fire_volley()
cannon_timer = cannon_interval
# Smack mechanic update
var all_players = get_tree().get_nodes_in_group("Players")
for player in all_players:
var pid = player.get("peer_id") if "peer_id" in player else player.name.to_int()
if not smack_cooldowns.has(pid) and not smack_charged.has(pid):
smack_cooldowns[pid] = SMACK_COOLDOWN
smack_charged[pid] = 0.0
if smack_cooldowns[pid] > 0:
smack_cooldowns[pid] -= delta
if smack_cooldowns[pid] <= 0:
smack_cooldowns[pid] = 0.0
smack_charged[pid] = SMACK_CHARGE_WINDOW
if player.has_method("sync_modulate"):
if _can_rpc():
player.rpc("sync_modulate", Color.PINK)
else:
player.sync_modulate(Color.PINK)
elif smack_charged[pid] > 0:
smack_charged[pid] -= delta
if smack_charged[pid] <= 0:
smack_charged[pid] = 0.0
smack_cooldowns[pid] = SMACK_COOLDOWN
if player.has_method("sync_modulate"):
if _can_rpc():
player.rpc("sync_modulate", Color.WHITE)
else:
player.sync_modulate(Color.WHITE)"""
content = re.sub(r"func _process\(delta: float\) -> void:.*?(?=\n# =+)", new_process, content, flags=re.DOTALL)
with open("scripts/managers/gauntlet_manager.gd", "w") as f:
f.write(content)