|
@@ -3,6 +3,16 @@ import type { ContentBundle, DialogueState, Engine, ItemDef, MapDef, ShopDef, Ti
|
|
|
import { connectLocal, connectWs, createSoundPlayer, createViewport, type ViewportHandle } from "@nengine/render";
|
|
import { connectLocal, connectWs, createSoundPlayer, createViewport, type ViewportHandle } from "@nengine/render";
|
|
|
import type { SoundPlayer } from "@nengine/render";
|
|
import type { SoundPlayer } from "@nengine/render";
|
|
|
import type { WsEventFrame, WsSnapshotFrame } from "@nengine/render";
|
|
import type { WsEventFrame, WsSnapshotFrame } from "@nengine/render";
|
|
|
|
|
+import {
|
|
|
|
|
+ DialogueBox,
|
|
|
|
|
+ HudBar,
|
|
|
|
|
+ InventoryPanel,
|
|
|
|
|
+ ShopPanel,
|
|
|
|
|
+ type DialogueState as NDialogueState,
|
|
|
|
|
+ type HudBarState,
|
|
|
|
|
+ type InventoryPanelState,
|
|
|
|
|
+ type ShopPanelState,
|
|
|
|
|
+} from "@nengine/ui";
|
|
|
import { parseDevParams } from "./params.js";
|
|
import { parseDevParams } from "./params.js";
|
|
|
import { keydownInput } from "./keys.js";
|
|
import { keydownInput } from "./keys.js";
|
|
|
import {
|
|
import {
|
|
@@ -30,11 +40,56 @@ const inventorySeed = document.getElementById("inventory-seed") as HTMLSpanEleme
|
|
|
const dialoguePanel = document.getElementById("dialogue-panel") as HTMLElement;
|
|
const dialoguePanel = document.getElementById("dialogue-panel") as HTMLElement;
|
|
|
const dialogueText = document.getElementById("dialogue-text") as HTMLDivElement;
|
|
const dialogueText = document.getElementById("dialogue-text") as HTMLDivElement;
|
|
|
const dialogueChoices = document.getElementById("dialogue-choices") as HTMLElement;
|
|
const dialogueChoices = document.getElementById("dialogue-choices") as HTMLElement;
|
|
|
-const shopPanel = document.getElementById("shop-panel") as HTMLElement;
|
|
|
|
|
|
|
+const shopPanelEl = document.getElementById("shop-panel") as HTMLElement;
|
|
|
const shopTitle = document.getElementById("shop-title") as HTMLHeadingElement;
|
|
const shopTitle = document.getElementById("shop-title") as HTMLHeadingElement;
|
|
|
const shopSells = document.getElementById("shop-sells") as HTMLElement;
|
|
const shopSells = document.getElementById("shop-sells") as HTMLElement;
|
|
|
const shopBuys = document.getElementById("shop-buys") as HTMLElement;
|
|
const shopBuys = document.getElementById("shop-buys") as HTMLElement;
|
|
|
const shopClose = document.getElementById("shop-close") as HTMLButtonElement;
|
|
const shopClose = document.getElementById("shop-close") as HTMLButtonElement;
|
|
|
|
|
+const inventoryBar = document.getElementById("inventory-bar") as HTMLElement;
|
|
|
|
|
+const hud = document.getElementById("hud") as HTMLElement;
|
|
|
|
|
+
|
|
|
|
|
+const dialogueBox = new DialogueBox();
|
|
|
|
|
+dialoguePanel.innerHTML = "";
|
|
|
|
|
+dialogueBox.mount(dialoguePanel);
|
|
|
|
|
+
|
|
|
|
|
+const shopPanel = new ShopPanel();
|
|
|
|
|
+shopPanelEl.innerHTML = "";
|
|
|
|
|
+shopPanel.mount(shopPanelEl);
|
|
|
|
|
+
|
|
|
|
|
+const hudUiDynamic = document.createElement("div");
|
|
|
|
|
+hudUiDynamic.id = "hud-ui-dynamic";
|
|
|
|
|
+inventoryBar.appendChild(hudUiDynamic);
|
|
|
|
|
+const hudBar = new HudBar();
|
|
|
|
|
+hudBar.mount(hudUiDynamic);
|
|
|
|
|
+
|
|
|
|
|
+const inventoryOverlay = document.createElement("div");
|
|
|
|
|
+inventoryOverlay.id = "inventory-overlay";
|
|
|
|
|
+inventoryOverlay.style.cssText = "position:absolute;top:80px;left:50%;transform:translateX(-50%);z-index:20;display:none;";
|
|
|
|
|
+game.appendChild(inventoryOverlay);
|
|
|
|
|
+const inventoryPanel = new InventoryPanel();
|
|
|
|
|
+inventoryPanel.mount(inventoryOverlay);
|
|
|
|
|
+
|
|
|
|
|
+const openInventoryBtn = document.createElement("button");
|
|
|
|
|
+openInventoryBtn.type = "button";
|
|
|
|
|
+openInventoryBtn.textContent = "Open Inventory";
|
|
|
|
|
+openInventoryBtn.style.cssText = "font:inherit;padding:6px 14px;border:1px solid #555;border-radius:4px;background:#333;color:#eee;cursor:pointer;";
|
|
|
|
|
+openInventoryBtn.addEventListener("click", () => {
|
|
|
|
|
+ const visible = inventoryOverlay.style.display !== "none";
|
|
|
|
|
+ inventoryOverlay.style.display = visible ? "none" : "";
|
|
|
|
|
+ if (!visible) {
|
|
|
|
|
+ renderInventoryPanel();
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+hud.appendChild(openInventoryBtn);
|
|
|
|
|
+
|
|
|
|
|
+const closeInventoryBtn = document.createElement("button");
|
|
|
|
|
+closeInventoryBtn.type = "button";
|
|
|
|
|
+closeInventoryBtn.textContent = "Close Inventory";
|
|
|
|
|
+closeInventoryBtn.style.cssText = "margin-bottom:8px;font:inherit;padding:4px 10px;border:1px solid #555;border-radius:4px;background:#37474f;color:#eceff1;cursor:pointer;";
|
|
|
|
|
+closeInventoryBtn.addEventListener("click", () => {
|
|
|
|
|
+ inventoryOverlay.style.display = "none";
|
|
|
|
|
+});
|
|
|
|
|
+inventoryOverlay.insertBefore(closeInventoryBtn, inventoryOverlay.firstChild);
|
|
|
|
|
|
|
|
type Tool = "interact" | "move";
|
|
type Tool = "interact" | "move";
|
|
|
|
|
|
|
@@ -54,6 +109,36 @@ let sync: SyncLike = { intent: () => {} };
|
|
|
let view: SnapshotView = { world: [], shops: [] };
|
|
let view: SnapshotView = { world: [], shops: [] };
|
|
|
let activeShop: { shopId: string } | undefined;
|
|
let activeShop: { shopId: string } | undefined;
|
|
|
let dialogueState: DialogueState | undefined;
|
|
let dialogueState: DialogueState | undefined;
|
|
|
|
|
+let invSystem: { use(itemId: string): void } | undefined;
|
|
|
|
|
+
|
|
|
|
|
+dialogueBox.on('choose', (idx: number) => {
|
|
|
|
|
+ sync.intent('choose', { choice: idx });
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+shopPanel.on('buy', (itemId: string, qty: number) => {
|
|
|
|
|
+ if (activeShop) {
|
|
|
|
|
+ sync.intent('buy', { shopId: activeShop.shopId, itemId, qty });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+shopPanel.on('sell', (itemId: string, qty: number) => {
|
|
|
|
|
+ if (activeShop) {
|
|
|
|
|
+ sync.intent('sell', { shopId: activeShop.shopId, itemId, qty });
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+shopPanel.on('close', () => {
|
|
|
|
|
+ shopPanelEl.classList.add('hidden');
|
|
|
|
|
+ activeShop = undefined;
|
|
|
|
|
+});
|
|
|
|
|
+
|
|
|
|
|
+inventoryPanel.on('use', (itemId: string) => {
|
|
|
|
|
+ if (invSystem && typeof invSystem.use === 'function') {
|
|
|
|
|
+ invSystem.use(itemId);
|
|
|
|
|
+ } else {
|
|
|
|
|
+ showFeedback('Use via MCP only', false);
|
|
|
|
|
+ }
|
|
|
|
|
+});
|
|
|
|
|
|
|
|
function toRepoPath(rel: string): string {
|
|
function toRepoPath(rel: string): string {
|
|
|
return new URL(rel.startsWith("/") ? rel : `../../../${rel}`, import.meta.url).pathname;
|
|
return new URL(rel.startsWith("/") ? rel : `../../../${rel}`, import.meta.url).pathname;
|
|
@@ -113,6 +198,10 @@ function renderUi(): void {
|
|
|
renderInventoryBar(stats);
|
|
renderInventoryBar(stats);
|
|
|
renderDialoguePanel();
|
|
renderDialoguePanel();
|
|
|
renderShopPanel();
|
|
renderShopPanel();
|
|
|
|
|
+ renderHudBar(stats);
|
|
|
|
|
+ if (inventoryOverlay.style.display !== "none") {
|
|
|
|
|
+ renderInventoryPanel();
|
|
|
|
|
+ }
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderSeedPicker(stats: PlayerStats): void {
|
|
function renderSeedPicker(stats: PlayerStats): void {
|
|
@@ -146,71 +235,69 @@ function renderDialoguePanel(): void {
|
|
|
dialoguePanel.classList.toggle("hidden", !active);
|
|
dialoguePanel.classList.toggle("hidden", !active);
|
|
|
if (!active) return;
|
|
if (!active) return;
|
|
|
const node = dialogueChoicesFromNode(dialogueState);
|
|
const node = dialogueChoicesFromNode(dialogueState);
|
|
|
- dialogueText.textContent = node.text.length > 0 ? node.text : "…";
|
|
|
|
|
- dialogueChoices.replaceChildren();
|
|
|
|
|
- node.choices.forEach((text, index) => {
|
|
|
|
|
- const button = document.createElement("button");
|
|
|
|
|
- button.type = "button";
|
|
|
|
|
- button.textContent = text;
|
|
|
|
|
- button.addEventListener("click", () => {
|
|
|
|
|
- sync.intent("choose", { index });
|
|
|
|
|
- });
|
|
|
|
|
- dialogueChoices.appendChild(button);
|
|
|
|
|
- });
|
|
|
|
|
|
|
+ const choices = node.choices.map((text) => ({ text }));
|
|
|
|
|
+ dialogueBox.update({
|
|
|
|
|
+ text: node.text.length > 0 ? node.text : "…",
|
|
|
|
|
+ choices,
|
|
|
|
|
+ typingSpeedMs: 20,
|
|
|
|
|
+ } as NDialogueState);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function renderShopPanel(): void {
|
|
function renderShopPanel(): void {
|
|
|
if (!activeShop) return;
|
|
if (!activeShop) return;
|
|
|
const shop = view.shops.find((candidate) => candidate.id === activeShop?.shopId);
|
|
const shop = view.shops.find((candidate) => candidate.id === activeShop?.shopId);
|
|
|
|
|
+ const stats = playerStatsFromSnapshot(view.world);
|
|
|
if (!shop) {
|
|
if (!shop) {
|
|
|
- shopTitle.textContent = `Shop: ${activeShop.shopId}`;
|
|
|
|
|
- shopSells.replaceChildren();
|
|
|
|
|
- shopBuys.replaceChildren();
|
|
|
|
|
- return;
|
|
|
|
|
- }
|
|
|
|
|
- const rows = shopRowsFromDef(shop, playerStatsFromSnapshot(view.world).inventory, view.items);
|
|
|
|
|
- shopTitle.textContent = `Shop: ${shop.id}`;
|
|
|
|
|
- shopSells.replaceChildren();
|
|
|
|
|
- for (const row of rows.sellRows) {
|
|
|
|
|
- const line = document.createElement("div");
|
|
|
|
|
- line.className = "row";
|
|
|
|
|
- const label = document.createElement("span");
|
|
|
|
|
- label.textContent = `${row.name} (own ${row.owned})`;
|
|
|
|
|
- const price = document.createElement("span");
|
|
|
|
|
- price.className = "price";
|
|
|
|
|
- price.textContent = `${row.price}g`;
|
|
|
|
|
- const button = document.createElement("button");
|
|
|
|
|
- button.type = "button";
|
|
|
|
|
- button.textContent = "Buy";
|
|
|
|
|
- button.addEventListener("click", () => {
|
|
|
|
|
- sync.intent("buy", { shopId: shop.id, itemId: row.itemId, qty: 1 });
|
|
|
|
|
|
|
+ shopPanel.update({
|
|
|
|
|
+ shopId: activeShop.shopId,
|
|
|
|
|
+ shopName: `Shop: ${activeShop.shopId}`,
|
|
|
|
|
+ sells: [],
|
|
|
|
|
+ buys: [],
|
|
|
|
|
+ playerGold: stats.gold,
|
|
|
});
|
|
});
|
|
|
- line.append(label, price, button);
|
|
|
|
|
- shopSells.appendChild(line);
|
|
|
|
|
- }
|
|
|
|
|
- shopBuys.replaceChildren();
|
|
|
|
|
- for (const row of rows.buyRows) {
|
|
|
|
|
- const line = document.createElement("div");
|
|
|
|
|
- line.className = "row";
|
|
|
|
|
- const label = document.createElement("span");
|
|
|
|
|
- label.textContent = `${row.name} (own ${row.owned})`;
|
|
|
|
|
- const price = document.createElement("span");
|
|
|
|
|
- price.className = "price";
|
|
|
|
|
- price.textContent = `${row.price}g`;
|
|
|
|
|
- const button = document.createElement("button");
|
|
|
|
|
- button.type = "button";
|
|
|
|
|
- button.textContent = "Sell";
|
|
|
|
|
- button.addEventListener("click", () => {
|
|
|
|
|
- sync.intent("sell", { shopId: shop.id, itemId: row.itemId, qty: 1 });
|
|
|
|
|
- });
|
|
|
|
|
- line.append(label, price, button);
|
|
|
|
|
- shopBuys.appendChild(line);
|
|
|
|
|
|
|
+ return;
|
|
|
}
|
|
}
|
|
|
|
|
+ const rows = shopRowsFromDef(shop, stats.inventory, view.items);
|
|
|
|
|
+ shopPanel.update({
|
|
|
|
|
+ shopId: shop.id,
|
|
|
|
|
+ shopName: `Shop: ${shop.id}`,
|
|
|
|
|
+ sells: rows.sellRows,
|
|
|
|
|
+ buys: rows.buyRows,
|
|
|
|
|
+ playerGold: stats.gold,
|
|
|
|
|
+ });
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderHudBar(stats: PlayerStats): void {
|
|
|
|
|
+ hudBar.update({
|
|
|
|
|
+ day: 1,
|
|
|
|
|
+ season: "spring",
|
|
|
|
|
+ gold: stats.gold,
|
|
|
|
|
+ stamina: { cur: stats.stamina, max: 100 },
|
|
|
|
|
+ selectedSeed: stats.selectedSeed,
|
|
|
|
|
+ } as HudBarState);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+function renderInventoryPanel(): void {
|
|
|
|
|
+ const stats = playerStatsFromSnapshot(view.world);
|
|
|
|
|
+ const items = Object.entries(stats.inventory).map(([id, qty]) => {
|
|
|
|
|
+ const def = view.items?.find((item) => item.id === id);
|
|
|
|
|
+ return {
|
|
|
|
|
+ itemId: id,
|
|
|
|
|
+ name: def?.name ?? id,
|
|
|
|
|
+ qty,
|
|
|
|
|
+ category: (def?.kind as "seed" | "crop" | "tool" | "good") ?? "good",
|
|
|
|
|
+ consumable: def?.kind === "crop" || def?.kind === "seed",
|
|
|
|
|
+ };
|
|
|
|
|
+ });
|
|
|
|
|
+ inventoryPanel.update({
|
|
|
|
|
+ items,
|
|
|
|
|
+ activeCategory: "all",
|
|
|
|
|
+ } as InventoryPanelState);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
function setActiveShop(shop: { shopId: string } | undefined): void {
|
|
function setActiveShop(shop: { shopId: string } | undefined): void {
|
|
|
activeShop = shop;
|
|
activeShop = shop;
|
|
|
- shopPanel.classList.toggle("hidden", shop === undefined);
|
|
|
|
|
|
|
+ shopPanelEl.classList.toggle("hidden", shop === undefined);
|
|
|
if (shop) renderShopPanel();
|
|
if (shop) renderShopPanel();
|
|
|
}
|
|
}
|
|
|
|
|
|
|
@@ -235,7 +322,6 @@ function bindKeyboard(syncHandle: { input(input: string): void }, sound: { prime
|
|
|
async function boot(): Promise<void> {
|
|
async function boot(): Promise<void> {
|
|
|
interactButton.addEventListener("click", () => setTool("interact"));
|
|
interactButton.addEventListener("click", () => setTool("interact"));
|
|
|
moveButton.addEventListener("click", () => setTool("move"));
|
|
moveButton.addEventListener("click", () => setTool("move"));
|
|
|
- shopClose.addEventListener("click", () => setActiveShop(undefined));
|
|
|
|
|
setTool("interact");
|
|
setTool("interact");
|
|
|
|
|
|
|
|
try {
|
|
try {
|