Initial scaffold: Nakama backend, Godot client, content schema
Sets up the Step 1 (Project Foundation) and Step 2 (Content Schemas) scaffold from the implementation sequence. Includes Docker Compose stack, Nakama TypeScript module stubs, PostgreSQL migration, Godot 4 project shell with scene stubs and NakamaClient singleton, YAML content schemas for all entity types, and example data for 3 characters, 2 jobs, and all 7 core resources. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
commit
fb00954954
37 changed files with 2730 additions and 0 deletions
BIN
godot/assets/placeholders/icon.png
Normal file
BIN
godot/assets/placeholders/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 179 B |
45
godot/project.godot
Normal file
45
godot/project.godot
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
; Engine configuration file.
|
||||
; It's best edited using the editor UI and not directly,
|
||||
; but this file is committed so the project can be opened without further setup.
|
||||
|
||||
config_version=5
|
||||
|
||||
[application]
|
||||
|
||||
config/name="Xyvera"
|
||||
config/description="Cozy management gacha with meaningful guild strategy."
|
||||
config/version="0.0.1-poc"
|
||||
run/main_scene="res://src/screens/MainMenu.tscn"
|
||||
config/features=PackedStringArray("4.3", "Mobile")
|
||||
config/icon="res://assets/placeholders/icon.png"
|
||||
|
||||
[display]
|
||||
|
||||
window/size/viewport_width=1080
|
||||
window/size/viewport_height=1920
|
||||
window/size/resizable=false
|
||||
window/stretch/mode="canvas_items"
|
||||
window/stretch/aspect="expand"
|
||||
|
||||
[rendering]
|
||||
|
||||
renderer/rendering_method="mobile"
|
||||
|
||||
[autoload]
|
||||
|
||||
NakamaClient="*res://src/nakama/NakamaClient.gd"
|
||||
GameState="*res://src/systems/GameState.gd"
|
||||
|
||||
[input]
|
||||
|
||||
ui_accept={
|
||||
"deadzone": 0.5,
|
||||
"events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":4194309,"physical_keycode":0,"key_label":0,"unicode":0,"echo":false,"script":null)
|
||||
, Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":-1,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":32,"physical_keycode":0,"key_label":0,"unicode":32,"echo":false,"script":null)
|
||||
, Object(InputEventJoypadButton,"resource_local_to_scene":false,"resource_name":"","device":-1,"button_index":0,"pressure":0.0,"pressed":false,"script":null)
|
||||
]
|
||||
}
|
||||
|
||||
[filesystem]
|
||||
|
||||
import/blender/enabled=false
|
||||
113
godot/src/nakama/NakamaClient.gd
Normal file
113
godot/src/nakama/NakamaClient.gd
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
## NakamaClient.gd — GDScript wrapper around the GDNakama plugin
|
||||
##
|
||||
## Registered as a global AutoLoad singleton (NakamaClient).
|
||||
## All server calls go through here so the rest of the codebase never needs
|
||||
## to know the Nakama session details or raw API.
|
||||
##
|
||||
## Setup required:
|
||||
## Install the GDNakama plugin: https://github.com/heroiclabs/nakama-godot
|
||||
## Enable it in Project > Project Settings > Plugins.
|
||||
##
|
||||
## Default connection target: localhost:7349 (matches docker-compose.yml)
|
||||
## Override with the env var XYVERA_NAKAMA_HOST for other environments.
|
||||
|
||||
extends Node
|
||||
|
||||
# ── Configuration ─────────────────────────────────────────────────────────────
|
||||
|
||||
const SERVER_HOST := "localhost"
|
||||
const SERVER_PORT := 7349
|
||||
const SERVER_KEY := "xyvera-dev-key"
|
||||
const SERVER_SCHEME := "http"
|
||||
|
||||
# ── State ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
var _client: NakamaClient # GDNakama plugin instance (set in _ready)
|
||||
var _session: NakamaSession # authenticated session
|
||||
var _socket: NakamaSocket # real-time socket (used for future live features)
|
||||
|
||||
signal session_changed(session: NakamaSession)
|
||||
signal connected()
|
||||
signal disconnected()
|
||||
|
||||
# ── Lifecycle ─────────────────────────────────────────────────────────────────
|
||||
|
||||
func _ready() -> void:
|
||||
# GDNakama plugin creates a NakamaClient via the singleton factory.
|
||||
# If the plugin is not installed this line will error at runtime.
|
||||
_client = Nakama.create_client(SERVER_KEY, SERVER_HOST, SERVER_PORT, SERVER_SCHEME)
|
||||
print("[NakamaClient] Configured: %s://%s:%d" % [SERVER_SCHEME, SERVER_HOST, SERVER_PORT])
|
||||
|
||||
# ── Auth ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
## Authenticate with a device ID (no account required in PoC).
|
||||
## On first call, Nakama creates the account and triggers the seed hook.
|
||||
func authenticate_device(device_id: String) -> NakamaSession:
|
||||
var result: NakamaSession = await _client.authenticate_device_async(device_id)
|
||||
if result.is_exception():
|
||||
push_error("[NakamaClient] Auth failed: %s" % result.get_exception().message)
|
||||
return null
|
||||
_session = result
|
||||
session_changed.emit(_session)
|
||||
print("[NakamaClient] Authenticated: %s" % _session.user_id)
|
||||
return _session
|
||||
|
||||
func is_authenticated() -> bool:
|
||||
return _session != null and not _session.is_expired()
|
||||
|
||||
# ── Generic RPC ───────────────────────────────────────────────────────────────
|
||||
|
||||
## Call a registered Nakama RPC and return the parsed JSON result.
|
||||
## Returns null on error (check Godot Output for details).
|
||||
func rpc(id: String, payload: Dictionary = {}) -> Variant:
|
||||
if not is_authenticated():
|
||||
push_error("[NakamaClient] RPC called without active session: %s" % id)
|
||||
return null
|
||||
|
||||
var payload_str := JSON.stringify(payload) if not payload.is_empty() else ""
|
||||
var result: NakamaAPI.ApiRpc = await _client.rpc_async(_session, id, payload_str)
|
||||
|
||||
if result.is_exception():
|
||||
push_error("[NakamaClient] RPC error (%s): %s" % [id, result.get_exception().message])
|
||||
return null
|
||||
|
||||
if result.payload.is_empty():
|
||||
return {}
|
||||
|
||||
var json := JSON.new()
|
||||
var err := json.parse(result.payload)
|
||||
if err != OK:
|
||||
push_error("[NakamaClient] Failed to parse RPC response for %s" % id)
|
||||
return null
|
||||
|
||||
return json.data
|
||||
|
||||
# ── Convenience helpers ───────────────────────────────────────────────────────
|
||||
|
||||
func get_resources() -> Dictionary:
|
||||
var result = await rpc("inventory/resources")
|
||||
return result.get("resources", {}) if result else {}
|
||||
|
||||
func get_village_state() -> Dictionary:
|
||||
var result = await rpc("village/state")
|
||||
return result if result else {}
|
||||
|
||||
func collect_passive_resources() -> Dictionary:
|
||||
var result = await rpc("village/collect")
|
||||
return result if result else {}
|
||||
|
||||
func manual_work(resource: String = "wood") -> Dictionary:
|
||||
var result = await rpc("village/manual_work", {"resource": resource})
|
||||
return result if result else {}
|
||||
|
||||
func get_banners() -> Array:
|
||||
var result = await rpc("gacha/banners")
|
||||
return result.get("banners", []) if result else []
|
||||
|
||||
func pull_gacha(banner_id: String, count: int = 1) -> Dictionary:
|
||||
var result = await rpc("gacha/pull", {"banner_id": banner_id, "count": count})
|
||||
return result if result else {}
|
||||
|
||||
func get_pity(banner_id: String) -> Dictionary:
|
||||
var result = await rpc("gacha/pity", {"banner_id": banner_id})
|
||||
return result if result else {}
|
||||
79
godot/src/screens/GachaPull.gd
Normal file
79
godot/src/screens/GachaPull.gd
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
## GachaPull.gd — Gacha pull / banner screen controller
|
||||
##
|
||||
## Shows available banners with rates, pity counter, and pull buttons.
|
||||
## Implements the fair-visibility design pillar:
|
||||
## - Pull rates displayed directly on the screen
|
||||
## - Pity counter shown prominently
|
||||
## - Banner end date and rerun policy visible
|
||||
##
|
||||
## Scene file: src/screens/GachaPull.tscn (create in Godot editor)
|
||||
|
||||
extends Control
|
||||
|
||||
var _current_banner_id: String = "standard"
|
||||
var _banners: Array = []
|
||||
|
||||
@onready var banner_name_label: Label = $Header/BannerNameLabel
|
||||
@onready var pity_label: Label = $Header/PityLabel
|
||||
@onready var rates_label: Label = $Header/RatesLabel
|
||||
@onready var rerun_label: Label = $Header/RerunLabel
|
||||
|
||||
@onready var pull_1_button: Button = $Body/Pull1Button
|
||||
@onready var pull_10_button: Button = $Body/Pull10Button
|
||||
@onready var result_container: VBoxContainer = $Body/ResultContainer
|
||||
|
||||
@onready var back_button: Button = $Footer/BackButton
|
||||
|
||||
func _ready() -> void:
|
||||
pull_1_button.pressed.connect(func(): _do_pull(1))
|
||||
pull_10_button.pressed.connect(func(): _do_pull(10))
|
||||
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://src/screens/Village.tscn"))
|
||||
|
||||
_load_banners()
|
||||
|
||||
func _load_banners() -> void:
|
||||
_banners = await NakamaClient.get_banners()
|
||||
if _banners.is_empty():
|
||||
banner_name_label.text = "Standard Banner (stub)"
|
||||
pity_label.text = "Pity: 0 / 90"
|
||||
rates_label.text = "SSR: 1.5% | SR: 8.5% | R: 90%\n(Soft pity from 70 pulls)"
|
||||
rerun_label.text = "Permanent banner — always available"
|
||||
return
|
||||
|
||||
var b: Dictionary = _banners[0]
|
||||
_current_banner_id = b.get("id", "standard")
|
||||
banner_name_label.text = b.get("name", "Banner")
|
||||
|
||||
var pity = await NakamaClient.get_pity(_current_banner_id)
|
||||
pity_label.text = "Pity: %d / %d (soft pity at %d)" % [
|
||||
pity.get("current_pity", 0),
|
||||
pity.get("hard_pity", 90),
|
||||
pity.get("soft_pity_start", 70),
|
||||
]
|
||||
|
||||
func _do_pull(count: int) -> void:
|
||||
pull_1_button.disabled = true
|
||||
pull_10_button.disabled = true
|
||||
|
||||
for child in result_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var result = await NakamaClient.pull_gacha(_current_banner_id, count)
|
||||
|
||||
if result and result.get("ok"):
|
||||
var results_list: Array = result.get("results", [])
|
||||
for r in results_list:
|
||||
var lbl := Label.new()
|
||||
lbl.text = "[%s] %s" % [r.get("rarity", "?"), r.get("character_id", "?")]
|
||||
result_container.add_child(lbl)
|
||||
# Refresh pity after pulling
|
||||
var pity = await NakamaClient.get_pity(_current_banner_id)
|
||||
pity_label.text = "Pity: %d / %d" % [pity.get("current_pity", 0), pity.get("hard_pity", 90)]
|
||||
GameState.update_resources(await NakamaClient.get_resources())
|
||||
else:
|
||||
var lbl := Label.new()
|
||||
lbl.text = result.get("message", "Pull system not yet implemented (PR 5)")
|
||||
result_container.add_child(lbl)
|
||||
|
||||
pull_1_button.disabled = false
|
||||
pull_10_button.disabled = false
|
||||
20
godot/src/screens/GachaPull.tscn
Normal file
20
godot/src/screens/GachaPull.tscn
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[gd_scene load_steps=2 format=3 uid=""]
|
||||
|
||||
[ext_resource type="Script" path="res://src/screens/GachaPull.gd" id="1_placeholder"]
|
||||
|
||||
[node name="GachaPull" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_placeholder")
|
||||
|
||||
[node name="Header" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
|
||||
[node name="Footer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
82
godot/src/screens/Inventory.gd
Normal file
82
godot/src/screens/Inventory.gd
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
## Inventory.gd — Character roster and item inventory screen controller
|
||||
##
|
||||
## Shows the player's character roster with filtering.
|
||||
## Design spec: every item must show what it is for, where it came from,
|
||||
## and where it can be spent. Inventory confusion is an economy bug.
|
||||
##
|
||||
## Scene file: src/screens/Inventory.tscn (create in Godot editor)
|
||||
|
||||
extends Control
|
||||
|
||||
var _roster: Array = []
|
||||
var _filter_rarity: String = ""
|
||||
var _filter_profession: String = ""
|
||||
|
||||
@onready var roster_container: VBoxContainer = $Body/RosterContainer
|
||||
@onready var filter_rarity_option: OptionButton = $Header/Filters/RarityFilter
|
||||
@onready var filter_profession_option: OptionButton = $Header/Filters/ProfessionFilter
|
||||
@onready var back_button: Button = $Footer/BackButton
|
||||
|
||||
func _ready() -> void:
|
||||
back_button.pressed.connect(func(): get_tree().change_scene_to_file("res://src/screens/Village.tscn"))
|
||||
|
||||
filter_rarity_option.item_selected.connect(_on_rarity_filter)
|
||||
filter_profession_option.item_selected.connect(_on_profession_filter)
|
||||
|
||||
_setup_filters()
|
||||
_load_roster()
|
||||
|
||||
func _setup_filters() -> void:
|
||||
filter_rarity_option.add_item("All Rarities")
|
||||
for r in ["R", "SR", "SSR", "UR"]:
|
||||
filter_rarity_option.add_item(r)
|
||||
|
||||
filter_profession_option.add_item("All Professions")
|
||||
for p in ["miner", "lumberjack", "researcher", "construction", "farmer", "trainer"]:
|
||||
filter_profession_option.add_item(p.capitalize())
|
||||
|
||||
func _load_roster() -> void:
|
||||
var result = await NakamaClient.rpc("inventory/get")
|
||||
if result and not result.get("error"):
|
||||
_roster = result.get("characters", [])
|
||||
else:
|
||||
_roster = []
|
||||
|
||||
_render_roster()
|
||||
|
||||
func _render_roster() -> void:
|
||||
for child in roster_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
var filtered := _roster.filter(func(c: Dictionary) -> bool:
|
||||
if _filter_rarity and c.get("rarity") != _filter_rarity:
|
||||
return false
|
||||
if _filter_profession and c.get("profession") != _filter_profession.to_lower():
|
||||
return false
|
||||
return true
|
||||
)
|
||||
|
||||
if filtered.is_empty():
|
||||
var lbl := Label.new()
|
||||
lbl.text = "No characters yet.\nPull from the banner to build your roster!"
|
||||
roster_container.add_child(lbl)
|
||||
return
|
||||
|
||||
for c in filtered:
|
||||
var lbl := Label.new()
|
||||
lbl.text = "[%s] %s Lv.%d | %s | %s" % [
|
||||
c.get("rarity", "?"),
|
||||
c.get("character_id", "?"),
|
||||
c.get("level", 1),
|
||||
c.get("combat_role", "?"),
|
||||
c.get("profession", "?"),
|
||||
]
|
||||
roster_container.add_child(lbl)
|
||||
|
||||
func _on_rarity_filter(idx: int) -> void:
|
||||
_filter_rarity = "" if idx == 0 else filter_rarity_option.get_item_text(idx)
|
||||
_render_roster()
|
||||
|
||||
func _on_profession_filter(idx: int) -> void:
|
||||
_filter_profession = "" if idx == 0 else filter_profession_option.get_item_text(idx)
|
||||
_render_roster()
|
||||
20
godot/src/screens/Inventory.tscn
Normal file
20
godot/src/screens/Inventory.tscn
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[gd_scene load_steps=2 format=3 uid=""]
|
||||
|
||||
[ext_resource type="Script" path="res://src/screens/Inventory.gd" id="1_placeholder"]
|
||||
|
||||
[node name="Inventory" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_placeholder")
|
||||
|
||||
[node name="Header" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
|
||||
[node name="Footer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
45
godot/src/screens/MainMenu.gd
Normal file
45
godot/src/screens/MainMenu.gd
Normal file
|
|
@ -0,0 +1,45 @@
|
|||
## MainMenu.gd — Main menu screen controller
|
||||
##
|
||||
## The entry point scene. Handles device authentication and routes the player
|
||||
## to the Village screen on success.
|
||||
## Scene file: src/screens/MainMenu.tscn (create in Godot editor)
|
||||
|
||||
extends Control
|
||||
|
||||
@onready var status_label: Label = $VBoxContainer/StatusLabel
|
||||
@onready var play_button: Button = $VBoxContainer/PlayButton
|
||||
|
||||
func _ready() -> void:
|
||||
play_button.pressed.connect(_on_play_pressed)
|
||||
status_label.text = "Connecting..."
|
||||
|
||||
# Attempt silent re-auth on launch using a persistent device ID
|
||||
var device_id := _get_or_create_device_id()
|
||||
var session = await NakamaClient.authenticate_device(device_id)
|
||||
|
||||
if session:
|
||||
status_label.text = "Welcome back, Traveller."
|
||||
play_button.disabled = false
|
||||
else:
|
||||
status_label.text = "Could not connect to server.\nMake sure Docker is running."
|
||||
play_button.disabled = true
|
||||
|
||||
func _on_play_pressed() -> void:
|
||||
await GameState.refresh_all()
|
||||
get_tree().change_scene_to_file("res://src/screens/Village.tscn")
|
||||
|
||||
func _get_or_create_device_id() -> String:
|
||||
const SAVE_PATH := "user://device_id.txt"
|
||||
if FileAccess.file_exists(SAVE_PATH):
|
||||
var f := FileAccess.open(SAVE_PATH, FileAccess.READ)
|
||||
var id := f.get_line().strip_edges()
|
||||
f.close()
|
||||
if not id.is_empty():
|
||||
return id
|
||||
|
||||
# Generate a new random device ID and persist it
|
||||
var new_id := "xyvera_" + str(randi()) + "_" + str(randi())
|
||||
var f := FileAccess.open(SAVE_PATH, FileAccess.WRITE)
|
||||
f.store_line(new_id)
|
||||
f.close()
|
||||
return new_id
|
||||
20
godot/src/screens/MainMenu.tscn
Normal file
20
godot/src/screens/MainMenu.tscn
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[gd_scene load_steps=2 format=3 uid=""]
|
||||
|
||||
[ext_resource type="Script" path="res://src/screens/MainMenu.gd" id="1_placeholder"]
|
||||
|
||||
[node name="MainMenu" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_placeholder")
|
||||
|
||||
[node name="Header" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
|
||||
[node name="Footer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
104
godot/src/screens/Village.gd
Normal file
104
godot/src/screens/Village.gd
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
## Village.gd — Village / home screen controller
|
||||
##
|
||||
## The emotional hub of the game. Shows:
|
||||
## - passive resource balances and collection button
|
||||
## - assignment slot overview (characters assigned to jobs)
|
||||
## - manual work button (the core PoC differentiator)
|
||||
## - navigation links to Gacha and Inventory screens
|
||||
##
|
||||
## Scene file: src/screens/Village.tscn (create in Godot editor)
|
||||
|
||||
extends Control
|
||||
|
||||
@onready var gold_label: Label = $Header/Resources/GoldLabel
|
||||
@onready var wood_label: Label = $Header/Resources/WoodLabel
|
||||
@onready var stone_label: Label = $Header/Resources/StoneLabel
|
||||
@onready var knowledge_label: Label = $Header/Resources/KnowledgeLabel
|
||||
@onready var mana_label: Label = $Header/Resources/ManaLabel
|
||||
@onready var diamond_label: Label = $Header/Resources/DiamondLabel
|
||||
|
||||
@onready var collect_button: Button = $Body/CollectButton
|
||||
@onready var manual_work_button: Button = $Body/ManualWorkButton
|
||||
@onready var slots_container: VBoxContainer = $Body/SlotsContainer
|
||||
|
||||
@onready var gacha_button: Button = $Footer/GachaButton
|
||||
@onready var inventory_button: Button = $Footer/InventoryButton
|
||||
|
||||
func _ready() -> void:
|
||||
collect_button.pressed.connect(_on_collect_pressed)
|
||||
manual_work_button.pressed.connect(_on_manual_work_pressed)
|
||||
gacha_button.pressed.connect(_on_gacha_pressed)
|
||||
inventory_button.pressed.connect(_on_inventory_pressed)
|
||||
|
||||
GameState.resources_updated.connect(_refresh_resource_display)
|
||||
GameState.village_updated.connect(_refresh_slots)
|
||||
|
||||
_refresh_resource_display(GameState.resources)
|
||||
_refresh_slots(GameState.village_slots)
|
||||
|
||||
func _refresh_resource_display(res: Dictionary) -> void:
|
||||
gold_label.text = "Gold: %d" % res.get("gold", 0)
|
||||
wood_label.text = "Wood: %d" % res.get("wood", 0)
|
||||
stone_label.text = "Stone: %d" % res.get("stone", 0)
|
||||
knowledge_label.text = "Knowledge: %d" % res.get("knowledge", 0)
|
||||
mana_label.text = "Mana: %d" % res.get("mana", 0)
|
||||
diamond_label.text = "Diamonds: %d" % res.get("diamond", 0)
|
||||
|
||||
func _refresh_slots(slots: Array) -> void:
|
||||
for child in slots_container.get_children():
|
||||
child.queue_free()
|
||||
|
||||
if slots.is_empty():
|
||||
var placeholder := Label.new()
|
||||
placeholder.text = "No characters assigned yet.\n(Assignment system — PR 8)"
|
||||
slots_container.add_child(placeholder)
|
||||
return
|
||||
|
||||
for slot in slots:
|
||||
var label := Label.new()
|
||||
label.text = "[%s] %s → %s" % [
|
||||
slot.get("job_id", "?"),
|
||||
slot.get("character_id", "empty"),
|
||||
slot.get("pending_yield", "?"),
|
||||
]
|
||||
slots_container.add_child(label)
|
||||
|
||||
func _on_collect_pressed() -> void:
|
||||
collect_button.disabled = true
|
||||
var result = await NakamaClient.collect_passive_resources()
|
||||
if result and result.get("ok"):
|
||||
var granted: Dictionary = result.get("granted", {})
|
||||
GameState.update_resources(await NakamaClient.get_resources())
|
||||
_show_toast("Collected: %s" % str(granted))
|
||||
else:
|
||||
_show_toast(result.get("message", "Collection stubbed — implement PR 8"))
|
||||
collect_button.disabled = false
|
||||
|
||||
func _on_manual_work_pressed() -> void:
|
||||
# Manual wood-chopping — the highest-priority PoC mechanic.
|
||||
# This button should show a clear comparison: how much wood the burst
|
||||
# action gave versus what passive would have yielded in the same time.
|
||||
manual_work_button.disabled = true
|
||||
var result = await NakamaClient.manual_work("wood")
|
||||
|
||||
if result and result.get("ok"):
|
||||
var burst: int = result.get("burst_yield", 0)
|
||||
var passive: int = result.get("passive_equivalent", 0)
|
||||
var used: int = result.get("daily_actions_used", 0)
|
||||
var cap: int = result.get("daily_actions_cap", 3)
|
||||
GameState.update_resources(await NakamaClient.get_resources())
|
||||
_show_toast("Manual wood +%d (passive would have given ~%d)\nActions today: %d/%d" % [burst, passive, used, cap])
|
||||
else:
|
||||
_show_toast(result.get("message", "Manual work stubbed — implement in PR 5"))
|
||||
|
||||
manual_work_button.disabled = false
|
||||
|
||||
func _on_gacha_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://src/screens/GachaPull.tscn")
|
||||
|
||||
func _on_inventory_pressed() -> void:
|
||||
get_tree().change_scene_to_file("res://src/screens/Inventory.tscn")
|
||||
|
||||
func _show_toast(msg: String) -> void:
|
||||
# TODO: replace with a proper toast/notification widget
|
||||
print("[Village] %s" % msg)
|
||||
20
godot/src/screens/Village.tscn
Normal file
20
godot/src/screens/Village.tscn
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
[gd_scene load_steps=2 format=3 uid=""]
|
||||
|
||||
[ext_resource type="Script" path="res://src/screens/Village.gd" id="1_placeholder"]
|
||||
|
||||
[node name="Village" type="Control"]
|
||||
layout_mode = 3
|
||||
anchors_preset = 15
|
||||
anchor_right = 1.0
|
||||
anchor_bottom = 1.0
|
||||
script = ExtResource("1_placeholder")
|
||||
|
||||
[node name="Header" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
anchors_preset = 0
|
||||
|
||||
[node name="Body" type="VBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
|
||||
[node name="Footer" type="HBoxContainer" parent="."]
|
||||
layout_mode = 1
|
||||
56
godot/src/systems/GameState.gd
Normal file
56
godot/src/systems/GameState.gd
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
## GameState.gd — Global game state cache
|
||||
##
|
||||
## AutoLoad singleton. Holds the last-known server state so screens can read
|
||||
## data synchronously without issuing a new RPC on every frame.
|
||||
## The server is always authoritative; this is a presentation cache only.
|
||||
|
||||
extends Node
|
||||
|
||||
# ── Player resources ───────────────────────────────────────────────────────────
|
||||
|
||||
var resources: Dictionary = {
|
||||
"gold": 0,
|
||||
"wood": 0,
|
||||
"stone": 0,
|
||||
"knowledge": 0,
|
||||
"experience": 0,
|
||||
"mana": 0,
|
||||
"diamond": 0,
|
||||
}
|
||||
|
||||
signal resources_updated(new_resources: Dictionary)
|
||||
|
||||
func update_resources(new_resources: Dictionary) -> void:
|
||||
resources.merge(new_resources, true)
|
||||
resources_updated.emit(resources)
|
||||
|
||||
# ── Roster ────────────────────────────────────────────────────────────────────
|
||||
|
||||
var roster: Array = [] # Array of character state Dicts from the server
|
||||
|
||||
signal roster_updated(new_roster: Array)
|
||||
|
||||
func update_roster(new_roster: Array) -> void:
|
||||
roster = new_roster
|
||||
roster_updated.emit(roster)
|
||||
|
||||
# ── Village ───────────────────────────────────────────────────────────────────
|
||||
|
||||
var village_slots: Array = [] # Assignment slot states
|
||||
|
||||
signal village_updated(slots: Array)
|
||||
|
||||
func update_village(new_slots: Array) -> void:
|
||||
village_slots = new_slots
|
||||
village_updated.emit(village_slots)
|
||||
|
||||
# ── Session helpers ───────────────────────────────────────────────────────────
|
||||
|
||||
func refresh_all() -> void:
|
||||
var res = await NakamaClient.get_resources()
|
||||
if res:
|
||||
update_resources(res)
|
||||
|
||||
var village = await NakamaClient.get_village_state()
|
||||
if village.get("slots"):
|
||||
update_village(village["slots"])
|
||||
Loading…
Add table
Add a link
Reference in a new issue