feat: the rebuild gamemode of "Gauntlet"
This commit is contained in:
@@ -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()
|
||||
@@ -0,0 +1 @@
|
||||
uid://cph7fr22ohbwy
|
||||
@@ -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])
|
||||
|
||||
@@ -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 []
|
||||
|
||||
|
||||
@@ -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,10 +140,22 @@ 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:
|
||||
@@ -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
|
||||
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:
|
||||
var target_pos = Vector3(target.x * cs.x + cs.x / 2.0, 0, target.y * cs.z + cs.z / 2.0)
|
||||
cannon_instance.rpc("spawn_projectile_rpc", target_pos, telegraph_time)
|
||||
|
||||
# Wait telegraph duration, then apply impact
|
||||
await get_tree().create_timer(telegraph_time).timeout
|
||||
# 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 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 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)
|
||||
@@ -421,15 +558,100 @@ func _get_nearby_valid_cells(center: Vector2i, radius: int, valid: Array) -> Arr
|
||||
|
||||
# =============================================================================
|
||||
# Telegraph & Impact (RPCs)
|
||||
# =============================================================================
|
||||
# =============================================================================
|
||||
|
||||
@rpc("authority", "call_local", "reliable")
|
||||
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."""
|
||||
"""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:
|
||||
@@ -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:
|
||||
@@ -549,10 +982,129 @@ 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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
# 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)
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -15,26 +15,24 @@ 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", {})
|
||||
if config.is_empty():
|
||||
# generate default 12 months for 2026/2027
|
||||
var year = 2026
|
||||
for m in range(1, 13):
|
||||
var m_str = "%d-%02d" % [year, m]
|
||||
var arr = []
|
||||
for d in range(30):
|
||||
arr.append(min(10 + d*5, 100)) # Reward is star currency, max 100
|
||||
config[m_str] = arr
|
||||
|
||||
text_edit.text = JSON.stringify(config, "\t")
|
||||
status_lbl.text = "Loaded"
|
||||
var config = result.get("data", {}).get("config", {})
|
||||
if config.is_empty():
|
||||
# generate default 12 months for 2026/2027
|
||||
var year = 2026
|
||||
for m in range(1, 13):
|
||||
var m_str = "%d-%02d" % [year, m]
|
||||
var arr = []
|
||||
for d in range(30):
|
||||
arr.append(min(10 + d*5, 100)) # Reward is star currency, max 100
|
||||
config[m_str] = arr
|
||||
|
||||
text_edit.text = JSON.stringify(config, "\t")
|
||||
status_lbl.text = "Loaded"
|
||||
|
||||
func _on_save():
|
||||
var json = JSON.new()
|
||||
@@ -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!"
|
||||
|
||||
@@ -39,34 +39,30 @@ 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()
|
||||
_month_rewards = data.get("month_rewards", [])
|
||||
var state = data.get("state", {})
|
||||
var data = result.get("data", {})
|
||||
_month_rewards = data.get("month_rewards", [])
|
||||
var state = data.get("state", {})
|
||||
|
||||
var claimed_list = state.get("claimed_days", [])
|
||||
_claimed_days.clear()
|
||||
for item in claimed_list:
|
||||
_claimed_days.append(int(item))
|
||||
|
||||
var claimed_list = state.get("claimed_days", [])
|
||||
_claimed_days.clear()
|
||||
for item in claimed_list:
|
||||
_claimed_days.append(int(item))
|
||||
|
||||
_can_claim = data.get("can_claim_today", false)
|
||||
_today = data.get("today_date", "")
|
||||
_today_index = data.get("today_index", 0)
|
||||
_server_month = data.get("server_month", 1)
|
||||
|
||||
var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
if _server_month >= 1 and _server_month <= 12:
|
||||
month_label.text = months[_server_month - 1] + " Sign-in"
|
||||
|
||||
_update_ui()
|
||||
else:
|
||||
status_label.text = "Error parsing data."
|
||||
_can_claim = data.get("can_claim_today", false)
|
||||
_today = data.get("today_date", "")
|
||||
_today_index = data.get("today_index", 0)
|
||||
_server_month = data.get("server_month", 1)
|
||||
|
||||
var months = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]
|
||||
if _server_month >= 1 and _server_month <= 12:
|
||||
month_label.text = months[_server_month - 1] + " Sign-in"
|
||||
|
||||
_update_ui()
|
||||
|
||||
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
|
||||
|
||||
@@ -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,29 +150,25 @@ 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()
|
||||
if data.has("leaderboard") and data.leaderboard.size() > 0:
|
||||
_apply_local_overrides(data.leaderboard)
|
||||
leaderboard_data = data.leaderboard
|
||||
_calculate_win_rates()
|
||||
status_label.text = ""
|
||||
_sort_by(current_sort_key)
|
||||
if leaderboard_data.size() > 0:
|
||||
_show_entry_preview(0)
|
||||
else:
|
||||
# No records exist yet — show a helpful hint
|
||||
status_label.text = "No scores recorded yet.\nPlay a match to appear here!"
|
||||
var data = result.get("data", {})
|
||||
if data.has("leaderboard") and data.leaderboard.size() > 0:
|
||||
_apply_local_overrides(data.leaderboard)
|
||||
leaderboard_data = data.leaderboard
|
||||
_calculate_win_rates()
|
||||
status_label.text = ""
|
||||
_sort_by(current_sort_key)
|
||||
if leaderboard_data.size() > 0:
|
||||
_show_entry_preview(0)
|
||||
else:
|
||||
status_label.text = "Error parsing server data"
|
||||
# No records exist yet — show a helpful hint
|
||||
status_label.text = "No scores recorded yet.\nPlay a match to appear here!"
|
||||
|
||||
func _calculate_win_rates() -> void:
|
||||
for entry in leaderboard_data:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user