feat: Add PlayerboardManager, SpecialTilesManager, PowerupInventoryUI, and new touch control/power tile assets.

This commit is contained in:
Yogi Wiguna
2026-02-02 18:01:42 +08:00
parent 614d678b84
commit 753757d273
20 changed files with 558 additions and 198 deletions
+151 -165
View File
@@ -6,13 +6,26 @@ extends Node
const HOLO_TILES = [11, 12, 13, 14]
enum SpecialEffect {
BURN_TILES, # Remove 3x3 pattern tiles on random opponent
SPAWN_TILES, # Spawn 3x3 pattern tiles around activating player
FREEZE_PLAYER, # Freeze random opponent for 3 seconds
BLOCK_FLOOR, # Make nearby tile non-walkable for 9 seconds
INVISIBLE_MODE # Speed boost + auto-grab + shield for 6 seconds
FASTER_SPEED, # ID 11
AREA_FREEZE, # ID 12
BLOCK_FLOOR, # ID 13
INVISIBLE_MODE # ID 14
}
# Levels & Cooldowns
var powerup_levels: Dictionary = {} # EffectEnum -> int (1 to 8)
var powerup_cooldowns: Dictionary = {} # EffectEnum -> float (Time Remaining)
var active_buffs: Dictionary = {} # EffectEnum -> float (Duration Running)
# Cooldown Constants (Level 1 / Level 8)
const COOLDOWN_L1 = 15.0
const COOLDOWN_L8 = 5.0
const FASTER_DURATION = 5.0
const FREEZE_SLOW_DURATION = 3.0
signal cooldown_updated(effect: int, time_left: float, max_time: float)
signal powerup_unlocked(effect: int, level: int)
# Random shape patterns for 3x3 area (relative offsets from center)
const PATTERNS = {
"T": [Vector2i(0, -1), Vector2i(-1, 0), Vector2i(0, 0), Vector2i(1, 0)],
@@ -45,12 +58,9 @@ var invisible_timer: float = 0.0
# Stores count of each power-up type. Max 1 per type as per user request?
# "player can store 1 of each different power up"
var inventory = {
SpecialEffect.BURN_TILES: false, # Coin
SpecialEffect.SPAWN_TILES: false, # (Merged with Coin or deprecated? User said "coin : random between two")
# Let's map Items 7-10 to Effects
# 7=Heart=Block, 8=Diamond=Freeze, 9=Star=Invisible, 10=Coin=Burn/Spawn
SpecialEffect.FASTER_SPEED: false,
SpecialEffect.AREA_FREEZE: false,
SpecialEffect.BLOCK_FLOOR: false,
SpecialEffect.FREEZE_PLAYER: false,
SpecialEffect.INVISIBLE_MODE: false
}
@@ -68,37 +78,55 @@ func initialize(p_player: Node3D, p_gridmap: Node):
# =============================================================================
func get_effect_from_item(item_id: int) -> int:
match item_id:
7: return SpecialEffect.BLOCK_FLOOR # Heart
8: return SpecialEffect.FREEZE_PLAYER # Diamond
9: return SpecialEffect.INVISIBLE_MODE # Star
10: return SpecialEffect.BURN_TILES # Coin (Handles Burn or Spawn)
11: return SpecialEffect.FASTER_SPEED
12: return SpecialEffect.AREA_FREEZE
13: return SpecialEffect.BLOCK_FLOOR
14: return SpecialEffect.INVISIBLE_MODE
_: return -1
func add_powerup_from_item(item_id: int):
var effect = get_effect_from_item(item_id)
if effect != -1:
if effect == -1: return
# Unlock or Level Up
if not inventory.get(effect, false):
# New Unlock
inventory[effect] = true
powerup_levels[effect] = 1
emit_signal("inventory_updated", inventory)
print("Player %s picked up powerup for effect %s" % [player.name, SpecialEffect.keys()[effect]])
emit_signal("powerup_unlocked", effect, 1)
print("Player %s unlocked powerup %s (Lvl 1)" % [player.name, SpecialEffect.keys()[effect]])
else:
# Level Up
var current_lvl = powerup_levels.get(effect, 1)
if current_lvl < 8:
powerup_levels[effect] = current_lvl + 1
emit_signal("powerup_unlocked", effect, powerup_levels[effect])
print("Player %s leveled up %s to Lvl %d" % [player.name, SpecialEffect.keys()[effect], powerup_levels[effect]])
if player.is_multiplayer_authority():
rpc("sync_inventory_add", effect)
if player.is_multiplayer_authority():
rpc("sync_inventory_add", effect, powerup_levels[effect])
@rpc("any_peer", "call_local", "reliable")
func sync_inventory_add(effect: int):
func sync_inventory_add(effect: int, level: int):
inventory[effect] = true
powerup_levels[effect] = level
emit_signal("inventory_updated", inventory)
emit_signal("powerup_unlocked", effect, level)
func remove_powerup(effect: int):
inventory[effect] = false
emit_signal("inventory_updated", inventory)
if player.is_multiplayer_authority():
rpc("sync_inventory_remove", effect)
# We DO NOT remove item from inventory on use anymore (for reusable leveling system)?
# Wait, user request: "cooldown is decarese and max is level 8"
# Does "activate it" consume the item?
# User says "it will cooldown agian for 15 seconds".
# Implies PERMANENT UNLOCK once picked up, reusable with cooldown.
# So we NEVER set inventory[effect] = false unless reset.
pass
@rpc("any_peer", "call_local", "reliable")
func sync_inventory_remove(effect: int):
inventory[effect] = false
emit_signal("inventory_updated", inventory)
# Deprecated for new system, but kept for compatibility if needed
pass
# =============================================================================
# Activate Effect (Explicit Target)
@@ -107,37 +135,40 @@ func sync_inventory_remove(effect: int):
func activate_effect(effect: int, target_player: Node3D = null):
# Validation
if not inventory.get(effect, false):
return # Start/Client mismatch
print("PowerUp %s not found in inventory or false. Inventory: %s" % [effect, inventory])
return
# Consume
remove_powerup(effect)
# Check Cooldown
if powerup_cooldowns.get(effect, 0.0) > 0:
print("PowerUp %s on cooldown." % SpecialEffect.keys()[effect])
return
# Calculate Cooldown based on Level
var level = powerup_levels.get(effect, 1)
# Linear Interp: Lvl 1 = 15s, Lvl 8 = 5s
# Slope = (5 - 15) / (8 - 1) = -10 / 7 = -1.428...
var cooldown_time = COOLDOWN_L1 + ((level - 1) * (COOLDOWN_L8 - COOLDOWN_L1) / 7.0)
print("[SpecialTiles] Player %s activated %s on %s" % [player.name, SpecialEffect.keys()[effect], target_player.name if target_player else "Self"])
powerup_cooldowns[effect] = cooldown_time
emit_signal("cooldown_updated", effect, cooldown_time, cooldown_time)
print("[SpecialTiles] Player %s activated %s (Lvl %d). Cooldown: %.1fs" % [player.name, SpecialEffect.keys()[effect], level, cooldown_time])
match effect:
SpecialEffect.BURN_TILES:
# Coin: Random between Burn or Spawn
# "coin : random between two... make it not use directly" -> When activated, it does one of them.
if rng.randf() < 0.5:
_execute_burn_tiles(target_player)
else:
# Spawn tiles around SELF (as per user request "around activating player")
_execute_spawn_tiles(player)
SpecialEffect.FASTER_SPEED:
_execute_faster_speed()
SpecialEffect.AREA_FREEZE:
_execute_area_freeze()
SpecialEffect.BLOCK_FLOOR:
if target_player:
_execute_block_floor(target_player)
SpecialEffect.FREEZE_PLAYER:
_execute_freeze_zones() # No target needed anymore
_execute_block_floor(target_player if target_player else player) # Self or Target? Usually defensive wall? "Wall Block"
SpecialEffect.INVISIBLE_MODE:
# Always self
_execute_invisible_mode(player)
_execute_invisible_mode(player) # Or whatever ID 14 is
# Play generic cast animation or sound?
if player.is_multiplayer_authority():
player.rpc("trigger_screen_shake", "light")
# Sync cooldown to others not strictly needed unless UI shows it?
# Probably local UI only.
# =============================================================================
@@ -147,144 +178,85 @@ func activate_effect(effect: int, target_player: Node3D = null):
func is_holo_tile(item_id: int) -> bool:
return item_id in HOLO_TILES
# =============================================================================
# Trigger random special effect
# =============================================================================
func trigger_random_effect():
var effect = rng.randi() % SpecialEffect.size()
print("[SpecialTiles] Player %s triggered effect: %s" % [player.name, SpecialEffect.keys()[effect]])
match effect:
SpecialEffect.BURN_TILES:
_execute_burn_tiles(_get_random_opponent())
SpecialEffect.SPAWN_TILES:
_execute_spawn_tiles(player)
SpecialEffect.FREEZE_PLAYER:
_execute_freeze_zones()
SpecialEffect.BLOCK_FLOOR:
_execute_block_floor(player)
SpecialEffect.INVISIBLE_MODE:
_execute_invisible_mode(player)
# Sync effect to all clients
if player.is_multiplayer_authority():
rpc("sync_effect_triggered", effect)
@rpc("any_peer", "call_local", "reliable")
func sync_effect_triggered(effect: int):
print("[SpecialTiles] Synced effect %s for player %s" % [SpecialEffect.keys()[effect], player.name])
# =============================================================================
# Effect Implementations
# Effect Implementations (New)
# =============================================================================
func _execute_burn_tiles(target: Node3D):
if not target: return
# Knock tiles from target's board
var board_indices = []
for i in range(target.playerboard.size()):
if target.playerboard[i] != -1:
board_indices.append(i)
if board_indices.is_empty():
return
var burn_count = rng.randi_range(3, 6)
board_indices.shuffle()
# Drop logic similar to burn but we just destroy them or scatter?
# "BURN_TILES, # Remove 3x3 pattern tiles" -> User request says Remove pattern.
# Original code did burn.
# Let's just remove them.
for i in range(min(burn_count, board_indices.size())):
target.playerboard[board_indices[i]] = -1
if player.is_multiplayer_authority():
# Sync the change on the target
var main = player.get_tree().get_root().get_node_or_null("Main")
if main:
main.rpc("sync_playerboard", target.name.to_int(), target.playerboard)
NotificationManager.send_message(target, NotificationManager.MESSAGES.BURNED_BY % player.display_name, NotificationManager.MessageType.WARNING)
func _execute_faster_speed():
if player.movement_manager:
player.movement_manager.set_speed_multiplier(1.5) # 50% faster
active_buffs[SpecialEffect.FASTER_SPEED] = FASTER_DURATION
NotificationManager.send_message(player, "Speed Boost! (5s)", NotificationManager.MessageType.POWERUP)
func _execute_spawn_tiles(target: Node3D):
# Spawn tiles around TARGET (usually Self for Coin)
spawn_powerups_around(target.current_position, false) # False = normal tiles? User says "Spawn 3x3 pattern tiles"
# Okay "SPAWN_TILES" usually means useful numbered tiles.
# But "spawn / replace your nearby tiles into power up" is for Headbutt.
# For Coin->Spawn_Tiles: "Spawn 3x3 pattern tiles around activating player ( self )".
# So random number tiles (7-10 are powerups, 1-6 are normal? No, 7-10 are patterns in this game).
# "Spawn 3x3 pattern tiles" -> Tiles with ID 7,8,9,10 are the goal tiles.
NotificationManager.send_message(target, NotificationManager.MESSAGES.TILES_SPAWNED, NotificationManager.MessageType.POWERUP)
func _execute_freeze_zones():
# Area-based freeze: spawn pattern around activating player
func _execute_area_freeze():
# "Area Freeze... slow their speed movement for 3s"
# Reuse freeze logic but simpler duration
var center = player.current_position
var pattern_keys = PATTERNS.keys()
var pattern_name = pattern_keys[rng.randi() % pattern_keys.size()]
var pattern = PATTERNS[pattern_name]
# 3x3 around player
var radius = 1
print("Player %s executing Area Freeze" % player.name)
print("[SpecialTiles] Player %s spawning FREEZE zones in pattern %s" % [player.name, pattern_name])
for offset in pattern:
var pos = center + offset
if enhanced_gridmap.is_position_valid(pos):
# Add to active zones
freeze_zones.append({
"position": pos,
"timer": FREEZE_ZONE_DURATION
})
# Sync visual (use item ID 15 for "Icy" floor marker or similar)
if player.is_multiplayer_authority():
# Get enemies in radius
var all_players = player.get_tree().get_nodes_in_group("Players")
for p in all_players:
if p == player: continue
var dist = Vector2(p.current_position.x - center.x, p.current_position.y - center.y).length()
if dist <= 1.5: # Adjacent or on top
p.rpc("apply_slow_effect", FREEZE_SLOW_DURATION)
# Visual Feedback (Icy Floor for 3s?)
if player.is_multiplayer_authority():
for x in range(-1, 2):
for y in range(-1, 2):
var pos = center + Vector2i(x, y)
var main = player.get_tree().get_root().get_node_or_null("Main")
if main:
# Layer 2 for effect overlays
main.rpc("sync_grid_item", pos.x, 2, pos.y, 15)
NotificationManager.send_message(player, NotificationManager.MESSAGES.FREEZE_ZONE_READY if NotificationManager.MESSAGES.has("FREEZE_ZONE_READY") else "Freeze zones deployed!", NotificationManager.MessageType.POWERUP)
if main: main.rpc("sync_grid_item", pos.x, 2, pos.y, 15) # Icy decal
# Cleanup visual timer (managed locally by author)
get_tree().create_timer(FREEZE_SLOW_DURATION).timeout.connect(func():
for x in range(-1, 2):
for y in range(-1, 2):
var pos = center + Vector2i(x, y)
var main = player.get_tree().get_root().get_node_or_null("Main")
if main: main.rpc("sync_grid_item", pos.x, 2, pos.y, -1)
)
func _execute_block_floor(target: Node3D):
# Make nearby tile non-walkable for 9 seconds
# Target the floor UNDER or NEAR the target?
# "block ( other player ) spawn BLOCK_FLOOR Make nearby tile non-walkable"
# Let's block the tile they are standing on + neighbors?
# Existing logic for blocking, reused
# "Wall Block" usually means block WHERE YOU ARE or FRONT?
# Original code blocked target's floor.
# If target is self, blocks self's floor? Maybe defensive.
# "Have the same cooldown as Faster" -> Just logic reuse.
var center = target.current_position
var neighbors = enhanced_gridmap.get_neighbors(center, 1) # Include diagonals
neighbors.append({"position": center}) # Add center
var neighbors = enhanced_gridmap.get_neighbors(center, 1)
neighbors.append({"position": center})
for n in neighbors:
var pos = n.position
var block_pos = Vector3i(pos.x, 0, pos.y)
# Block it
if player.is_multiplayer_authority():
var main = player.get_tree().get_root().get_node_or_null("Main")
if main:
# 4 = Blocked Tile ID usually
main.rpc("sync_grid_item", block_pos.x, block_pos.y, block_pos.z, 4)
var original_item = enhanced_gridmap.get_cell_item(block_pos)
var original_item = 0 # Assume floor
# If we have logic to save original, fine, but for now just 0
blocked_tiles.append({
"position": block_pos,
"original_item": original_item if original_item != 4 else 0, # Restore to 0 (floor) if confused
"original_item": 0,
"timer": BLOCK_DURATION
})
NotificationManager.send_message(target, NotificationManager.MESSAGES.FLOOR_BLOCKED, NotificationManager.MessageType.WARNING)
NotificationManager.send_message(target, "Wall Block Created!", NotificationManager.MessageType.POWERUP)
func _execute_invisible_mode(target: Node3D):
# Existing logic kept as ID 14 placeholder
target.is_invisible = true
# Auto-disable after duration handled in Player._process or here?
# SpecialTilesManager seems to handle effect timers.
invisible_timer = INVISIBLE_DURATION
NotificationManager.send_message(target, NotificationManager.MESSAGES.INVISIBLE, NotificationManager.MessageType.POWERUP)
NotificationManager.send_message(target, "Invisible Mode!", NotificationManager.MessageType.POWERUP)
# =============================================================================
@@ -293,7 +265,7 @@ func _execute_invisible_mode(target: Node3D):
func spawn_powerups_around(center: Vector2i, force_powerups: bool = true):
# "spawn / replace your nearby tiles into power up ( special tiles )"
# PowerUp Tiles are 7, 8, 9, 10 (Heart, Diamond, Star, Coin)
# New PowerUp Tiles are 11, 12, 13, 14
var radius = 2
for x in range(-radius, radius + 1):
for y in range(-radius, radius + 1):
@@ -302,7 +274,7 @@ func spawn_powerups_around(center: Vector2i, force_powerups: bool = true):
# Random chance
if rng.randf() > 0.4: continue
var item_id = rng.randi_range(7, 10) # 7-10 are the special tiles
var item_id = rng.randi_range(11, 14) # 11-14 are the new powerups
var cell = Vector3i(pos.x, 1, pos.y)
if player.is_multiplayer_authority():
@@ -350,6 +322,29 @@ func _check_for_icy_floor():
pass
func _process(delta):
# Update Cooldowns
for effect in powerup_cooldowns.keys():
if powerup_cooldowns[effect] > 0:
powerup_cooldowns[effect] -= delta
# Emit signal occasionally or only on change? Every frame might be too much for UI?
# Optimization: Emit every 0.1s or if diff is significant?
# For snappy UI text, frame sync is okay for local player.
emit_signal("cooldown_updated", effect, powerup_cooldowns[effect], 0.0) # max unused for tick
if powerup_cooldowns[effect] <= 0:
powerup_cooldowns[effect] = 0
emit_signal("cooldown_updated", effect, 0, 0)
print("Cooldown finished for %s" % SpecialEffect.keys()[effect])
# Update Active Buffs (Speed)
if active_buffs.has(SpecialEffect.FASTER_SPEED):
active_buffs[SpecialEffect.FASTER_SPEED] -= delta
if active_buffs[SpecialEffect.FASTER_SPEED] <= 0:
active_buffs.erase(SpecialEffect.FASTER_SPEED)
if player.movement_manager:
player.movement_manager.set_speed_multiplier(1.0) # Reset
NotificationManager.send_message(player, "Speed Boost Ended", NotificationManager.MessageType.NORMAL)
_update_blocked_tiles(delta)
_update_invisible_timer(delta)
_update_freeze_zones(delta)
@@ -399,15 +394,6 @@ func _update_invisible_timer(delta: float):
# Helper Functions
# =============================================================================
func _get_random_opponent() -> Node3D:
var all_players = player.get_tree().get_nodes_in_group("Players")
var opponents = all_players.filter(func(p): return p != player)
if opponents.is_empty():
return null
return opponents[rng.randi() % opponents.size()]
func _get_empty_neighbors_recursive(center: Vector2i, radius: int) -> Array[Vector2i]:
var result: Array[Vector2i] = []
for x in range(-radius, radius + 1):