feat: expand multiplayer, chat, and release support

- add network NPC synchronization and dialogue interactions
- add world bulletin publishing and display
- improve authentication, session refresh, and appearance sync
- synchronize player direction and movement animations
- improve input focus and progressive Web loading
- add macOS/Windows builds and deployment configuration
- include required fonts, shaders, and runtime assets
This commit is contained in:
2026-09-08 21:37:43 +08:00
parent 175621f66c
commit fc6c3c1bd3
87 changed files with 4546 additions and 506 deletions

79
scripts/build_desktop.sh Executable file
View File

@@ -0,0 +1,79 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
GODOT_BIN="${GODOT_BIN:-/Applications/Godot.app/Contents/MacOS/Godot}"
TARGET="${1:-all}"
VERSION="${WHALETOWN_VERSION:-1.0.0}"
if [[ ! -x "$GODOT_BIN" ]]; then
echo "Godot executable not found: $GODOT_BIN" >&2
echo "Set GODOT_BIN to the Godot 4.6.2 executable path." >&2
exit 1
fi
export_target() {
local preset="$1"
local output="$2"
local marker
marker="$(mktemp /tmp/whaletown-export.XXXXXX)"
mkdir -p "$(dirname "$output")"
echo "Exporting $preset -> $output"
"$GODOT_BIN" --headless --path "$PROJECT_DIR" --export-release "$preset" "$output"
if [[ ! -e "$output" ]] || [[ -z "$(find "$output" -newer "$marker" -print -quit 2>/dev/null)" ]]; then
echo "Export failed: Godot did not create a fresh artifact at $output" >&2
exit 1
fi
}
package_artifacts() {
local release_dir="$PROJECT_DIR/build/desktop/release"
local mac_app="$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
local windows_exe="$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
local mac_zip="$release_dir/WhaleTown-macOS-$VERSION.zip"
local windows_zip="$release_dir/WhaleTown-Windows-x86_64-$VERSION.zip"
if [[ ! -d "$mac_app" ]] || [[ ! -f "$windows_exe" ]]; then
echo "Both macOS and Windows artifacts are required before packaging." >&2
exit 1
fi
mkdir -p "$release_dir"
echo "Packaging $mac_zip"
/usr/bin/ditto -c -k --sequesterRsrc --keepParent "$mac_app" "$mac_zip"
echo "Packaging $windows_zip"
/usr/bin/ditto -c -k --keepParent "$windows_exe" "$windows_zip"
(
cd "$release_dir"
shasum -a 256 "$(basename "$mac_zip")" "$(basename "$windows_zip")" > SHA256SUMS.txt
)
echo "Release packages are under: $release_dir"
}
case "$TARGET" in
macos)
export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
;;
windows)
export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
;;
all)
export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
;;
package)
package_artifacts
;;
release)
export_target "WhaleTown macOS" "$PROJECT_DIR/build/desktop/macos/WhaleTown.app"
export_target "WhaleTown Windows" "$PROJECT_DIR/build/desktop/windows/WhaleTown.exe"
package_artifacts
;;
*)
echo "Usage: $0 [macos|windows|all|package|release]" >&2
exit 2
;;
esac
echo "Desktop export complete. Files are under: $PROJECT_DIR/build/desktop"

View File

@@ -41,6 +41,8 @@ work_zone=$(build_pack "work_zone" "Scene Work Zone")
cafe=$(build_pack "cafe_interior" "Scene Cafe")
personal_space=$(build_pack "personal_space" "Scene Personal Space")
auth=$(build_pack "auth" "Scene Auth")
auth_pack_size=$(printf '%s' "$auth" | jq -r '.size')
perl -0pi -e "s/__WHALETOWN_AUTH_PACK_SIZE__/$auth_pack_size/g" "$WEB_DIR/index.html"
jq -n \
--argjson square "$square" \

46
scripts/prepare_site_release.sh Executable file
View File

@@ -0,0 +1,46 @@
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)"
SITE_DIR="$PROJECT_DIR/build/site"
WEB_DIR="$PROJECT_DIR/build/web"
HOMEPAGE_DIR="$(cd "$PROJECT_DIR/../homepage" && pwd)"
RELEASE_DIR="$PROJECT_DIR/build/desktop/release"
required_files=(
"$WEB_DIR/index.html"
"$WEB_DIR/index.js"
"$WEB_DIR/index.wasm"
"$RELEASE_DIR/WhaleTown-macOS-1.0.0.zip"
"$RELEASE_DIR/WhaleTown-Windows-x86_64-1.0.0.zip"
)
for file in "${required_files[@]}"; do
if [[ ! -f "$file" ]]; then
echo "Missing required release artifact: $file" >&2
echo "Build the Web and desktop releases before preparing the site." >&2
exit 1
fi
done
rm -rf "$SITE_DIR"
mkdir -p "$SITE_DIR/play" "$SITE_DIR/downloads"
cp -R "$HOMEPAGE_DIR/." "$SITE_DIR/"
if [[ -d "$SITE_DIR/.git" ]]; then
find "$SITE_DIR/.git" -depth -delete
fi
find "$SITE_DIR" -name '*.import' -delete
find "$SITE_DIR" -name '.DS_Store' -delete
cp -R "$WEB_DIR/." "$SITE_DIR/play/"
find "$SITE_DIR/play" -name '*.import' -delete
find "$SITE_DIR/play" -name '.DS_Store' -delete
cp "$RELEASE_DIR/WhaleTown-macOS-1.0.0.zip" "$SITE_DIR/downloads/"
cp "$RELEASE_DIR/WhaleTown-Windows-x86_64-1.0.0.zip" "$SITE_DIR/downloads/"
cp "$RELEASE_DIR/SHA256SUMS.txt" "$SITE_DIR/downloads/"
echo "Site release prepared in: $SITE_DIR"
echo " / Landing page"
echo " /play/ Web client"
echo " /downloads Desktop client ZIP files"

View File

@@ -0,0 +1,48 @@
extends SceneTree
const NETWORK_NPC_SCENE: PackedScene = preload("res://scenes/characters/network_npc.tscn")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var npc := NETWORK_NPC_SCENE.instantiate()
root.add_child(npc)
npc.call("apply_snapshot", {
"npcId": "npc_niulai",
"name": "牛来",
"scene": "niulai_ambassador",
"version": 1,
"x": 0,
"y": 0,
"movementState": "idle",
})
var animationPlayer := npc.get_node("AnimationPlayer") as AnimationPlayer
assert(npc.collision_layer == 2, "NPC must occupy the dedicated NPC collision layer")
assert(npc.collision_mask == 1, "NPC collision mask must include static map collision")
var npcShape := (npc.get_node("CollisionShape2D") as CollisionShape2D).shape as RectangleShape2D
assert(npcShape.size == Vector2(52, 24), "Niulai must use the adjusted physical footprint")
var nameplate := npc.get_node("Nameplate") as Label
assert(nameplate.get_theme_font_size("font_size") == 16, "NPC names must use the player name font size")
assert(nameplate.get_theme_constant("outline_size") == 3, "NPC names must use the player name outline")
assert(nameplate.get_theme_stylebox("normal") is StyleBoxEmpty, "NPC names must not use the old pill background")
npc.call("_configure_visual", "town_mayor")
assert(is_equal_approx(npc.nameplateOffsetY, -52.0), "The mayor name must follow the visible propeller instead of transparent frame padding")
npc.call("_configure_visual", "niulai_ambassador")
var library := animationPlayer.get_animation_library("")
var expectedRows := {"down": 0, "up": 1, "right": 2, "left": 3}
for direction in expectedRows:
var animationName := "walk_%s" % direction
assert(library.has_animation(animationName), "missing %s" % animationName)
var animation := library.get_animation(animationName)
assert(animation.loop_mode == Animation.LOOP_LINEAR, "%s must loop" % animationName)
assert(animation.track_get_key_count(0) == 4, "%s must contain four frames" % animationName)
for column in range(4):
assert(
animation.track_get_key_value(0, column) == expectedRows[direction] * 4 + column,
"%s uses the wrong spritesheet row" % animationName,
)
npc.call("interact")
assert(root.get_node_or_null("WorldChatBubbleLayer") == null, "NPC interaction must not create a world bubble layer")
print("NETWORK_NPC_ANIMATIONS_OK")
quit()

View File

@@ -0,0 +1 @@
uid://cadhx6wgvrk3y

View File

@@ -0,0 +1,49 @@
extends SceneTree
const CHAT_UI_SCENE: PackedScene = preload("res://scenes/ui/ChatUI.tscn")
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var chatUi := CHAT_UI_SCENE.instantiate()
root.add_child(chatUi)
await process_frame
chatUi.call("start_npc_whisper", "npc_niulai", "牛来", "欢迎来到鲸鱼小镇!")
await process_frame
var panel := chatUi.get_node("ChatPanel") as PanelContainer
var worldTab := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/Tabs/PopularTab") as Control
var npcTabButton := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/Tabs/RecentTab/RecentTabButton") as Button
var friendsTab := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/Tabs/FriendsTab") as Control
var inputRow := chatUi.get_node("ChatPanel/PanelMargin/ContentVBox/InputRow") as Control
assert(panel.visible, "NPC interaction must open the dialogue panel")
assert(is_equal_approx(panel.anchor_left, 0.5) and is_equal_approx(panel.anchor_right, 0.5), "NPC dialogue must be horizontally centered")
assert((panel.get_theme_stylebox("panel") as StyleBoxFlat).border_width_left == 4, "NPC dialogue must use the framed dialogue style")
assert(not worldTab.visible and not friendsTab.visible, "Channel tabs must be hidden during an NPC dialogue")
assert(npcTabButton.text == "牛来", "The dialogue header must show the NPC name")
assert(inputRow.visible, "AI NPC dialogue must retain the message input")
var dialogueMessage := chatUi.find_child("ChatMessage", true, false) as Control
assert(dialogueMessage != null, "NPC greeting must render in the dialogue")
assert(not dialogueMessage.get_node("MessageRow/LeftAvatarPanel").visible, "NPC dialogue messages must not render chat bubbles or avatars")
root.size = Vector2i(640, 360)
await process_frame
var mobileRect := panel.get_rect()
var mobileCanvasSize: Vector2 = chatUi.size
assert(mobileRect.position.x >= 0.0 and mobileRect.end.x <= mobileCanvasSize.x, "NPC dialogue must fit the mobile canvas horizontally")
assert(mobileRect.position.y >= 0.0 and mobileRect.end.y <= mobileCanvasSize.y, "NPC dialogue must fit the mobile canvas vertically")
chatUi.call("hide_chat", true)
await process_frame
assert(is_equal_approx(panel.anchor_left, 0.024), "Closing an NPC dialogue must restore the normal chat layout")
assert(worldTab.visible and friendsTab.visible, "Closing an NPC dialogue must restore channel tabs")
chatUi.call("show_npc_dialogue", "虾小满", "码头今天风平浪静。")
await process_frame
assert(panel.visible, "Static NPC interaction must use the same dialogue panel")
assert(not inputRow.visible, "Static NPC dialogue must not show an unusable input")
assert(npcTabButton.text == "虾小满", "Static NPC dialogue must show its speaker")
assert(root.get_node_or_null("WorldChatBubbleLayer") == null, "NPC dialogue must not create a world bubble layer")
print("NPC_DIALOGUE_UI_OK")
quit()

View File

@@ -0,0 +1 @@
uid://bi7lnt88l1qoo

View File

@@ -0,0 +1,105 @@
extends SceneTree
const MAP_SCENES := {
"whale_port": "res://scenes/Maps/square.tscn",
"work_zone": "res://scenes/Maps/work_zone.tscn",
"whale_cafe": "res://scenes/Maps/cafe_interior.tscn",
}
const MAX_NPC_FOOTPRINT := Vector2(60, 28)
const ROUTE_SAMPLE_DISTANCE := 8.0
var _failures: Array[String] = []
func _initialize() -> void:
call_deferred("_run")
func _run() -> void:
var arguments := OS.get_cmdline_user_args()
assert(arguments.size() == 1, "pass the exported world NPC graph JSON path after --")
var graph := JSON.parse_string(FileAccess.get_file_as_string(arguments[0])) as Dictionary
assert(not graph.is_empty(), "world NPC graph JSON is invalid")
var locationsById := {}
for locationValue in graph.get("locations", []):
var location := locationValue as Dictionary
locationsById[str(location.get("id", ""))] = location
for mapId in MAP_SCENES:
await _validate_map(str(mapId), str(MAP_SCENES[mapId]), graph, locationsById)
if not _failures.is_empty():
for failure in _failures:
push_error(failure)
quit(1)
return
print("WORLD_NPC_NAVIGATION_OK")
quit()
func _validate_map(mapId: String, scenePath: String, graph: Dictionary, locationsById: Dictionary) -> void:
var mapScene := (load(scenePath) as PackedScene).instantiate() as Node2D
for uiName in ["ChatUI", "WorldBulletinPanel"]:
var uiNode := mapScene.find_child(uiName, true, false)
if uiNode != null:
uiNode.free()
root.add_child(mapScene)
await process_frame
for locationValue in graph.get("locations", []):
var location := locationValue as Dictionary
if str(location.get("mapId", "")) != mapId:
continue
for slotIndex in range((location.get("slots", []) as Array).size()):
var slot := (location.get("slots", []) as Array)[slotIndex] as Dictionary
_validate_clear_point(
mapScene, Vector2(float(slot.get("x", 0)), float(slot.get("y", 0))),
"%s slot %d" % [location.get("id", ""), slotIndex],
)
for edgeValue in graph.get("edges", []):
var edge := edgeValue as Dictionary
if str(edge.get("kind", "")) != "walk":
continue
var from := locationsById.get(str(edge.get("from", "")), {}) as Dictionary
var to := locationsById.get(str(edge.get("to", "")), {}) as Dictionary
if str(from.get("mapId", "")) != mapId or str(to.get("mapId", "")) != mapId:
continue
var fromCenter := Vector2(float(from.get("x", 0)), float(from.get("y", 0)))
var toCenter := Vector2(float(to.get("x", 0)), float(to.get("y", 0)))
var fromPoints: Array[Vector2] = [fromCenter]
var toPoints: Array[Vector2] = [toCenter]
for slotValue in from.get("slots", []):
var slot := slotValue as Dictionary
fromPoints.append(Vector2(float(slot.get("x", 0)), float(slot.get("y", 0))))
for slotValue in to.get("slots", []):
var slot := slotValue as Dictionary
toPoints.append(Vector2(float(slot.get("x", 0)), float(slot.get("y", 0))))
for fromPoint in fromPoints:
_validate_clear_segment(mapScene, fromPoint, toCenter, "%s -> %s" % [from.get("id", ""), to.get("id", "")])
for toPoint in toPoints:
_validate_clear_segment(mapScene, fromCenter, toPoint, "%s -> %s" % [from.get("id", ""), to.get("id", "")])
mapScene.queue_free()
await process_frame
func _validate_clear_segment(mapScene: Node2D, from: Vector2, to: Vector2, label: String) -> void:
var samples := maxi(1, int(ceil(from.distance_to(to) / ROUTE_SAMPLE_DISTANCE)))
for sampleIndex in range(samples + 1):
var point := from.lerp(to, float(sampleIndex) / float(samples))
if not _static_colliders_at(mapScene, point).is_empty():
_failures.append("route intersects static collision: %s at %s" % [label, point])
return
func _validate_clear_point(mapScene: Node2D, point: Vector2, label: String) -> void:
var colliders := _static_colliders_at(mapScene, point)
if not colliders.is_empty():
_failures.append("slot intersects static collision: %s at %s (%s)" % [label, point, ", ".join(colliders)])
func _static_colliders_at(mapScene: Node2D, point: Vector2) -> Array[String]:
var shape := RectangleShape2D.new()
shape.size = MAX_NPC_FOOTPRINT
var query := PhysicsShapeQueryParameters2D.new()
query.shape = shape
query.transform = Transform2D(0.0, point)
query.collision_mask = 1
query.collide_with_areas = false
query.collide_with_bodies = true
var colliders: Array[String] = []
for hit in mapScene.get_world_2d().direct_space_state.intersect_shape(query, 64):
var collider := hit.get("collider") as CollisionObject2D
if collider is StaticBody2D and not colliders.has(str(collider.get_path())):
colliders.append(str(collider.get_path()))
return colliders

View File

@@ -0,0 +1 @@
uid://ccc351itj8ilh