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:
79
scripts/build_desktop.sh
Executable file
79
scripts/build_desktop.sh
Executable 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"
|
||||
@@ -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
46
scripts/prepare_site_release.sh
Executable 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"
|
||||
48
scripts/test_network_npc_animations.gd
Normal file
48
scripts/test_network_npc_animations.gd
Normal 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()
|
||||
1
scripts/test_network_npc_animations.gd.uid
Normal file
1
scripts/test_network_npc_animations.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://cadhx6wgvrk3y
|
||||
49
scripts/test_npc_dialogue_ui.gd
Normal file
49
scripts/test_npc_dialogue_ui.gd
Normal 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()
|
||||
1
scripts/test_npc_dialogue_ui.gd.uid
Normal file
1
scripts/test_npc_dialogue_ui.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://bi7lnt88l1qoo
|
||||
105
scripts/test_world_npc_navigation.gd
Normal file
105
scripts/test_world_npc_navigation.gd
Normal 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
|
||||
1
scripts/test_world_npc_navigation.gd.uid
Normal file
1
scripts/test_world_npc_navigation.gd.uid
Normal file
@@ -0,0 +1 @@
|
||||
uid://ccc351itj8ilh
|
||||
Reference in New Issue
Block a user