feat(world): draw the workshop town and work its farms

The city canvas merges the house furniture atlas (craft-v1) with the
workshop's town art (vale-v1: ground, houses, props, crop stages), generated
from the offline export with a tile entry per frame. A click on a bed waters,
harvests, clears or plants it (the server decides who may and what grows);
the panel shows the harvest in the house and picks the crop to plant.
This commit is contained in:
tonhowtf
2026-09-23 16:57:29 -03:00
parent 267eb694f9
commit e832cfdc8b
21 changed files with 2126 additions and 23 deletions
+36 -3
View File
@@ -15,7 +15,7 @@
import { t } from "$lib/i18n";
import { getSettings } from "$lib/stores/settings-store.svelte";
import { codecErrorCode } from "$lib/world/codec";
import { agentSprite, buildChunkTiles, loadWorldAtlas, objectFrame, type MapDef } from "$lib/world/assets";
import { agentSprite, buildChunkTiles, loadWorldAtlas, objectFrame, CRAFT_ATLAS_URL, VALE_ATLAS_URL, type MapDef } from "$lib/world/assets";
import { buildHud, capAgents, gameClockLabel, type AgentFrame } from "$lib/world/hud";
import { attachInput, type Pickable } from "$lib/world/input";
import { sample } from "$lib/world/interp";
@@ -27,12 +27,16 @@
interface Props {
city: string;
server?: string | null;
/** What a click on an empty bed plants. */
crop?: string;
/** A farm action was acknowledged (or refused, with the code). */
onfarm?: (result: { ok: boolean; code: string; action: string }) => void;
onready?: (ready: CityReady) => void;
onfailed?: (error: string) => void;
onstate?: (state: { region: string; ent: number; tick: number; interior: boolean }) => void;
onstats?: (stats: FrameStats, fps: number, tier: Tier) => void;
}
let { city, server = null, onready, onfailed, onstate, onstats }: Props = $props();
let { city, server = null, crop = "carrot", onready, onfailed, onstate, onstats, onfarm }: Props = $props();
/** A chat line over someone's head. */
export function say(ent: number, text: string): void {
@@ -202,6 +206,23 @@
}
}
/** The crop bed drawn on a tile, if any: its kind decides the action. */
function bedAt(x: number, y: number): { kind: string } | null {
for (const o of world.objects.values()) {
if (o.tx === x && o.ty === y && (o.kind.startsWith("crop/") || o.kind === "prop/soil-empty")) return { kind: o.kind };
}
return null;
}
/** Water, harvest, clear or plant, by what the bed shows. */
async function farm(x: number, y: number, kind: string): Promise<void> {
const stage = kind.split("/")[2] ?? "";
const action = kind === "prop/soil-empty" ? "plant" : stage === "ripe" ? "harvest" : stage === "withered" ? "clear" : "water";
const seq = await session?.send({ type: "farm", tile: [x, y], action, crop: action === "plant" ? crop : undefined });
if (seq !== undefined) pendingFarm.set(seq, action);
}
const pendingFarm = new Map<number, string>();
/** The door marker on a tile of the current region, if any. */
function portalAt(x: number, y: number): string | null {
const map = maps.get(region);
@@ -225,6 +246,12 @@
try {
await bootRender();
session = createCitySession({ city, server });
stops.push(session.onAck((ack) => {
const action = pendingFarm.get(ack.seq);
if (!action) return;
pendingFarm.delete(ack.seq);
onfarm?.({ ok: ack.status === 0, code: ack.code, action });
}));
stops.push(session.onClosed((reason) => {
if (reason !== "left") {
errorCode = `ERR_CITY_${reason.toUpperCase()}`;
@@ -255,7 +282,8 @@
const r = mod.createRenderer();
const caps = await r.init(canvas!, { tier });
tier = caps.tier;
const loaded = await loadWorldAtlas();
// The house furniture (craft-v1) and the town (vale-v1) in one atlas.
const loaded = await loadWorldAtlas(CRAFT_ATLAS_URL, [VALE_ATLAS_URL]);
r.loadAtlas(loaded.json, loaded.pages);
atlas = mod.parseAtlas(loaded.json);
const rect = canvas!.getBoundingClientRect();
@@ -304,6 +332,11 @@
void send({ type: "enter", portal });
return;
}
const bed = bedAt(tile.x, tile.y);
if (bed) {
void farm(tile.x, tile.y, bed.kind);
return;
}
void send({ type: "move", to: [tile.x, tile.y] });
},
onAgent: (id) => {
+29 -2
View File
@@ -17,10 +17,14 @@
onleave: () => void;
onsay?: (ent: number, text: string) => void;
ongoto?: (tile: [number, number]) => void;
/** The crop a click on an empty bed plants; the canvas reads it. */
crop?: string;
oncrop?: (crop: string) => void;
}
let { city, where, onenter, onleave, onsay, ongoto }: Props = $props();
let { city, where, onenter, onleave, onsay, ongoto, crop = "carrot", oncrop }: Props = $props();
type Home = { home: { id: string; name: string; plot: string; interior_region: string }; plot: { id: string; address: string; entrance: [number, number]; region: string } | null };
type Home = { home: { id: string; name: string; plot: string; interior_region: string; inventory?: Record<string, number> }; plot: { id: string; address: string; entrance: [number, number]; region: string; kind?: string } | null };
const CROPS = ["carrot", "tomato", "wheat"];
let cityId = $state("cidade-piloto");
let server = $state(getSettings()?.world?.city_server ?? "");
@@ -90,6 +94,11 @@
}
});
/** The canvas tells the page a farm action landed; refresh the inventory. */
export function refreshHome(): void {
void loadHome();
}
async function loadHome(): Promise<void> {
if (!city) return;
try {
@@ -184,6 +193,14 @@
<span class="label">{$t("world.city.address")}</span>
<span class="chip host">{home.plot.address}</span>
<button type="button" class="btn" onclick={goHome}>{$t("world.city.go_home")}</button>
{#if home.home.inventory && Object.keys(home.home.inventory).length > 0}
<span class="chip" title={$t("world.city.inventory") as string}>{Object.entries(home.home.inventory).map(([k, v]) => `${$t(`world.city.crop_${k}`)} ×${v}`).join(" · ")}</span>
{/if}
<label class="label">{$t("world.city.plant")}
<select value={crop} onchange={(e) => oncrop?.((e.currentTarget as HTMLSelectElement).value)}>
{#each CROPS as c}<option value={c}>{$t(`world.city.crop_${c}`)}</option>{/each}
</select>
</label>
{:else}
<button type="button" class="btn primary" onclick={claim} disabled={busy}>{$t("world.city.claim")}</button>
<span class="hint">{$t("world.city.claim_hint")}</span>
@@ -236,6 +253,16 @@
opacity: 0.5;
cursor: default;
}
select {
padding: 0.3rem 0.5rem;
border-radius: 8px;
border: 1px solid rgba(127, 127, 127, 0.3);
background: rgba(127, 127, 127, 0.08);
color: inherit;
font: inherit;
font-size: 0.85rem;
margin-left: 0.3rem;
}
input {
padding: 0.35rem 0.6rem;
border-radius: 8px;
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+15
View File
@@ -9378,17 +9378,32 @@ export type TranslationKeys =
| 'world.city.claim_hint'
| 'world.city.claimed'
| 'world.city.create_account'
| 'world.city.crop_carrot'
| 'world.city.crop_tomato'
| 'world.city.crop_wheat'
| 'world.city.enter'
| 'world.city.entering'
| 'world.city.farm_bad_action'
| 'world.city.farm_conflict'
| 'world.city.farm_empty'
| 'world.city.farm_no_cell'
| 'world.city.farm_no_home'
| 'world.city.farm_not_ripe'
| 'world.city.farm_not_withered'
| 'world.city.farm_not_your_plot'
| 'world.city.farm_occupied'
| 'world.city.farm_unknown_crop'
| 'world.city.go_home'
| 'world.city.have_account'
| 'world.city.hint'
| 'world.city.in'
| 'world.city.inside'
| 'world.city.inventory'
| 'world.city.leave'
| 'world.city.no_account'
| 'world.city.outside'
| 'world.city.password'
| 'world.city.plant'
| 'world.city.reconnecting'
| 'world.city.server'
| 'world.city.server_hint'
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Criar conta",
"have_account": "Já tenho conta",
"no_account": "Criar uma conta",
"sign_out": "Sair da conta"
"sign_out": "Sair da conta",
"inventory": "Colheita na sua casa",
"plant": "Plantar",
"crop_carrot": "cenoura",
"crop_tomato": "tomate",
"crop_wheat": "trigo",
"farm_empty": "Esse canteiro está vazio",
"farm_not_ripe": "Ainda não está pronto; regue e espere",
"farm_not_withered": "Nada para limpar",
"farm_occupied": "Esse canteiro já tem cultura",
"farm_unknown_crop": "Cultura desconhecida",
"farm_conflict": "O canteiro mudou, tente de novo",
"farm_bad_action": "Nada a fazer aí",
"farm_not_your_plot": "Esse canteiro é de outra pessoa",
"farm_no_home": "Reivindique um lote primeiro",
"farm_no_cell": "Não há canteiro aí"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+16 -1
View File
@@ -9621,7 +9621,22 @@
"create_account": "Create account",
"have_account": "I already have an account",
"no_account": "Create an account",
"sign_out": "Sign out"
"sign_out": "Sign out",
"inventory": "Harvest in your house",
"plant": "Plant",
"crop_carrot": "carrot",
"crop_tomato": "tomato",
"crop_wheat": "wheat",
"farm_empty": "That bed is empty",
"farm_not_ripe": "Not ripe yet; water it and wait",
"farm_not_withered": "Nothing to clear",
"farm_occupied": "That bed already has a crop",
"farm_unknown_crop": "Unknown crop",
"farm_conflict": "The bed changed under you, try again",
"farm_bad_action": "Nothing to do there",
"farm_not_your_plot": "That bed belongs to someone else",
"farm_no_home": "Claim a plot first",
"farm_no_cell": "No bed there"
}
},
"llm": {
+8 -5
View File
@@ -19,6 +19,8 @@ export const OMNI_ATLAS_URL = '/world/omni/atlas.json';
export const CASA_ATLAS_URL = '/world/tiles/casa-v1/atlas.json';
/** Visual theme only: the same tile keys and collision metadata as casa-v1. */
export const CRAFT_ATLAS_URL = '/world/tiles/craft-v1/atlas.json';
/** The workshop's town art (ground, houses, props, crops), for the city. */
export const VALE_ATLAS_URL = '/world/tiles/vale-v1/atlas.json';
export const HOUSE_MAP_URL = '/world/house-v1.json';
export const ERR_ASSETS = {
@@ -237,16 +239,17 @@ async function json<T>(url: string, code: string): Promise<T> {
}
/** Loads both published atlases plus the generated HUD page. */
export async function loadWorldAtlas(tileAtlasUrl = CRAFT_ATLAS_URL): Promise<WorldAtlas> {
const [omni, casa] = await Promise.all([
export async function loadWorldAtlas(tileAtlasUrl = CRAFT_ATLAS_URL, extraTileAtlasUrls: string[] = []): Promise<WorldAtlas> {
const urls = [tileAtlasUrl, ...extraTileAtlasUrls];
const [omni, ...tileAtlases] = await Promise.all([
json<RawAtlas>(OMNI_ATLAS_URL, ERR_ASSETS.ATLAS_MISSING),
json<RawAtlas>(tileAtlasUrl, ERR_ASSETS.ATLAS_MISSING),
...urls.map((u) => json<RawAtlas>(u, ERR_ASSETS.ATLAS_MISSING)),
]);
const hud = buildHudAtlasPart();
const merged = mergeAtlases([omni, casa, hud.part]);
const merged = mergeAtlases([omni, ...tileAtlases, hud.part]);
const pages = await Promise.all([
...omni.pages.map((p) => bitmap(`/world/omni/${p}`)),
...casa.pages.map((p) => bitmap(`${tileAtlasUrl.slice(0, tileAtlasUrl.lastIndexOf('/') + 1)}${p}`)),
...tileAtlases.flatMap((atlas, i) => atlas.pages.map((p) => bitmap(`${urls[i].slice(0, urls[i].lastIndexOf('/') + 1)}${p}`))),
hudBitmap(hud.draw),
]);
return { json: merged.json, pages };
+2 -1
View File
@@ -54,7 +54,8 @@ export type CityInput =
| { type: 'wave'; ent: number }
| { type: 'sit'; object: number }
| { type: 'idle' }
| { type: 'enter'; portal: string };
| { type: 'enter'; portal: string }
| { type: 'farm'; cell?: string; tile?: [number, number]; action: 'water' | 'harvest' | 'clear' | 'plant'; crop?: string };
export interface CityChat {
from_ent: number;
+10
View File
@@ -43,6 +43,8 @@
let cityMode = $state<{ city: string; server: string | null } | null>(null);
let cityState = $state<{ region: string; ent: number; tick: number; interior: boolean } | null>(null);
let cityRef = $state<{ say: (ent: number, text: string) => void; goTo: (tile: [number, number]) => Promise<void> } | null>(null);
let cityPanelRef = $state<{ refreshHome: () => void } | null>(null);
let cityCrop = $state("carrot");
let canvasRef = $state<{ say: (ent: number, text: string) => void; focus: (ent: number) => void; frameHouse: () => void } | null>(null);
let residents = $state<AgentRow[]>([]);
let demoBusy = $state(false);
@@ -127,6 +129,11 @@
bind:this={cityRef}
city={cityMode.city}
server={cityMode.server}
crop={cityCrop}
onfarm={(r) => {
if (r.ok) cityPanelRef?.refreshHome();
else showToast("error", $t(`world.city.farm_${r.code.toLowerCase().replace(/^err_world_(farm_)?/, "")}`) as string);
}}
onstate={(s) => (cityState = s)}
onfailed={(error) => {
showToast("error", error);
@@ -137,8 +144,11 @@
{/key}
</div>
<CityPanel
bind:this={cityPanelRef}
city={cityMode}
where={cityState}
crop={cityCrop}
oncrop={(c) => (cityCrop = c)}
onenter={(c) => (cityMode = c)}
onleave={() => {
cityMode = null;
File diff suppressed because it is too large Load Diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 352 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 75 KiB