init
This commit is contained in:
BIN
Scripts/.DS_Store
vendored
Normal file
BIN
Scripts/.DS_Store
vendored
Normal file
Binary file not shown.
169
Scripts/AutoLoad/NetworkManager.gd
Normal file
169
Scripts/AutoLoad/NetworkManager.gd
Normal file
@@ -0,0 +1,169 @@
|
||||
extends Node
|
||||
|
||||
signal connected
|
||||
signal disconnected
|
||||
signal login_ok(my_id, players)
|
||||
signal player_joined(id, name, x, y, skin_id)
|
||||
signal player_left(id)
|
||||
signal player_moved(id, x, y, flip_h, frame)
|
||||
signal chat_message_received(sender_id, text)
|
||||
|
||||
signal private_chat_message_received(sender_id, text)
|
||||
signal notices_received(data)
|
||||
|
||||
const DEFAULT_SERVER_URL = "ws://127.0.0.1:8910"
|
||||
|
||||
var socket := WebSocketPeer.new()
|
||||
var my_id: int = -1
|
||||
var player_info := {"name": "Player", "skin_id": 0}
|
||||
var players := {} # id -> {name, x, y, skin_id}
|
||||
var _connected := false
|
||||
var next_spawn_position: Vector2 = Vector2.ZERO
|
||||
|
||||
func _ready():
|
||||
process_mode = Node.PROCESS_MODE_ALWAYS
|
||||
|
||||
func _process(_delta):
|
||||
socket.poll()
|
||||
var state = socket.get_ready_state()
|
||||
|
||||
if state == WebSocketPeer.STATE_OPEN:
|
||||
if not _connected:
|
||||
_connected = true
|
||||
_on_connected()
|
||||
|
||||
while socket.get_available_packet_count():
|
||||
var packet = socket.get_packet()
|
||||
_on_message(packet.get_string_from_utf8())
|
||||
|
||||
elif state == WebSocketPeer.STATE_CLOSED:
|
||||
if _connected:
|
||||
_connected = false
|
||||
_on_disconnected()
|
||||
|
||||
func connect_to_server(url: String = ""):
|
||||
if url.is_empty():
|
||||
url = DEFAULT_SERVER_URL
|
||||
var err = socket.connect_to_url(url)
|
||||
if err != OK:
|
||||
print("Failed to connect: ", err)
|
||||
return err
|
||||
|
||||
func _on_connected():
|
||||
print("Connected to server!")
|
||||
# Send login
|
||||
send_json({
|
||||
"type": "login",
|
||||
"name": player_info["name"],
|
||||
"skin_id": player_info["skin_id"]
|
||||
})
|
||||
connected.emit()
|
||||
|
||||
func _on_disconnected():
|
||||
print("Disconnected from server")
|
||||
my_id = -1
|
||||
players.clear()
|
||||
disconnected.emit()
|
||||
|
||||
func _on_message(data: String):
|
||||
var json = JSON.new()
|
||||
var err = json.parse(data)
|
||||
if err != OK:
|
||||
print("JSON parse error: ", data)
|
||||
return
|
||||
|
||||
var msg = json.data
|
||||
# if msg.get("type") == "player_move" and int(msg.get("id", -1)) != my_id:
|
||||
# print("RAW NET: ", data)
|
||||
var msg_type = msg.get("type", "")
|
||||
|
||||
match msg_type:
|
||||
"login_ok":
|
||||
my_id = int(msg["id"])
|
||||
for p in msg["players"]:
|
||||
players[int(p["id"])] = {
|
||||
"name": p["name"],
|
||||
"x": p["x"],
|
||||
"y": p["y"],
|
||||
"skin_id": p["skin_id"]
|
||||
}
|
||||
login_ok.emit(my_id, players)
|
||||
|
||||
"player_join":
|
||||
var id = int(msg["id"])
|
||||
players[id] = {
|
||||
"name": msg["name"],
|
||||
"x": msg["x"],
|
||||
"y": msg["y"],
|
||||
"skin_id": msg["skin_id"]
|
||||
}
|
||||
player_joined.emit(id, msg["name"], msg["x"], msg["y"], msg["skin_id"])
|
||||
|
||||
"player_leave":
|
||||
var id = int(msg["id"])
|
||||
players.erase(id)
|
||||
player_left.emit(id)
|
||||
|
||||
"player_move":
|
||||
var id = int(msg["id"])
|
||||
if id != my_id: # Only log others to reduce spam, or log all? Let's log all for now if issue is critical
|
||||
print("Net: Recv Move for ID: ", id, " Frame: ", msg.get("frame", 1))
|
||||
if players.has(id):
|
||||
players[id]["x"] = msg["x"]
|
||||
players[id]["y"] = msg["y"]
|
||||
player_moved.emit(id, msg["x"], msg["y"], msg.get("flip_h", false), int(msg.get("frame", 1)))
|
||||
|
||||
"chat":
|
||||
var id = int(msg["id"])
|
||||
chat_message_received.emit(id, msg["text"])
|
||||
|
||||
"private_chat":
|
||||
var id = int(msg["sender_id"])
|
||||
private_chat_message_received.emit(id, msg["text"])
|
||||
|
||||
"notices_list":
|
||||
notices_received.emit(msg.get("data", []))
|
||||
|
||||
|
||||
"scene_peers":
|
||||
# Refresh player list for new scene
|
||||
players.clear()
|
||||
for p in msg["players"]:
|
||||
players[int(p["id"])] = {
|
||||
"name": p["name"],
|
||||
"x": p["x"],
|
||||
"y": p["y"],
|
||||
"skin_id": p["skin_id"]
|
||||
}
|
||||
# Emit login_ok to trigger refresh (or we could add a scene_loaded signal)
|
||||
login_ok.emit(my_id, players)
|
||||
|
||||
func send_json(obj: Dictionary):
|
||||
if socket.get_ready_state() == WebSocketPeer.STATE_OPEN:
|
||||
socket.send_text(JSON.stringify(obj))
|
||||
|
||||
func send_move(x: float, y: float, flip_h: bool, frame: int):
|
||||
send_json({"type": "move", "x": x, "y": y, "flip_h": flip_h, "frame": frame})
|
||||
|
||||
func send_change_scene(scene_name: String, pos: Vector2 = Vector2(-20000, -20000)):
|
||||
var data = {"type": "change_scene", "scene": scene_name}
|
||||
if pos.x != -20000:
|
||||
data["x"] = pos.x
|
||||
data["y"] = pos.y
|
||||
send_json(data)
|
||||
|
||||
func send_message(text: String):
|
||||
send_json({"type": "chat", "text": text})
|
||||
|
||||
func send_private_message(target_id: int, text: String):
|
||||
send_json({"type": "private_chat", "target_id": target_id, "text": text})
|
||||
|
||||
func request_notices():
|
||||
send_json({"type": "get_notices"})
|
||||
|
||||
|
||||
func set_player_name(n: String):
|
||||
player_info["name"] = n
|
||||
|
||||
func set_skin_id(id: int):
|
||||
player_info["skin_id"] = id
|
||||
1
Scripts/AutoLoad/NetworkManager.gd.uid
Normal file
1
Scripts/AutoLoad/NetworkManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bxf7twlnfjyfo
|
||||
13
Scripts/AutoLoad/SkinManager.gd
Normal file
13
Scripts/AutoLoad/SkinManager.gd
Normal file
@@ -0,0 +1,13 @@
|
||||
extends Node
|
||||
|
||||
# Placeholder skins
|
||||
var skins: Dictionary = {
|
||||
0: "res://icon.svg", # Fallback
|
||||
}
|
||||
|
||||
func get_skin_texture(id: int) -> Texture2D:
|
||||
if skins.has(id):
|
||||
# In a real scenario, this would preload or load form cache
|
||||
# For now, using load() is fine for prototype
|
||||
return load(skins[id])
|
||||
return load(skins[0])
|
||||
1
Scripts/AutoLoad/SkinManager.gd.uid
Normal file
1
Scripts/AutoLoad/SkinManager.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://d0oxbcdjtte7y
|
||||
48
Scripts/Chair.gd
Normal file
48
Scripts/Chair.gd
Normal file
@@ -0,0 +1,48 @@
|
||||
extends StaticBody2D
|
||||
|
||||
signal on_player_entered_range
|
||||
signal on_player_exited_range
|
||||
|
||||
@onready var sit_marker = $SitMarker
|
||||
@onready var interaction_area = $InteractionArea
|
||||
|
||||
var is_occupied: bool = false
|
||||
var current_player: Node2D = null
|
||||
var player_in_range: Node2D = null
|
||||
|
||||
func _ready():
|
||||
interaction_area.body_entered.connect(_on_body_entered)
|
||||
interaction_area.body_exited.connect(_on_body_exited)
|
||||
|
||||
func _input(event):
|
||||
if is_occupied:
|
||||
return
|
||||
|
||||
if player_in_range and event is InputEventKey and event.pressed and event.keycode == KEY_E:
|
||||
_interact(player_in_range)
|
||||
|
||||
func _interact(player: Node2D):
|
||||
if is_occupied:
|
||||
return
|
||||
|
||||
is_occupied = true
|
||||
current_player = player
|
||||
|
||||
# Assume player has this method (we will implement it next)
|
||||
if player.has_method("start_sitting"):
|
||||
player.start_sitting(sit_marker.global_position, self)
|
||||
|
||||
# Called by player when they stand up
|
||||
func on_player_stand_up():
|
||||
is_occupied = false
|
||||
current_player = null
|
||||
|
||||
func _on_body_entered(body):
|
||||
if body.has_method("start_sitting"):
|
||||
player_in_range = body
|
||||
emit_signal("on_player_entered_range")
|
||||
|
||||
func _on_body_exited(body):
|
||||
if body == player_in_range:
|
||||
player_in_range = null
|
||||
emit_signal("on_player_exited_range")
|
||||
1
Scripts/Chair.gd.uid
Normal file
1
Scripts/Chair.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://hlnghptb6myk
|
||||
30
Scripts/NPCMayor.gd
Normal file
30
Scripts/NPCMayor.gd
Normal file
@@ -0,0 +1,30 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
signal interaction_happened(text)
|
||||
|
||||
@export var npc_name: String = "Mayor"
|
||||
@export var dialogue: String = "欢迎来到WhaleTown,我是镇长范鲸鱼"
|
||||
|
||||
func _ready():
|
||||
$Sprite2D.texture = preload("res://Assets/MayorWhale.png")
|
||||
# Sprite Sheet setup for idle animation (if needed later)
|
||||
# For now, frame 0 is fine
|
||||
$Sprite2D.hframes = 3
|
||||
$Sprite2D.vframes = 4
|
||||
|
||||
# Start Idle Animation
|
||||
if has_node("AnimationPlayer"):
|
||||
$AnimationPlayer.play("idle")
|
||||
|
||||
# Ensure interaction layer
|
||||
collision_layer = 3 # Layer 1 & 2 (Blocking)
|
||||
collision_mask = 3
|
||||
|
||||
func interact():
|
||||
show_bubble(dialogue)
|
||||
return null
|
||||
|
||||
func show_bubble(text):
|
||||
var bubble = preload("res://Scenes/UI/ChatBubble.tscn").instantiate()
|
||||
add_child(bubble)
|
||||
bubble.set_text(text)
|
||||
1
Scripts/NPCMayor.gd.uid
Normal file
1
Scripts/NPCMayor.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://5tvkhbf0237e
|
||||
13
Scripts/NoticeBoard.gd
Normal file
13
Scripts/NoticeBoard.gd
Normal file
@@ -0,0 +1,13 @@
|
||||
extends StaticBody2D
|
||||
|
||||
func interact():
|
||||
print("Interacted with Notice Board")
|
||||
|
||||
# Check if dialog already exists
|
||||
if get_tree().root.has_node("NoticeDialog"):
|
||||
return
|
||||
|
||||
var dialog = preload("res://Scenes/UI/NoticeDialog.tscn").instantiate()
|
||||
dialog.name = "NoticeDialog"
|
||||
get_tree().root.add_child(dialog)
|
||||
return null # No bubble text needed
|
||||
1
Scripts/NoticeBoard.gd.uid
Normal file
1
Scripts/NoticeBoard.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://do577s3lo1p6f
|
||||
222
Scripts/Player.gd
Normal file
222
Scripts/Player.gd
Normal file
@@ -0,0 +1,222 @@
|
||||
extends CharacterBody2D
|
||||
|
||||
@export var grid_size := 16
|
||||
|
||||
var player_id: int = -1
|
||||
var is_local := false
|
||||
var is_moving := false
|
||||
var server_position := Vector2.ZERO
|
||||
|
||||
signal interaction_request(target_id, target_name)
|
||||
|
||||
@onready var ray = $RayCast2D
|
||||
|
||||
func _ready():
|
||||
server_position = position
|
||||
|
||||
# Initial sprite setup
|
||||
$Sprite2D.scale = Vector2(0.5, 0.5)
|
||||
$Sprite2D.frame = 1
|
||||
|
||||
# Physics Collision (Movement)
|
||||
collision_layer = 2 # Force Player to be on Layer 2
|
||||
collision_mask = 1 # Only collide with World (Layer 1), walk through other Players (Layer 2)
|
||||
|
||||
# RayCast Setup
|
||||
ray.enabled = true
|
||||
ray.hit_from_inside = true # Detect if overlapping
|
||||
ray.collision_mask = 3 # Layers 1 (World) and 2 (Player)
|
||||
ray.add_exception(self) # Ignore local player
|
||||
|
||||
if is_local:
|
||||
var cam = Camera2D.new()
|
||||
cam.zoom = Vector2(1.0, 1.0)
|
||||
cam.position_smoothing_enabled = true
|
||||
add_child(cam)
|
||||
|
||||
NetworkManager.chat_message_received.connect(_on_chat_message)
|
||||
|
||||
# Set Name Label
|
||||
var name_label = $NameLabel
|
||||
if is_local:
|
||||
name_label.text = NetworkManager.player_info["name"]
|
||||
name_label.modulate = Color(0.5, 1, 0.5) # Highlight local player
|
||||
elif player_id != -1 and NetworkManager.players.has(player_id):
|
||||
name_label.text = NetworkManager.players[player_id].name
|
||||
|
||||
func _physics_process(_delta):
|
||||
if is_local:
|
||||
_process_input()
|
||||
else:
|
||||
_process_remote_movement()
|
||||
|
||||
# Interaction Check (F Key) moved to _input to prevent spam
|
||||
|
||||
var is_sitting := false
|
||||
var current_chair: Node2D = null
|
||||
|
||||
func _process_input():
|
||||
# Stop input if chatting
|
||||
var focus_owner = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner is LineEdit:
|
||||
return
|
||||
|
||||
if is_moving or is_sitting:
|
||||
return
|
||||
|
||||
var input_dir := Vector2.ZERO
|
||||
if Input.is_action_pressed("ui_up") or Input.is_key_pressed(KEY_W):
|
||||
input_dir = Vector2.UP
|
||||
$Sprite2D.frame = 4 # Row 1, Center (Back view)
|
||||
$Sprite2D.flip_h = false
|
||||
ray.target_position = Vector2(0, -96)
|
||||
elif Input.is_action_pressed("ui_down") or Input.is_key_pressed(KEY_S):
|
||||
input_dir = Vector2.DOWN
|
||||
$Sprite2D.frame = 1 # Row 0, Center (Front view)
|
||||
$Sprite2D.flip_h = false
|
||||
ray.target_position = Vector2(0, 96)
|
||||
elif Input.is_action_pressed("ui_left") or Input.is_key_pressed(KEY_A):
|
||||
input_dir = Vector2.LEFT
|
||||
$Sprite2D.frame = 3 # Row 1, Left (Side view)
|
||||
$Sprite2D.flip_h = false
|
||||
ray.target_position = Vector2(-96, 0)
|
||||
elif Input.is_action_pressed("ui_right") or Input.is_key_pressed(KEY_D):
|
||||
input_dir = Vector2.RIGHT
|
||||
$Sprite2D.frame = 3 # Use same side view
|
||||
$Sprite2D.flip_h = true # Flip it
|
||||
ray.target_position = Vector2(96, 0)
|
||||
|
||||
if input_dir != Vector2.ZERO:
|
||||
_attempt_move(input_dir)
|
||||
|
||||
func _input(event):
|
||||
if not is_local:
|
||||
return
|
||||
|
||||
if event is InputEventKey and event.pressed and not event.echo and event.keycode == KEY_F:
|
||||
# Check if ignoring input (chatting)
|
||||
var focus_owner = get_viewport().gui_get_focus_owner()
|
||||
if focus_owner is LineEdit:
|
||||
return
|
||||
|
||||
if is_sitting:
|
||||
stand_up()
|
||||
return
|
||||
|
||||
ray.force_raycast_update()
|
||||
|
||||
if ray.is_colliding():
|
||||
var collider = ray.get_collider()
|
||||
# Check if collider is a player (ID-based name)
|
||||
if collider:
|
||||
if collider.has_method("interact"):
|
||||
var text = collider.interact()
|
||||
if text:
|
||||
show_bubble(text) # Show response locally for now
|
||||
elif collider.name.is_valid_int():
|
||||
var target_id = int(str(collider.name))
|
||||
var p_info = NetworkManager.players.get(target_id)
|
||||
var t_name = p_info["name"] if p_info else "Unknown"
|
||||
interaction_request.emit(target_id, t_name)
|
||||
|
||||
func start_sitting(target_pos: Vector2, chair: Node2D):
|
||||
if is_sitting:
|
||||
return
|
||||
|
||||
is_sitting = true
|
||||
is_moving = false # Force stop moving
|
||||
current_chair = chair
|
||||
|
||||
# Disable physics/collision
|
||||
set_physics_process(false)
|
||||
$CollisionShape2D.set_deferred("disabled", true)
|
||||
|
||||
# Tween to seat
|
||||
var tween = create_tween()
|
||||
tween.tween_property(self, "position", target_pos, 0.3).set_trans(Tween.TRANS_SINE).set_ease(Tween.EASE_OUT)
|
||||
|
||||
# Visuals
|
||||
$Sprite2D.frame = 1 # Sit/Idle Front
|
||||
$Sprite2D.flip_h = false
|
||||
# You might want to offset Z-index if chair has backrest vs stool
|
||||
# z_index = 0 # Default
|
||||
|
||||
func stand_up():
|
||||
if not is_sitting:
|
||||
return
|
||||
|
||||
is_sitting = false
|
||||
|
||||
# Notify chair
|
||||
if current_chair and current_chair.has_method("on_player_stand_up"):
|
||||
current_chair.on_player_stand_up()
|
||||
current_chair = null
|
||||
|
||||
# Restore physics
|
||||
set_physics_process(true)
|
||||
$CollisionShape2D.set_deferred("disabled", false)
|
||||
|
||||
# Optional: Move slightly forward to avoid clipping immediately if collision enables
|
||||
var exit_pos = position + Vector2(0, 10)
|
||||
position = exit_pos
|
||||
NetworkManager.send_move(position.x, position.y, $Sprite2D.flip_h, $Sprite2D.frame)
|
||||
|
||||
|
||||
|
||||
|
||||
func _attempt_move(dir: Vector2):
|
||||
var target_pos = position + dir * grid_size
|
||||
|
||||
# Save state
|
||||
var old_target = ray.target_position
|
||||
var old_mask = ray.collision_mask
|
||||
var old_hfi = ray.hit_from_inside
|
||||
var old_cwa = ray.collide_with_areas
|
||||
|
||||
# Configure for movement check (World Only, Short Range, Bodies Only)
|
||||
ray.target_position = dir * grid_size
|
||||
ray.collision_mask = 1
|
||||
ray.hit_from_inside = false
|
||||
ray.collide_with_areas = false # Ignore Areas (like Doors), only hit Walls (Bodies)
|
||||
|
||||
ray.force_raycast_update()
|
||||
var blocked = ray.is_colliding()
|
||||
|
||||
# Restore state for Chat Interaction
|
||||
ray.target_position = old_target
|
||||
ray.collision_mask = old_mask
|
||||
ray.hit_from_inside = old_hfi
|
||||
ray.collide_with_areas = old_cwa
|
||||
|
||||
if not blocked:
|
||||
_tween_movement(target_pos)
|
||||
# Send to server WITH orientation
|
||||
NetworkManager.send_move(target_pos.x, target_pos.y, $Sprite2D.flip_h, $Sprite2D.frame)
|
||||
|
||||
func _process_remote_movement():
|
||||
if position != server_position and not is_moving:
|
||||
_tween_movement(server_position)
|
||||
|
||||
func set_remote_state(pos: Vector2, flip_h: bool, frame: int):
|
||||
if is_local:
|
||||
return # Do not let remote updates override local input
|
||||
server_position = pos
|
||||
$Sprite2D.flip_h = flip_h
|
||||
$Sprite2D.frame = frame
|
||||
|
||||
func _tween_movement(target_pos: Vector2):
|
||||
is_moving = true
|
||||
var tween = create_tween()
|
||||
tween.tween_property(self, "position", target_pos, 0.2)
|
||||
tween.tween_callback(func(): is_moving = false)
|
||||
|
||||
func _on_chat_message(sender_id, text):
|
||||
if sender_id == player_id:
|
||||
show_bubble(text)
|
||||
|
||||
func show_bubble(text):
|
||||
$NameLabel.hide()
|
||||
var bubble = preload("res://Scenes/UI/ChatBubble.tscn").instantiate()
|
||||
add_child(bubble)
|
||||
bubble.set_text(text)
|
||||
bubble.tree_exited.connect(func(): $NameLabel.show())
|
||||
1
Scripts/Player.gd.uid
Normal file
1
Scripts/Player.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c2oq8ax5dl7s2
|
||||
91
Scripts/Room.gd
Normal file
91
Scripts/Room.gd
Normal file
@@ -0,0 +1,91 @@
|
||||
extends Node2D
|
||||
|
||||
@onready var tile_map = $TileMapLayer
|
||||
@onready var players_container = $PlayersContainer
|
||||
|
||||
var player_nodes := {} # id -> Node
|
||||
|
||||
func _ready():
|
||||
# Visual setup
|
||||
tile_map.scale = Vector2(0.5, 0.5)
|
||||
|
||||
NetworkManager.login_ok.connect(_on_login_ok)
|
||||
NetworkManager.player_joined.connect(_on_player_joined)
|
||||
NetworkManager.player_left.connect(_on_player_left)
|
||||
NetworkManager.player_moved.connect(_on_player_moved)
|
||||
NetworkManager.disconnected.connect(_on_disconnected) # Handle disconnect
|
||||
NetworkManager.chat_message_received.connect(_on_chat_message_received) # Reuse chat logic if desired
|
||||
|
||||
# Spawn myself manually since scene_peers excludes self
|
||||
_spawn_player(NetworkManager.my_id, NetworkManager.player_info["name"], 0, 0, NetworkManager.player_info["skin_id"])
|
||||
|
||||
# Spawn existing players (including self if already in list, but we usually spawn self manually or via list)
|
||||
# Since we just transitioned, NetworkManager.players contains the new list from 'scene_peers' message
|
||||
for id in NetworkManager.players:
|
||||
var p = NetworkManager.players[id]
|
||||
_spawn_player(id, p["name"], p["x"], p["y"], p.get("skin_id", 0))
|
||||
|
||||
# Chat UI
|
||||
var chat = preload("res://Scenes/UI/ChatHUD.tscn").instantiate()
|
||||
add_child(chat)
|
||||
|
||||
func _spawn_player(id, _p_name, x, y, _skin_id):
|
||||
if player_nodes.has(id): return # Prevent duplicate
|
||||
|
||||
var p = preload("res://Scenes/Player.tscn").instantiate()
|
||||
p.name = str(id)
|
||||
|
||||
# For local player in Room, we might want a fixed spawn point (0,0) or the one from server
|
||||
# If it's local player, we usually force it to (0,0) on entry
|
||||
if id == NetworkManager.my_id:
|
||||
p.position = Vector2(0, 0)
|
||||
else:
|
||||
p.position = Vector2(x, y)
|
||||
|
||||
p.player_id = id
|
||||
p.is_local = (id == NetworkManager.my_id)
|
||||
|
||||
players_container.add_child(p)
|
||||
player_nodes[id] = p
|
||||
|
||||
if p.is_local:
|
||||
var camera = Camera2D.new()
|
||||
camera.make_current()
|
||||
p.add_child(camera)
|
||||
|
||||
func _on_login_ok(_my_id, _players):
|
||||
# Refresh players if we get a full update
|
||||
for id in player_nodes:
|
||||
if id != NetworkManager.my_id:
|
||||
player_nodes[id].queue_free()
|
||||
player_nodes.clear()
|
||||
|
||||
for id in NetworkManager.players:
|
||||
var p = NetworkManager.players[id]
|
||||
_spawn_player(id, p["name"], p["x"], p["y"], p.get("skin_id", 0))
|
||||
|
||||
func _on_player_joined(id, p_name, x, y, skin_id):
|
||||
_spawn_player(id, p_name, x, y, skin_id)
|
||||
|
||||
func _on_player_left(id):
|
||||
if player_nodes.has(id):
|
||||
player_nodes[id].queue_free()
|
||||
player_nodes.erase(id)
|
||||
|
||||
func _on_player_moved(id, x, y, flip_h, frame):
|
||||
if player_nodes.has(id):
|
||||
player_nodes[id].set_remote_state(Vector2(x, y), flip_h, frame)
|
||||
|
||||
func _on_chat_message_received(sender_id, text):
|
||||
# Chat HUD handles it via signals usually, but if we need bubbles:
|
||||
if player_nodes.has(sender_id):
|
||||
player_nodes[sender_id].show_bubble(text)
|
||||
|
||||
func _on_disconnected():
|
||||
get_tree().change_scene_to_file("res://Scenes/StartMenu.tscn")
|
||||
|
||||
func _on_exit_body_entered(body):
|
||||
if body.name == str(NetworkManager.my_id):
|
||||
# Return to the world
|
||||
NetworkManager.send_change_scene("world", NetworkManager.next_spawn_position)
|
||||
get_tree().change_scene_to_file("res://Scenes/World.tscn")
|
||||
1
Scripts/Room.gd.uid
Normal file
1
Scripts/Room.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dbq68ofn2ldxm
|
||||
31
Scripts/StartMenu.gd
Normal file
31
Scripts/StartMenu.gd
Normal file
@@ -0,0 +1,31 @@
|
||||
extends CanvasLayer
|
||||
|
||||
@onready var name_input = $CenterContainer/Card/Content/Form/NameInput
|
||||
|
||||
const SERVER_URL = "ws://127.0.0.1:8910"
|
||||
|
||||
func _ready():
|
||||
name_input.grab_focus()
|
||||
|
||||
func _unhandled_input(event):
|
||||
if event.is_action_pressed("ui_accept"):
|
||||
_on_join_pressed()
|
||||
|
||||
func _on_join_pressed():
|
||||
if name_input.text.strip_edges().is_empty():
|
||||
return
|
||||
|
||||
NetworkManager.set_player_name(name_input.text)
|
||||
|
||||
# Connect with hardcoded URL
|
||||
var err = NetworkManager.connect_to_server(SERVER_URL)
|
||||
if err == OK:
|
||||
# Disable button to prevent double click?
|
||||
# Wait for login_ok before changing scene
|
||||
if not NetworkManager.login_ok.is_connected(_on_login_ok):
|
||||
NetworkManager.login_ok.connect(_on_login_ok, CONNECT_ONE_SHOT)
|
||||
else:
|
||||
print("Connection failed: ", err)
|
||||
|
||||
func _on_login_ok(_my_id, _players):
|
||||
get_tree().change_scene_to_file("res://Scenes/World.tscn")
|
||||
1
Scripts/StartMenu.gd.uid
Normal file
1
Scripts/StartMenu.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://c78blp14uphut
|
||||
9
Scripts/UI/ChatBubble.gd
Normal file
9
Scripts/UI/ChatBubble.gd
Normal file
@@ -0,0 +1,9 @@
|
||||
extends Control
|
||||
|
||||
@onready var label = $PanelContainer/Label
|
||||
|
||||
func set_text(text):
|
||||
label.text = text
|
||||
# Destroy after 5 seconds
|
||||
await get_tree().create_timer(5.0).timeout
|
||||
queue_free()
|
||||
1
Scripts/UI/ChatBubble.gd.uid
Normal file
1
Scripts/UI/ChatBubble.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bpddnrpvlcf7l
|
||||
71
Scripts/UI/ChatHUD.gd
Normal file
71
Scripts/UI/ChatHUD.gd
Normal file
@@ -0,0 +1,71 @@
|
||||
extends CanvasLayer
|
||||
|
||||
@onready var chat_log = $PanelContainer/VBoxContainer/RichTextLabel
|
||||
@onready var input_field = $PanelContainer/VBoxContainer/LineEdit
|
||||
|
||||
var private_target_id: int = -1
|
||||
var private_target_name: String = ""
|
||||
|
||||
func _ready():
|
||||
NetworkManager.chat_message_received.connect(_on_chat_message_received)
|
||||
NetworkManager.private_chat_message_received.connect(_on_private_message_received)
|
||||
input_field.text_submitted.connect(_on_text_submitted)
|
||||
|
||||
func _input(event):
|
||||
if event.is_action_pressed("ui_cancel"): # ESC
|
||||
set_private_target(-1, "")
|
||||
input_field.release_focus()
|
||||
elif event.is_action_pressed("ui_accept"): # Enter
|
||||
if not input_field.has_focus():
|
||||
input_field.grab_focus()
|
||||
get_viewport().set_input_as_handled()
|
||||
|
||||
func set_private_target(id: int, p_name: String):
|
||||
private_target_id = id
|
||||
private_target_name = p_name
|
||||
|
||||
if id != -1:
|
||||
input_field.placeholder_text = "[Private] To " + p_name + "..."
|
||||
input_field.add_theme_color_override("font_color", Color(1, 0.5, 1)) # Pink for private
|
||||
# input_field.grab_focus() # Don't grab focus automatically, let user move
|
||||
else:
|
||||
input_field.placeholder_text = "Press Enter to chat..."
|
||||
input_field.remove_theme_color_override("font_color")
|
||||
|
||||
func _on_text_submitted(new_text):
|
||||
if new_text.strip_edges().is_empty():
|
||||
input_field.release_focus()
|
||||
return
|
||||
|
||||
if private_target_id != -1:
|
||||
NetworkManager.send_private_message(private_target_id, new_text)
|
||||
# Show my own private message in log
|
||||
chat_log.append_text("[color=#FF88FF][Private -> %s]: %s[/color]\n" % [private_target_name, new_text])
|
||||
else:
|
||||
NetworkManager.send_message(new_text)
|
||||
|
||||
input_field.clear()
|
||||
input_field.release_focus()
|
||||
|
||||
func _on_chat_message_received(sender_id, text):
|
||||
var sender_name = str(sender_id)
|
||||
|
||||
print("Chat Debug: Msg from ", sender_id, " (Type: ", typeof(sender_id), ")")
|
||||
print("Known Players: ", NetworkManager.players.keys())
|
||||
|
||||
if NetworkManager.players.has(sender_id):
|
||||
sender_name = NetworkManager.players[sender_id].get("name", str(sender_id))
|
||||
# Debug empty name
|
||||
if sender_name == "":
|
||||
sender_name = "Player_" + str(sender_id)
|
||||
else:
|
||||
print("Chat Debug: Player ID not found in NetworkManager.players")
|
||||
|
||||
chat_log.append_text("[color=yellow]%s[/color]: %s\n" % [sender_name, text])
|
||||
|
||||
func _on_private_message_received(sender_id, text):
|
||||
var sender_name = str(sender_id)
|
||||
if NetworkManager.players.has(sender_id):
|
||||
sender_name = NetworkManager.players[sender_id].get("name", str(sender_id))
|
||||
|
||||
chat_log.append_text("[color=#FF88FF][Private From %s]: %s[/color]\n" % [sender_name, text])
|
||||
1
Scripts/UI/ChatHUD.gd.uid
Normal file
1
Scripts/UI/ChatHUD.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dia2n8udb7xxq
|
||||
144
Scripts/UI/NoticeDialog.gd
Normal file
144
Scripts/UI/NoticeDialog.gd
Normal file
@@ -0,0 +1,144 @@
|
||||
extends CanvasLayer
|
||||
|
||||
@onready var content_label = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/TextPanel/ContentLabel
|
||||
@onready var prev_btn = $CenterContainer/PanelContainer/VBoxContainer/Footer/PrevButton
|
||||
@onready var next_btn = $CenterContainer/PanelContainer/VBoxContainer/Footer/NextButton
|
||||
@onready var dots_container = $CenterContainer/PanelContainer/VBoxContainer/Footer/DotsContainer
|
||||
@onready var content_container = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer
|
||||
|
||||
# Mock Data
|
||||
var pages = [
|
||||
{
|
||||
"text": "欢迎来到 [color=#3399ff]Datawhale Town[/color]!\n\n这里是开源学习者的家园。在这里,我们一同探索知识,分享成长。\n\n[center]🐋[/center]",
|
||||
"image_color": Color(0.9, 0.9, 0.9) # Mock image placeholder
|
||||
},
|
||||
{
|
||||
"text": "最新活动:\n\n- 镇长刚刚搬进来了,就在喷泉左边。\n- 欢迎板已经设立,查看最新动态。\n- 玩家名字现在显示在头顶了!",
|
||||
"image_color": Color(0.8, 0.9, 0.8)
|
||||
},
|
||||
{
|
||||
"text": "操作提示:\n\n- 按 [color=#ffaa00]F[/color] 键可以与物体互动。\n- 在下方输入框输入文字并在气泡中显示。\n- 点击右下角按钮发送聊天。",
|
||||
"image_color": Color(0.9, 0.8, 0.8)
|
||||
}
|
||||
]
|
||||
|
||||
var current_page = 0
|
||||
var tween: Tween
|
||||
|
||||
func _ready():
|
||||
# Pause the game
|
||||
get_tree().paused = true
|
||||
|
||||
$CenterContainer/PanelContainer/VBoxContainer/Header/RightContainer/CloseButton.pressed.connect(_on_close_pressed)
|
||||
prev_btn.pressed.connect(_on_prev_pressed)
|
||||
next_btn.pressed.connect(_on_next_pressed)
|
||||
|
||||
# Network Integration
|
||||
NetworkManager.notices_received.connect(_on_notices_received)
|
||||
NetworkManager.request_notices()
|
||||
|
||||
# Initial Setup (with generic "Loading" state)
|
||||
pages = [{"text": "[center]Loading notices...[/center]", "image_color": Color(0.9, 0.9, 0.9)}]
|
||||
_setup_dots()
|
||||
_update_ui(false)
|
||||
|
||||
func _on_notices_received(data: Array):
|
||||
if data.is_empty():
|
||||
pages = [{"text": "[center]No notices available.[/center]", "image_color": Color(0.9, 0.9, 0.9)}]
|
||||
else:
|
||||
pages = data
|
||||
# Handle color strings from JSON if necessary
|
||||
for p in pages:
|
||||
if p.has("image_color") and p["image_color"] is String:
|
||||
p["image_color"] = Color(p["image_color"])
|
||||
|
||||
current_page = 0
|
||||
_setup_dots()
|
||||
_update_ui(true)
|
||||
|
||||
|
||||
func _setup_dots():
|
||||
for child in dots_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
for i in range(pages.size()):
|
||||
var dot = ColorRect.new()
|
||||
dot.custom_minimum_size = Vector2(10, 10) # Base size
|
||||
dots_container.add_child(dot)
|
||||
|
||||
func _update_ui(animate: bool = true):
|
||||
if pages.is_empty():
|
||||
return
|
||||
|
||||
# Update Buttons
|
||||
prev_btn.disabled = (current_page == 0)
|
||||
next_btn.disabled = (current_page == pages.size() - 1)
|
||||
|
||||
# Update Dots Logic
|
||||
var dots = dots_container.get_children()
|
||||
for i in range(dots.size()):
|
||||
if i == current_page:
|
||||
dots[i].color = Color(0.2, 0.2, 0.2, 1) # Dark Active
|
||||
dots[i].custom_minimum_size = Vector2(12, 12) # Active Slightly Larger
|
||||
else:
|
||||
dots[i].color = Color(0.8, 0.8, 0.8, 1) # Light Inactive
|
||||
dots[i].custom_minimum_size = Vector2(10, 10)
|
||||
|
||||
# Update Content
|
||||
if animate:
|
||||
_animate_content_change()
|
||||
else:
|
||||
_set_content_immediate()
|
||||
|
||||
@onready var image_rect = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageRect
|
||||
@onready var image_label = $CenterContainer/PanelContainer/VBoxContainer/ContentContainer/ImagePanel/ImageLabel
|
||||
|
||||
func _set_content_immediate():
|
||||
var page = pages[current_page]
|
||||
content_label.text = page.get("text", "")
|
||||
|
||||
if page.has("image_path") and page["image_path"] != "":
|
||||
var path = page["image_path"]
|
||||
if ResourceLoader.exists(path):
|
||||
image_rect.texture = load(path)
|
||||
image_label.visible = false
|
||||
else:
|
||||
image_rect.texture = null
|
||||
image_label.visible = true
|
||||
image_label.text = "Image Not Found"
|
||||
else:
|
||||
image_rect.texture = null
|
||||
image_label.visible = true
|
||||
image_label.text = "No Image"
|
||||
|
||||
|
||||
|
||||
func _animate_content_change():
|
||||
if tween and tween.is_valid():
|
||||
tween.kill()
|
||||
|
||||
tween = create_tween()
|
||||
|
||||
# Fade Out
|
||||
tween.tween_property(content_container, "modulate:a", 0.0, 0.15)
|
||||
|
||||
# Callback to change text
|
||||
tween.tween_callback(self._set_content_immediate)
|
||||
|
||||
# Fade In
|
||||
tween.tween_property(content_container, "modulate:a", 1.0, 0.15)
|
||||
|
||||
func _on_prev_pressed():
|
||||
if current_page > 0:
|
||||
current_page -= 1
|
||||
_update_ui()
|
||||
|
||||
func _on_next_pressed():
|
||||
if current_page < pages.size() - 1:
|
||||
current_page += 1
|
||||
_update_ui()
|
||||
|
||||
func _on_close_pressed():
|
||||
# Unpause the game
|
||||
get_tree().paused = false
|
||||
queue_free()
|
||||
1
Scripts/UI/NoticeDialog.gd.uid
Normal file
1
Scripts/UI/NoticeDialog.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cy3n8ccmeolgd
|
||||
20
Scripts/UI/WelcomeDialog.gd
Normal file
20
Scripts/UI/WelcomeDialog.gd
Normal file
@@ -0,0 +1,20 @@
|
||||
extends CanvasLayer
|
||||
|
||||
func _ready():
|
||||
# Connect close button (X)
|
||||
var header_close = find_child("CloseButton", true, false)
|
||||
if header_close:
|
||||
header_close.pressed.connect(_on_close_pressed)
|
||||
|
||||
# Connect Start button
|
||||
var start_btn = find_child("StartButton", true, false)
|
||||
if start_btn:
|
||||
start_btn.pressed.connect(_on_close_pressed)
|
||||
|
||||
func _on_close_pressed():
|
||||
queue_free()
|
||||
|
||||
func _input(event):
|
||||
# Allow ESC to close
|
||||
if event.is_action_pressed("ui_cancel"):
|
||||
queue_free()
|
||||
1
Scripts/UI/WelcomeDialog.gd.uid
Normal file
1
Scripts/UI/WelcomeDialog.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://djjy58mh6kl4o
|
||||
13
Scripts/WelcomeBoard.gd
Normal file
13
Scripts/WelcomeBoard.gd
Normal file
@@ -0,0 +1,13 @@
|
||||
extends StaticBody2D
|
||||
|
||||
func interact():
|
||||
# Prevent multiple dialogs
|
||||
if get_tree().root.has_node("WelcomeDialog"):
|
||||
return null
|
||||
|
||||
# Spawn the Welcome Dialog
|
||||
var dialog = preload("res://Scenes/UI/WelcomeDialog.tscn").instantiate()
|
||||
dialog.name = "WelcomeDialog"
|
||||
# Add to the Scene Root (World) or CanvasLayer if it has one
|
||||
get_tree().root.add_child(dialog)
|
||||
return null # Return null prevents Player from showing a bubble
|
||||
1
Scripts/WelcomeBoard.gd.uid
Normal file
1
Scripts/WelcomeBoard.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cfiinv7n2spwd
|
||||
159
Scripts/World.gd
Normal file
159
Scripts/World.gd
Normal file
@@ -0,0 +1,159 @@
|
||||
extends Node2D
|
||||
|
||||
@onready var players_container = $PlayersContainer
|
||||
@onready var tile_map = $Ground
|
||||
|
||||
var player_nodes := {} # id -> Node
|
||||
|
||||
func _ready():
|
||||
$Ground.scale = Vector2(1, 1)
|
||||
if has_node("Water"):
|
||||
$Water.scale = Vector2(1, 1)
|
||||
|
||||
# Clean setup: trust the scene file
|
||||
var entrance = get_node_or_null("NeighborhoodEntrance")
|
||||
if entrance:
|
||||
entrance.monitorable = true
|
||||
entrance.monitoring = true
|
||||
# Ensure it detects Player (Layer 2) OR Layer 1 just in case
|
||||
entrance.collision_mask = 3 # Layers 1 and 2
|
||||
|
||||
if not entrance.body_entered.is_connected(_on_neighborhood_entrance_body_entered):
|
||||
entrance.body_entered.connect(_on_neighborhood_entrance_body_entered)
|
||||
if not entrance.body_exited.is_connected(_on_neighborhood_entrance_body_exited):
|
||||
entrance.body_exited.connect(_on_neighborhood_entrance_body_exited)
|
||||
|
||||
NetworkManager.login_ok.connect(_on_login_ok)
|
||||
NetworkManager.player_joined.connect(_on_player_joined)
|
||||
NetworkManager.player_left.connect(_on_player_left)
|
||||
NetworkManager.player_moved.connect(_on_player_moved)
|
||||
NetworkManager.disconnected.connect(_on_disconnected)
|
||||
|
||||
# Explicitly spawn local player if logged in
|
||||
if NetworkManager.my_id != -1:
|
||||
_spawn_player(NetworkManager.my_id, NetworkManager.player_info["name"], 0, 0, NetworkManager.player_info["skin_id"])
|
||||
|
||||
for id in NetworkManager.players:
|
||||
var p = NetworkManager.players[id]
|
||||
_spawn_player(id, p["name"], p["x"], p["y"], p.get("skin_id", 0))
|
||||
|
||||
var chat = preload("res://Scenes/UI/ChatHUD.tscn").instantiate()
|
||||
add_child(chat)
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
var player_in_entrance := false
|
||||
|
||||
func _on_neighborhood_entrance_body_entered(body):
|
||||
print("Body ENTERED Trigger: ", body.name, " (My ID: ", NetworkManager.my_id, ")")
|
||||
# Check if it's the local player
|
||||
if body.name == str(NetworkManager.my_id):
|
||||
player_in_entrance = true
|
||||
print("Player ENTERED entrance area")
|
||||
|
||||
func _on_neighborhood_entrance_body_exited(body):
|
||||
if body.name == str(NetworkManager.my_id):
|
||||
player_in_entrance = false
|
||||
print("Player EXITED entrance area")
|
||||
|
||||
func _physics_process(_delta):
|
||||
if player_in_entrance:
|
||||
# Check for Up input while inside the entrance
|
||||
if Input.is_action_pressed("ui_up") or Input.is_key_pressed(KEY_W):
|
||||
_do_room_transition()
|
||||
|
||||
func _do_room_transition():
|
||||
var entrance = get_node_or_null("NeighborhoodEntrance")
|
||||
if not entrance:
|
||||
return
|
||||
|
||||
var local_player = player_nodes.get(NetworkManager.my_id)
|
||||
if not local_player:
|
||||
return
|
||||
|
||||
player_in_entrance = false # Prevent re-trigger
|
||||
NetworkManager.next_spawn_position = local_player.position + Vector2(0, 32)
|
||||
NetworkManager.send_change_scene("room_" + str(NetworkManager.my_id), Vector2(0, 0))
|
||||
get_tree().change_scene_to_file("res://Scenes/Room.tscn")
|
||||
|
||||
|
||||
func _fill_background():
|
||||
# Fill base stone floor (standard blue brick at 0,0)
|
||||
# Procedural generation for background
|
||||
# Procedural generation for background
|
||||
for x in range(-50, 150):
|
||||
for y in range(-50, 100):
|
||||
var tile_pos = Vector2i(x, y)
|
||||
|
||||
# Plaza in the center (0 to 15)
|
||||
if x >= 0 and x < 15 and y >= 0 and y < 15:
|
||||
# Use light gray stone coordinates confirmed in TownTileset.tres
|
||||
var rand_var = randi() % 3
|
||||
tile_map.set_cell(tile_pos, 5, Vector2i(4 + rand_var, 0)) # (4,0), (5,0), (6,0)
|
||||
else:
|
||||
# Regular floor (blue-ish / darker)
|
||||
if (x + y) % 2 == 0:
|
||||
tile_map.set_cell(tile_pos, 5, Vector2i(1, 0))
|
||||
else:
|
||||
tile_map.set_cell(tile_pos, 5, Vector2i(2, 0))
|
||||
|
||||
func _on_login_ok(_my_id, _players):
|
||||
pass
|
||||
|
||||
func _on_player_joined(id, p_name, x, y, skin_id):
|
||||
_spawn_player(id, p_name, x, y, skin_id)
|
||||
|
||||
func _on_player_left(id):
|
||||
if player_nodes.has(id):
|
||||
player_nodes[id].queue_free()
|
||||
player_nodes.erase(id)
|
||||
|
||||
func _on_player_moved(id, x, y, flip_h, frame):
|
||||
if player_nodes.has(id):
|
||||
player_nodes[id].set_remote_state(Vector2(x, y), flip_h, frame)
|
||||
|
||||
func _on_disconnected():
|
||||
get_tree().change_scene_to_file("res://Scenes/StartMenu.tscn")
|
||||
|
||||
func _spawn_player(id, _p_name, x, y, _skin_id):
|
||||
# Anti-Ghosting: Check if this player already exists in the tree
|
||||
if players_container.has_node(str(id)):
|
||||
var old_node = players_container.get_node(str(id))
|
||||
old_node.name = "Ghost_" + str(id) # Rename to avoid duplicate name error during queue_free
|
||||
old_node.queue_free()
|
||||
|
||||
# Also clean dictionary
|
||||
if player_nodes.has(id):
|
||||
player_nodes.erase(id)
|
||||
|
||||
var p = preload("res://Scenes/Player.tscn").instantiate()
|
||||
p.name = str(id)
|
||||
|
||||
# Override position if returning from another scene
|
||||
var spawn_pos = Vector2(x, y)
|
||||
if id == NetworkManager.my_id and NetworkManager.next_spawn_position != Vector2.ZERO:
|
||||
spawn_pos = NetworkManager.next_spawn_position
|
||||
# Reset after use
|
||||
NetworkManager.next_spawn_position = Vector2.ZERO
|
||||
|
||||
p.position = spawn_pos
|
||||
p.player_id = id
|
||||
p.is_local = (id == NetworkManager.my_id)
|
||||
print("Spawning Player ID: ", id, " Name: ", _p_name, " Is Local: ", p.is_local, " (My ID: ", NetworkManager.my_id, ")")
|
||||
|
||||
if p.is_local:
|
||||
p.interaction_request.connect(_on_interaction_request)
|
||||
|
||||
players_container.add_child(p)
|
||||
player_nodes[id] = p
|
||||
|
||||
func _on_interaction_request(target_id, target_name):
|
||||
print("World received interaction request for: ", target_name)
|
||||
var chat = get_node_or_null("ChatHUD")
|
||||
if chat:
|
||||
if chat.has_method("set_private_target"):
|
||||
chat.set_private_target(target_id, target_name)
|
||||
else:
|
||||
print("ChatHUD missing set_private_target method")
|
||||
1
Scripts/World.gd.uid
Normal file
1
Scripts/World.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://dpgj6ge7vk76f
|
||||
Reference in New Issue
Block a user