From fc3d24375ef933088227e2d944e6a592244eef01 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 18 Sep 2026 07:46:40 +0100 Subject: [PATCH 1/2] Add survival modes, inventory, and crafting progression --- .github/workflows/tests.yml | 7 + AGENTS.md | 11 +- CRAFTING.md | 133 ++++++ ROADMAP.md | 23 +- autoload/game_config.gd | 14 + game/block_containers.gd | 161 +++++++ game/block_containers.gd.uid | 1 + game/crafting_recipes.gd | 121 +++++ game/crafting_recipes.gd.uid | 1 + game/game_mode.gd | 19 + game/game_mode.gd.uid | 1 + game/item_drops.gd | 183 +++++++ game/item_drops.gd.uid | 1 + game/item_inventory.gd | 148 ++++++ game/item_inventory.gd.uid | 1 + game/item_registry.gd | 133 +++++- game/main.gd | 253 +++++++--- game/survival_ui.gd | 94 ++++ game/survival_ui.gd.uid | 1 + player/player.gd | 324 ++++++++++++- player/player_effects.gd | 118 +++++ player/player_effects.gd.uid | 1 + project.godot | 12 + tools/flat_survival_warning_verify.gd | 43 ++ tools/flat_survival_warning_verify.gd.uid | 1 + tools/gameplay_integration_verify.gd | 178 +++++++ tools/gameplay_integration_verify.gd.uid | 1 + tools/inventory_crafting_verify.gd | 220 +++++++++ tools/inventory_crafting_verify.gd.uid | 1 + tools/inventory_ui_verify.gd | 264 ++++++++++ tools/inventory_ui_verify.gd.uid | 1 + tools/item_drops_verify.gd | 159 +++++++ tools/item_drops_verify.gd.uid | 1 + tools/player_survival_verify.gd | 245 ++++++++++ tools/player_survival_verify.gd.uid | 1 + tools/player_target_verify.gd | 1 + tools/survival_progression_verify.gd | 401 ++++++++++++++++ tools/survival_progression_verify.gd.uid | 1 + tools/ui_flow_verify.gd | 33 ++ tools/world_storage_verify.gd | 44 ++ tools/worldgen_ore_verify.gd | 32 ++ tools/worldgen_water_verify.gd | 13 + ui/inventory_overlay.gd | 556 +++++++++++++++++++--- ui/inventory_slot.gd | 91 ++++ ui/inventory_slot.gd.uid | 1 + ui/inventory_transfer.gd | 44 ++ ui/inventory_transfer.gd.uid | 1 + ui/main_menu.gd | 1 + ui/pause_menu.gd | 2 +- ui/play_panel.gd | 61 ++- world/block_registry.gd | 181 ++++--- world/world_storage.gd | 17 +- world/worldgen/voxel_populator.gd | 10 +- world/worldgen/world_gen_config.gd | 3 +- 54 files changed, 4129 insertions(+), 240 deletions(-) create mode 100644 CRAFTING.md create mode 100644 game/block_containers.gd create mode 100644 game/block_containers.gd.uid create mode 100644 game/crafting_recipes.gd create mode 100644 game/crafting_recipes.gd.uid create mode 100644 game/game_mode.gd create mode 100644 game/game_mode.gd.uid create mode 100644 game/item_drops.gd create mode 100644 game/item_drops.gd.uid create mode 100644 game/item_inventory.gd create mode 100644 game/item_inventory.gd.uid create mode 100644 game/survival_ui.gd create mode 100644 game/survival_ui.gd.uid create mode 100644 player/player_effects.gd create mode 100644 player/player_effects.gd.uid create mode 100644 tools/flat_survival_warning_verify.gd create mode 100644 tools/flat_survival_warning_verify.gd.uid create mode 100644 tools/gameplay_integration_verify.gd create mode 100644 tools/gameplay_integration_verify.gd.uid create mode 100644 tools/inventory_crafting_verify.gd create mode 100644 tools/inventory_crafting_verify.gd.uid create mode 100644 tools/inventory_ui_verify.gd create mode 100644 tools/inventory_ui_verify.gd.uid create mode 100644 tools/item_drops_verify.gd create mode 100644 tools/item_drops_verify.gd.uid create mode 100644 tools/player_survival_verify.gd create mode 100644 tools/player_survival_verify.gd.uid create mode 100644 tools/survival_progression_verify.gd create mode 100644 tools/survival_progression_verify.gd.uid create mode 100644 ui/inventory_slot.gd create mode 100644 ui/inventory_slot.gd.uid create mode 100644 ui/inventory_transfer.gd create mode 100644 ui/inventory_transfer.gd.uid diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index f9263f8..ae8a43e 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,6 +68,13 @@ jobs: tools/input_rebind_verify.gd \ tools/photo_mode_verify.gd \ tools/player_target_verify.tscn \ + tools/player_survival_verify.gd \ + tools/inventory_crafting_verify.gd \ + tools/inventory_ui_verify.gd \ + tools/item_drops_verify.gd \ + tools/gameplay_integration_verify.gd \ + tools/survival_progression_verify.gd \ + tools/flat_survival_warning_verify.gd \ tools/explosives_verify.tscn \ tools/fire_verify.tscn \ tools/worldgen_cactus_verify.gd \ diff --git a/AGENTS.md b/AGENTS.md index d5d4072..7d7db96 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -28,10 +28,10 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - Stream-center rebuilds enumerate nearest-first rings without sorting the full square, completed collision results commit before distant visuals, and a single-cell edit queues its owner ahead of neighbor-light remeshes. Physics bodies/shapes exist only inside the collision radius; distant visual chunks do not register empty bodies with the physics server. - Persistence: `world/world_storage.gd` stores versioned metadata and deterministic sparse edits in ZSTD region files. Region I/O and edit hydration stay on the main thread; workers receive immutable per-chunk snapshots. The clean-region cache is capped at 16, dirty regions cannot be evicted, unloaded disk-backed edit buckets are released, and temp/backup replacement preserves a valid recovery backup. Save metadata includes player/inventory/slot/spawn/time/moon/weather; `Main` autosaves every 30 seconds and flushes on exit/new world. - Spawning: `TerrainGenerator.find_spawn_position()` only knows terrain height, so `VoxelWorld.find_safe_spawn()` searches outward in the generated chunk data for a column whose topmost block is solid (not leaves) with two air cells above; `Main` calls it after `setup_player()`. It depends on which chunk jobs have committed, so the result varies run to run — pin `GameConfig.world["seed"]` and use `get_spawn_position()` (deterministic) for reproducible captures. -- `terrain_generator.gd`: FastNoiseLite climate biomes, caves, data-driven ores, and tree/structure stamping. Deterministic per seed, no mutable state after `configure()`. `WorldGenConfig.CURRENT_VERSION` is 13: v8 keeps legacy cave classification, v9 adds dripstone caves/highland boulders, v10 preserves the original default-off spline terrain/elevated hydrology experiments, v11 adds climate variants, 2D spline profiles, routed elevated hydrology, organic cave regions, large trees, improved lava basins, region POIs, and soft ceiling behavior, v12 replaces the frequent artificial cobblestone outcrop props with low stone pebbles, and v13 removes rigid fallen-log/driftwood decorations. `generate_data(pos, edits, lod)` returns either full voxel data or compact LOD columns; both modes consume the same authoritative inland-water field. +- `terrain_generator.gd`: FastNoiseLite climate biomes, caves, data-driven ores, and tree/structure stamping. Deterministic per seed, no mutable state after `configure()`. `WorldGenConfig.CURRENT_VERSION` is 14: v8 keeps legacy cave classification, v9 adds dripstone caves/highland boulders, v10 preserves the original default-off spline terrain/elevated hydrology experiments, v11 adds climate variants, 2D spline profiles, routed elevated hydrology, organic cave regions, large trees, improved lava basins, region POIs, and soft ceiling behavior, v12 replaces the frequent artificial cobblestone outcrop props with low stone pebbles, v13 removes rigid fallen-log/driftwood decorations, and v14 generates ores independently of cave density in non-flat worlds (older worlds retain the old gate). `generate_data(pos, edits, lod)` returns either full voxel data or compact LOD columns; both modes consume the same authoritative inland-water field. - `chunk_mesher.gd`: produces one ArrayMesh per chunk, a separate water mesh, and ConcavePolygonShape3D collision (the last two only for full-detail chunks). `build_lod()` instead takes the compact distance columns and emits one top quad per column plus vertical runs of side quads down to the neighbor's top, sampling compact per-edge top arrays (`LodNeighbors`); heights match the full mesh so seams stay closed, and light is full sky. LOD block faces get cheap occlusion instead of a light volume: top faces darken under taller cardinal neighbors, sides darken with depth, and sides below the soil layer use stone. Full-detail chunks receive `NeighborSample.from_lod()` samples for bordering distance chunks and expand them into the light volume on the worker thread. Face shading, ambient occlusion, and voxel light are baked per vertex. Before meshing, a 3x3-chunk block volume is assembled and flood-filled on the worker thread: the generator's per-column heightmap (`GenResult.heights`) seeds a sky-light pass, then a lateral BFS carries it indoors, and an RGB block-light BFS runs when emissive blocks are present. Per-vertex light is packed into `ARRAY_CUSTOM0` (`ARRAY_CUSTOM_RGBA_FLOAT`: block RGB + sky level) and the texture-array layer into `ARRAY_CUSTOM1` (`ARRAY_CUSTOM_R_FLOAT`); `world/block.gdshader` samples `vec3(UV, layer)`, multiplies sky light into albedo, and adds block light as emission. Any custom-attribute mesh needs the matching `Mesh.ARRAY_FORMAT_CUSTOM*` flags in `arrays_to_mesh()`, and `world/block.gdshader` replaces the old StandardMaterial3D. -- `block_registry.gd`: block table `BLOCK_DEFS` rows `[id, name, top, side, bottom, flags]`, packed into a runtime `Texture2DArray` (one 64px layer per texture; per-image tinting, edge fix-up, and mipmaps). `layer_for(block_id, face)` returns the layer the mesher writes per vertex. - - To add a block: append a row using an existing texture filename from `assets/placeholders/zigcraft/default/`, then add it to `Main.HOTBAR` / `INITIAL_INVENTORY` if it should be placeable. A missing texture logs a warning and renders magenta. +- `block_registry.gd`: block table `BLOCK_DEFS` rows `[id, name, top, side, bottom, flags, hardness, preferred_tool, required_tier]`, packed into a runtime `Texture2DArray` (one 64px layer per texture; per-image tinting, edge fix-up, and mipmaps). `layer_for(block_id, face)` returns the layer the mesher writes per vertex. + - To add a block: append a row using an existing texture filename from `assets/placeholders/zigcraft/default/`, then expose it through `CraftingRecipes` or `Main.INITIAL_INVENTORY`. The hotbar now reflects the first ten inventory slots, not a fixed block list. A missing texture logs a warning and renders magenta. - Emissive blocks: add the id to `EMISSIVE_COLORS` (and `FLAG_EMISSIVE` on the def row). The mesher BFS seeds colored light from it; the existing light volume makes new sources work with no other changes. - Cross blocks (torches, plants): `FLAG_CROSS` meshes two intersecting inset quads instead of a cube, samples the cell's light without AO, and adds no collision. Torches combine `FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE`. - IDs are stored in `PackedByteArray`, so stay under 256. `TEXTURE_TINTS` applies per-texture tinting (grass, leaves, sand, ...). @@ -60,6 +60,11 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 ## Player / HUD contract +- Game modes: `GameMode.CREATIVE`/`SURVIVAL` are persisted in top-level world metadata, never terrain config or global settings. `GameConfig.pending_game_mode` defaults to Survival for creation; `get_game_mode()` resolves loaded metadata with a Creative fallback for legacy worlds. Main snapshots the mode before restoring Player/inventory. Survival initializes empty but restores earned stacks; Creative uses starter supplies plus the catalog, unlimited placement, instant mining, flight and no survival damage/tool wear. The creation form is the only mode selector; do not add in-session or world-management switches. Future mode changes belong to the unimplemented command console. +- Survival: `Player` owns health/hunger/air and emits `died`, `vitals_changed`, `mined_block(position,id,harvest)`, `block_picked`, and `block_interacted`. `Main` owns inventory consumption/tool wear, physical drops and station interaction, and `SurvivalUI` owns the vitals/death presentation. Death pauses; respawn synchronously validates terrain. C crouches, F5 toggles third person, middle-click picks owned blocks, and mining requires holding LMB. +- Inventory: `game/item_inventory.gd` stores 40 `{id,count,durability}` stacks (empty `{}`), first ten hotbar slots, 64-item caps and single-tool stacks. `CraftingRecipes` commits atomically; `BlockContainers` stores chests and furnace progress. `InventoryOverlay.configure_inventory()` binds the live model; `open_station()` opens a table/chest/furnace. `InventoryTransfer` validates both swap directions, including output-slot rejection. Main session schema v2 migrates old count rows and persists vitals, stacks, containers and `ItemDrops`; never credit mined blocks directly. +- Crafting progression: see `CRAFTING.md`. All logs convert to `BLOCK_PLANKS` (74), which replaces raw logs in wooden recipes. `ItemRegistry.harvest_drop()` owns stone-to-cobble/coal drops. New items 1017-1021 are charcoal, bowl, stew, melon slice and flint. Whole melons place; slices eat. `CraftingRecipes.craft(..., batches=1)` is atomic, `max_craftable()` accounts for freed ingredient slots, and UI availability caching invalidates on inventory/table access changes. `survival_progression_verify.gd` proves all six wood-species paths with real mining/drop/pickup/station calls; do not replace it with tests that directly grant finished crafting ingredients. +- New focused headless verifiers: `player_survival_verify.gd`, `inventory_crafting_verify.gd`, `inventory_ui_verify.gd`, `item_drops_verify.gd`, `gameplay_integration_verify.gd`. - `player/player.gd` talks to `Main` only through signals (`block_broken`, `block_placed`, `status_requested`, `slot_cycled`, `slot_selected`, `pause_requested`), connected in `Main._connect_player()`. Keep that pattern instead of reaching into nodes. - Targeting uses `Player._voxel_raycast()` grid traversal, not collision geometry; it skips water so non-colliding cross plants are selectable, and the entered cell normal becomes the place face. - Controls: WASD, Space jump, double-tap Space toggles fly, Shift sprint, Ctrl fast fly (`fly_boost`), E inventory (`inventory`), M map, ] minimap toggle, N map mode, F1 HUD (`hud_toggle`), F2 screenshot (`screenshot`), P photo camera (`photo_camera`), 1-9/0 and mouse wheel select hotbar slot, LMB mine, RMB place, Esc pause (`ui_cancel`). In the photo camera WASD flies, Space/Shift rise/descend, Ctrl boosts, the wheel zooms, and Esc exits. Every `project.godot` action is rebindable in Settings -> Controls: `GameConfig` stores physical keycodes in `settings["input_bindings"]` (persisted in `settings.cfg`), rewrites the InputMap on change, and reads the defaults from `ProjectSettings` for resets. diff --git a/CRAFTING.md b/CRAFTING.md new file mode 100644 index 0000000..ec8b2f6 --- /dev/null +++ b/CRAFTING.md @@ -0,0 +1,133 @@ +# Survival Crafting Progression + +Survival starts with an empty inventory. Crafting uses recipe buttons rather than +a shaped ingredient grid. Open the inventory with **E**, scroll to the recipe +book, and search by an output or ingredient name. Categories and **Craftable only** +help narrow the list. Requirements show **owned / needed** quantities. + +**Craft 1** makes one batch, not necessarily one item. **Craft Max** makes up to +64 batches that fit the backpack. Failed crafts consume nothing. A larger batch +can sometimes fit when a single batch cannot, because it empties an ingredient +slot. Table recipes require opening a placed crafting table, not merely carrying +one. + +## 1. Logs to Wooden Tools + +Oak, spruce, birch, acacia, jungle, and mangrove logs all work. Each has a recipe +producing the same placeable **Wooden Planks** block; mixed species therefore do +not strand incompatible crafting materials. + +| Recipe | Ingredients | Output | Where | +|---|---|---|---| +| Wooden planks | 1 log of any species | 4 planks | Inventory | +| Sticks | 2 planks | 4 sticks | Inventory | +| Bamboo sticks | 2 bamboo | 1 stick | Inventory | +| Crafting table | 4 planks | 1 table | Inventory | +| Wooden pickaxe | 3 planks + 2 sticks | 1 tool | Table | +| Wooden axe | 3 planks + 2 sticks | 1 tool | Table | +| Wooden shovel | 1 plank + 2 sticks | 1 tool | Table | + +**First milestone: harvest three logs by hand.** Craft them into 12 planks, then +make a table and one batch of sticks. Move the table to a hotbar slot, place it, +and right-click it. Craft a wooden pickaxe. You still have three planks and two +sticks left over. Axes speed up wood harvesting; they are not needed to start. + +## 2. Stone, Storage, and a Furnace + +Mine ordinary stone with the wooden pickaxe. It drops **cobblestone**, not smooth +stone. Breaking it with an unsuitable tool does not award a block. Mining wears +the tool only when a block actually breaks. + +| Recipe | Ingredients | Output | Where | +|---|---|---|---| +| Stone pickaxe or axe | 3 cobblestone + 2 sticks | 1 tool | Table | +| Stone shovel | 1 cobblestone + 2 sticks | 1 tool | Table | +| Furnace | 8 cobblestone | 1 furnace | Table | +| Chest | 8 planks | 1 chest, 27 slots | Table | +| Crushed deepstone | 1 deepstone | 1 cobblestone | Inventory | + +**Second milestone: mine 11 stone blocks** for a stone pickaxe and furnace. +Keep gathering wood for replacement tools, fuel, storage, and shelter. + +## 3. Fuel and Light Without Finding Coal + +Place and open the furnace. Move material into **Input**, combustible material +into **Fuel**, and collect the **Output**. Output slots cannot accept items. +Furnaces run while playing; close the inventory to let time advance. Every item +takes ten seconds. Partial progress and remaining fuel survive saving/loading. + +| Fuel | Burn Time | Smelts per Fresh Fuel Item | +|---|---|---| +| Coal or charcoal | 80 seconds | 8 | +| Any log, plank, or wooden tool | 15 seconds | 1.5 | +| Stick | 5 seconds | 0.5 | + +Smelt a log of **any species** into charcoal using another log or planks as fuel. +One coal **or** charcoal plus one stick makes **four torches** in the inventory. +Wooden pickaxes can also harvest coal ore, which drops coal directly. + +Fuel time is shared across successive smelts. An already-lit furnace can burn +remaining fuel while idle, so load the next input before leaving it running. + +## 4. Iron and Further Materials + +Use a **stone pickaxe** to harvest iron ore, then smelt it into iron ingots. +At the crafting table, iron pickaxes/axes cost three ingots and two sticks; +an iron shovel costs one ingot and two sticks. Iron pickaxes can harvest gold ore. +Gold can be smelted and stored, but gold equipment is not implemented. + +| Smelting Input | Output | +|---|---| +| Iron ore | Iron ingot | +| Gold ore | Gold ingot | +| Any log | Charcoal | +| Sand or red sand | Glass | +| Cobblestone | Smooth stone | +| Clay | Terracotta | +| Kelp | Dried kelp | + +Knap one gravel into one flint in the inventory. One flint and one iron ingot +make flint-and-steel at a table. These recipes do not rely on random gravel drops. + +## 5. Foraged Food + +| Recipe | Ingredients | Output / Use | +|---|---|---| +| Melon slices | 1 melon block | 4 slices; each restores 2 hunger | +| Melon block | 4 slices | 1 placeable melon; no net item gain | +| Bowls | 3 planks | 4 bowls | +| Mushroom stew | 1 bowl + 1 brown mushroom + 1 red mushroom | Restores 6 hunger; returns the bowl | +| Dried kelp | Smelt 1 kelp | Restores 1 hunger | + +These crafting recipes work in the inventory without a table. Select prepared +food and right-click to eat. Whole melons are building blocks, not directly +edible items. Eating at full hunger consumes nothing. A returned bowl goes into +the backpack, or becomes a physical drop if there is no room. + +Foraging is **not renewable farming**: local melons, mushrooms, and kelp do not +regrow yet. Meat items exist in Creative, but Survival has no animal source. +Farming, renewable food, animals, and further equipment are future work. + +## World and Save Rules + +- Normal and Amplified worlds support the ore progression above. +- New worldgen version 14 generates ores even when cave density is zero. +- Existing version 13 and older worlds retain their generation behavior; + changing recipes does not regenerate their terrain. +- Flat worlds have no generated trees or ores and are resource-limited. + The creation form warns about this when selecting Flat Survival. +- Existing block and item IDs are unchanged. New content is appended, so stored + logs, tools, inventories, and containers remain usable. +- Creative still has the free catalog and unlimited building. These recipes do + not provide a way to change a world's game mode. + +## Regression Coverage + +`tools/survival_progression_verify.gd` repeats the complete path for all six log +species from empty inventory through iron tools and gold harvesting. It uses +timed Player mining, Main's drop/tool-wear handling, physical pickup, placed +stations, UI crafting, and mid-smelt save restoration. It also checks table +gates, incorrect-tool harvesting, prepared foods, and full-inventory bowl returns. + +Model atomicity, recipe filtering, keyboard crafting, and capacity behavior are +covered by `inventory_crafting_verify.gd` and `inventory_ui_verify.gd`. diff --git a/ROADMAP.md b/ROADMAP.md index 8b3f47c..78381d1 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -24,8 +24,8 @@ Property names and file references are included so each item is easy to find. - [x] Compact HUD — hotbar slots 76 -> 48 px with smaller icons, key hints, and counts, coords/stats chips reduced to ~150 px through the shared compact chip style, and status/selection labels moved above the smaller hotbar - [x] E opens the inventory — `inventory` action rebound from I; main-menu footer and docs updated - [ ] Loading and world-creation progress — show generation/streaming progress between menu and gameplay; high render distances still need feedback while the refreshed RD 16/32 authoritative-stream measurements are collected -- [ ] Chat / command console — `/time`, `/weather`, `/tp`, `/give`, `/seed`, and `/help` for testing and moderation; reuses `DayNightCycle.set_time()`, `WeatherSystem.toggle()`, and `GameConfig.world` -- [ ] Death and respawn flow — prompts respawn at `Player.spawn_position` or quit to menu; pairs with the survival layer in Gameplay +- [ ] Chat / command console — `/time`, `/weather`, `/tp`, `/give`, `/seed`, `/gamemode`, and `/help` for testing and moderation; reuses `DayNightCycle.set_time()`, `WeatherSystem.toggle()`, and `GameConfig.world`. Future mode changes must be command-only; there is currently no way to switch an existing world's mode in-game. +- [x] Death and respawn flow — `SurvivalUI` presents a focused respawn/menu modal; respawn streams and validates `Player.spawn_position` before resuming. Saved deaths reopen the prompt. - [x] Photo mode and HUD hiding — F1 hides the HUD, F2 saves a HUD-free PNG to `user://screenshots/` (every CanvasLayer, so modals cannot leak into the frame), and P swaps in a detached free camera (`game/photo_mode.gd`, built on the `game/shadow_capture.gd` precedent: opt-in node added by `Main`, own key handling, `status_requested` feedback, `user://` output). The free camera inherits the player camera pose/FOV, flies with WASD/SPACE/SHIFT/CTRL, zooms with the wheel, and P/Esc return to the player. While active, `Main` follows it with chunk streaming (entry streams around the camera asynchronously; exit sync-commits the player's 3x3 ring before unfreezing so a long flight cannot drop the player), rain/cover, underwater grading, and the coords readout, and `Player.set_photo_mode()` freezes input and hides the targeting highlight and held block; entering photo mode hides the HUD, minimap, and F3 worldgen overlay, and exiting restores the pre-entry HUD state (an F1 press inside photo mode persists). `tools/photo_mode_verify.gd` covers HUD state, camera handoff, look/movement, player artifact and UI-layer hiding, and screenshot naming; `_get_shadow_capture_state()` records HUD/camera mode - [x] Minimap and world map overlay — `ui/minimap.gd` adds an optional corner map (`]` toggles, off by default, 64 px detail over a 256-block span) and `ui/map_overlay.gd` a full-screen modal atlas (`M`) that zooms with the wheel or `+`/`-`, pans with drag or arrow keys, and resamples its worker raster against the settled view (128 px detail, 128–1536-block view), sharing view math in `ui/map_view.gd`. Both cycle the nine `WorldgenOverlay` modes with N, sample asynchronously through `VoxelWorld.request_debug_map()` — whose detail cap was raised, with the final-height family downshifting automatically because it resolves the erosion graph per pixel — and the minimap rebuilds on open, mode change, and a 64-block recenter. North-up compass and span captions; footer, pause menu, and the startup toast document the keys; `tools/map_verify.gd` pins mode cycling, view projection, zoom/pan limits, payload packing, and the overlay's pause ownership - [x] Frame pacing options — Display settings gained V-Sync (Off/On/Adaptive/Mailbox), an FPS cap (Unlimited/30-240, stored as the real frame rate), and dynamic resolution: `GameConfig.dynamic_resolution_scale()` is a pure stepping policy (50% floor, 5% steps, 1.05x down / 0.85x up margins) whose budget is the slower of the FPS target and any hard cap (FPS cap or vsync refresh) so capped frames are not read as GPU load. `Main._update_frame_pacing()` smooths frame time and applies one step per 0.5 s to `Viewport.scaling_3d_scale`, never above the configured Render Scale, switching to the FSR2 upscale path whenever the scale is sub-native; the target row hides while the toggle is off, and out-of-table persisted values snap to the nearest option. V-Sync/FPS apply at load and on change through `GameConfig.apply_frame_pacing()`; all four keys persist in `settings.cfg`. Live startup-streaming trace stepped 0.77 -> 0.67 and recovered to 0.77 once frames were cheap; `tools/frame_pacing_verify.gd` pins defaults, key application, option value mapping, and policy steps @@ -75,12 +75,17 @@ Property names and file references are included so each item is easy to find. - [x] World management UI — the Play hub lists every saved world and supports create/load, rename, independent duplication, deletion with confirmation, and timestamped manual backups under `user://world_backups`; Gameplay settings provide Off/30 s/1/2/5/10 min autosave intervals while leave/quit saves remain unconditional - [x] Versioned save format and migration — metadata and region files carry independent magic/version headers, metadata freezes chunk/world/region layout, the region v1 reader migrates legacy edit records into the current v2 palette/RLE representation on rewrite, and gameplay state now has an explicit schema revision with a v0 migration. Unsupported future or malformed data is rejected, writes use temp/backup replacement, corrupt primaries recover without discarding the only valid backup, and an unreadable region rejects edits to that region without blocking unrelated regions or session state. The clean-region cache is bounded to 16 entries and dirty regions are never evicted - [x] Persist time, weather, inventory, and player state — versioned save state includes validated player position/flying/spawn, inventory, selected slot, day/night time, moon phase, weather target and rain-transition progress; missing legacy inventory retains defaults and restored weather grading/audio is applied immediately -- [ ] Survival layer — health/hunger/damage with respawn and damage sources (fall, drowning, lava/fire); `Player` has no health, falling below `FALL_RESET_Y` just teleports -- [ ] Tools, hardness, and durability — per-block hardness/break time and tool tiers in `BlockRegistry.BLOCK_DEFS`, tool stacks with durability; mining is instant today (`Player._break_target()` -> `VoxelWorld.break_block()`) -- [ ] Item inventory and crafting — stack grid with drag/drop beyond the fixed `Main.HOTBAR`, crafting recipes/table, furnaces, and chests; `Main.inventory` is only an id->count dictionary rendered by `InventoryOverlay` -- [ ] Block break feedback — progress cracks, hit particles, and a held-item swing animation; breaks resolve instantly with no visual feedback today -- [ ] Block and item drops — physical drop entities with a pickup animation instead of `Main.collect_block()` crediting instantly -- [ ] Player verbs — sneak/crouch (edge protection), third-person camera toggle, and middle-click block pick; none have input actions in `project.godot` +- [x] Creative and Survival modes — selected only in New World, defaulting to Survival. `WorldStorage` persists `game_mode` separately from terrain configuration; loading, duplication and backup preserve it. Legacy saves without a mode load as Creative. Survival starts with an empty inventory, keeps earned items on reload, and disables flight and inventory time/weather cheats. Creative supplies a free item catalog, unlimited placement, instant mining, flight and damage immunity without hunger or tool wear. No settings, pause or world-management mode switch exists; `/gamemode` awaits the command console. +- [x] Survival layer — `Player` owns health/hunger/air, food and regeneration, fall/void/drowning/lava/fire/starvation/suffocation damage, persisted vitals and safe respawn. `SurvivalUI` displays vitals and death choices in Survival; sandbox flight and starter supplies are Creative-only. +- [x] Tools, hardness, and durability — `BlockRegistry.BLOCK_DEFS` appends hardness, preferred tool and harvest tier; hold-to-mine uses `break_seconds()`/`can_harvest()`. Wood/stone/iron pickaxes, axes and shovels have individual durability; completed mining and successful flint use wear the selected stack. +- [x] Item inventory and crafting — `ItemInventory` provides 40 bounded stack slots with the first ten as a movable hotbar; `InventoryOverlay` supports atomic drag/drop, half-stack and keyboard transfers. `CraftingRecipes` supplies capacity-safe recipes and table-gated tools; `BlockContainers` owns 27-slot chests and fuel/input/output furnaces. Session schema v2 persists stacks, durability, stations and smelting progress, migrating legacy count inventories with overflow preserved as drops. +- [x] Survival crafting progression — all six log species craft into common placeable planks; planks unlock sticks, tables, wooden tools, storage and bowls. Stone harvesting yields cobblestone for stone tools/furnaces; all logs smelt into charcoal, and charcoal/coal make torches. Prepared foods include melon slices, mushroom stew with bowl return, and dried kelp. The 29-recipe book adds search, categories, craftable filtering, capacity-aware Craft Max, readable missing requirements, and furnace guidance. `CRAFTING.md` documents the complete path; `survival_progression_verify.gd` runs each wood species from empty inventory through physical harvesting/pickup, stations, mid-smelt save restoration and iron tools. Flat Survival warns about missing resources; new v14 terrain retains ores with caves disabled. +- [ ] Renewable Survival resources — farming/food regrowth and sapling/tree renewal; current melons, mushrooms and kelp are finite forage, and meat has no Survival source until animals exist. +- [x] Block break feedback — `PlayerEffects` renders progressive cracks, hit/break debris and held-item swing; mining cancels on target/tool/slot changes, release, modal opening and death. +- [x] Block and item drops — `ItemDrops` scatters physical textured drops with gravity, collision, bob/rotation and collision-aware pickup motion. Capacity-limited pickups preserve overflow and durability; unloaded terrain freezes drops, and save state preserves uncollected stacks. Broken containers release their contents. +- [x] Player verbs — C crouches with ledge protection, F5 toggles a collision-aware third-person camera, and middle-click (or B) picks the targeted block. Survival selects owned blocks only; Creative grants a full stack. Actions are declared in `project.godot`; all three keyboard bindings appear in Controls. + +Gameplay verification: `player_survival_verify.gd`, `inventory_crafting_verify.gd`, `inventory_ui_verify.gd`, `item_drops_verify.gd`, and `gameplay_integration_verify.gd` cover the new contracts. Rendered Forward+ smoke checks exercised inventory, recipe crafting, chest transfers, furnace smelting, camera toggle, effects, death and respawn. Flat-world water generation now stays dry in full/LOD data, preventing spawn-time drowning (`worldgen_water_verify.gd`). - [ ] Functional blocks — stairs, slabs, doors, ladders, signs, and block rotation so builds are not cube-only - [ ] Sleeping and spawn points — a bed that skips the night through `DayNightCycle`, sets the respawn point, and is blocked while raining or threatened - [ ] Mobs and combat — passive and hostile creatures with light/biome/time spawning rules, pathfinding, and melee combat; no entity system exists beyond the player @@ -93,7 +98,7 @@ Property names and file references are included so each item is easy to find. - [ ] Biome and location ambience — cave drips, birdsong, and wind at altitude; the underwater low-pass landed with the underwater ambience pass, and the cold-biome wind bed plus thunder landed with the weather depth item above ## World generation overhaul -Current state: the TerraForged-inspired staged pipeline is live under `world/worldgen/`. It builds immutable padded terrain fields, domain-warped continents and blended profiles, analytic erosion and optional cached hydraulic erosion, river/coast masks, climate-selected biomes, data-driven surfaces, caves/ores, and deterministic cross-chunk decoration/POIs. `TerrainGenerator` remains an immutable worker-safe facade. The world is 192 blocks high with sea level 48. `WorldGenConfig.CURRENT_VERSION` is 13: v8 preserves the old cave classifier, v9 adds dripstone regions and highland boulders, v10 preserves the original default-off spline terrain and elevated hydrology experiments, v11 adds climate variants, 2D spline profiles, routed hydrology, organic cave regions, large trees, improved lava basins, region POIs, and a soft terrain ceiling, v12 removes the frequent raised cobblestone outcrop props from natural biome decoration, and v13 removes the rigid fallen-log/driftwood props. +Current state: the TerraForged-inspired staged pipeline is live under `world/worldgen/`. It builds immutable padded terrain fields, domain-warped continents and blended profiles, analytic erosion and optional cached hydraulic erosion, river/coast masks, climate-selected biomes, data-driven surfaces, caves/ores, and deterministic cross-chunk decoration/POIs. `TerrainGenerator` remains an immutable worker-safe facade. The world is 192 blocks high with sea level 48. `WorldGenConfig.CURRENT_VERSION` is 14: v8 preserves the old cave classifier, v9 adds dripstone regions and highland boulders, v10 preserves the original default-off spline terrain and elevated hydrology experiments, v11 adds climate variants, 2D spline profiles, routed hydrology, organic cave regions, large trees, improved lava basins, region POIs, and a soft terrain ceiling, v12 removes the frequent raised cobblestone outcrop props from natural biome decoration, v13 removes the rigid fallen-log/driftwood props, and v14 enables ores independently of cave density in non-flat worlds while preserving earlier-version terrain. ### Research / decisions (do first) - [x] Choose the world-gen reference model — use a custom RedotCraft pipeline inspired by TerraForged 0.3.x, not a literal port. TerraForged's useful pattern is staged data generation (continent/terrain profiles -> erosion/rivers -> climate/biomes -> voxel fill -> surfaces/caves/decorations), but the archived project depends on an unavailable `Engine` module and Minecraft-specific chunk, registry, structure, and decoration APIs. Reimplement the ideas with `FastNoiseLite`, immutable GDScript data, and Redot's worker pool; do not target seed- or output-compatibility. diff --git a/autoload/game_config.gd b/autoload/game_config.gd index 7363d38..b788db1 100644 --- a/autoload/game_config.gd +++ b/autoload/game_config.gd @@ -62,6 +62,9 @@ const INPUT_ACTION_DEFS := [ {"action": "move_right", "label": "Move Right", "group": "Movement"}, {"action": "jump", "label": "Jump / Double-tap Fly", "group": "Movement"}, {"action": "sprint", "label": "Sprint", "group": "Movement"}, + {"action": "crouch", "label": "Crouch / Protect Edges", "group": "Movement"}, + {"action": "third_person", "label": "Third Person", "group": "Action"}, + {"action": "pick_block", "label": "Pick Block / Middle Mouse", "group": "Action"}, {"action": "fly_up", "label": "Fly Up", "group": "Movement"}, {"action": "fly_down", "label": "Fly Down", "group": "Movement"}, {"action": "fly_boost", "label": "Fly Boost", "group": "Movement"}, @@ -177,6 +180,7 @@ var world: Dictionary = DEFAULT_WORLD.duplicate() var graphics: Dictionary = {} var active_world_id := "" var active_world_metadata: Dictionary = {} +var pending_game_mode: int = GameMode.SURVIVAL func _ready() -> void: @@ -202,8 +206,12 @@ func apply_world(config: Dictionary) -> void: func activate_world(metadata: Dictionary) -> bool: if metadata.is_empty() or typeof(metadata.get("worldgen", null)) != TYPE_DICTIONARY: return false + var mode: Variant = metadata.get("game_mode", GameMode.CREATIVE) + if not GameMode.is_valid(mode) or String(metadata.get("id", "")).is_empty(): + return false active_world_id = String(metadata.get("id", "")) active_world_metadata = metadata.duplicate(true) + active_world_metadata["game_mode"] = int(mode) apply_world(metadata["worldgen"]) return not active_world_id.is_empty() @@ -217,6 +225,12 @@ func has_active_world() -> bool: return not active_world_id.is_empty() +func get_game_mode() -> int: + if has_active_world(): + return int(active_world_metadata.get("game_mode", GameMode.CREATIVE)) + return pending_game_mode + + func get_setting(key: String) -> Variant: return settings.get(key, DEFAULT_SETTINGS.get(key)) diff --git a/game/block_containers.gd b/game/block_containers.gd new file mode 100644 index 0000000..09f6789 --- /dev/null +++ b/game/block_containers.gd @@ -0,0 +1,161 @@ +class_name BlockContainers +extends RefCounted + +signal changed + +const CHEST_SIZE := 27 +const INPUT := 0 +const FUEL := 1 +const OUTPUT := 2 + +## Vector3i -> {block_id, inventory, progress, burn_remaining, recipe_input}. +## Inventory slots are directly accessible; furnace UI must restrict output to +## extraction and use can_insert() before inserting into its other slots. +var containers: Dictionary = {} + + +func ensure_chest(position: Vector3i) -> ItemInventory: + return _ensure(position, BlockRegistry.BLOCK_CHEST, CHEST_SIZE) + + +func ensure_furnace(position: Vector3i) -> ItemInventory: + return _ensure(position, BlockRegistry.BLOCK_FURNACE, 3) + + +func _ensure(position: Vector3i, block_id: int, size: int) -> ItemInventory: + if containers.has(position): + return containers[position].inventory if containers[position].block_id == block_id else null + var inventory := ItemInventory.new(size) + inventory.changed.connect(changed.emit) + containers[position] = {"block_id": block_id, "inventory": inventory, + "progress": 0.0, "burn_remaining": 0.0, "recipe_input": 0} + changed.emit() + return inventory + + +## Queries never create containers, including when chunks load or tick runs. +func get_inventory(position: Vector3i) -> ItemInventory: + return containers[position].inventory if containers.has(position) else null + + +## Returns live furnace state, or {}. Progress and burn_remaining are seconds. +func get_furnace(position: Vector3i) -> Dictionary: + if containers.has(position) and containers[position].block_id == BlockRegistry.BLOCK_FURNACE: + return containers[position] + return {} + + +func can_insert(position: Vector3i, slot: int, item_id: int) -> bool: + var inventory := get_inventory(position) + if inventory == null or slot < 0 or slot >= inventory.slots.size() or not ItemRegistry.is_valid(item_id): + return false + if containers[position].block_id == BlockRegistry.BLOCK_CHEST: + return true + return (slot == INPUT and CraftingRecipes.SMELTING.has(item_id)) or (slot == FUEL and ItemRegistry.fuel_seconds(item_id) > 0.0) + + +## Removes the model and returns its stacks for the caller to drop or retain. +func remove(position: Vector3i) -> Array: + var inventory := get_inventory(position) + if inventory == null: + return [] + var contents := inventory.persistent_state() + inventory.changed.disconnect(changed.emit) + containers.erase(position) + changed.emit() + return contents + + +func tick(delta: float) -> void: + if not is_finite(delta) or delta <= 0.0: + return + var dirty := false + for position in containers: + var furnace: Dictionary = containers[position] + if furnace.block_id != BlockRegistry.BLOCK_FURNACE: + continue + var inventory: ItemInventory = furnace.inventory + var remaining := delta + while remaining > 0.0: + var input_id := int(inventory.slots[INPUT].get("id", 0)) + var output_id := int(CraftingRecipes.SMELTING.get(input_id, 0)) + if furnace.recipe_input != input_id: + furnace.progress = 0.0 + furnace.recipe_input = input_id + dirty = true + var output := inventory.slots[OUTPUT] + var fits := output.is_empty() or (int(output.id) == output_id and int(output.count) < ItemRegistry.stack_limit(output_id)) + if output_id == 0 or not fits: + if furnace.progress != 0.0 or furnace.burn_remaining > 0.0: + dirty = true + furnace.progress = 0.0 + furnace.burn_remaining = maxf(0.0, float(furnace.burn_remaining) - remaining) + break + if furnace.burn_remaining <= 0.0: + var fuel_id := int(inventory.slots[FUEL].get("id", 0)) + var burn := ItemRegistry.fuel_seconds(fuel_id) + if burn <= 0.0: + break + furnace.burn_remaining = burn + inventory.remove_at(FUEL) + var step := minf(remaining, minf(float(furnace.burn_remaining), CraftingRecipes.SMELT_SECONDS - float(furnace.progress))) + furnace.burn_remaining = maxf(0.0, float(furnace.burn_remaining) - step) + furnace.progress += step + remaining -= step + dirty = true + if furnace.progress >= CraftingRecipes.SMELT_SECONDS: + furnace.progress = 0.0 + # Commit both slots before notifying observers. + inventory.slots[INPUT].count -= 1 + if inventory.slots[INPUT].count == 0: + inventory.slots[INPUT] = {} + if output.is_empty(): + inventory.slots[OUTPUT] = {"id": output_id, "count": 1, "durability": 0} + else: + output.count += 1 + inventory.changed.emit() + if dirty: + changed.emit() + + +## JSON-safe, versioned state. No scene nodes or live inventory references. +func persistent_state() -> Dictionary: + var entries: Array = [] + for position: Vector3i in containers: + var entry: Dictionary = containers[position] + entries.append({"position": [position.x, position.y, position.z], + "block_id": entry.block_id, "slots": entry.inventory.persistent_state(), + "progress": entry.progress, "burn_remaining": entry.burn_remaining, + "recipe_input": entry.recipe_input}) + return {"version": 1, "containers": entries} + + +func restore(state: Dictionary) -> void: + if int(state.get("version", 0)) != 1 or not state.get("containers") is Array: + return + for entry in containers.values(): + entry.inventory.changed.disconnect(changed.emit) + containers.clear() + for value in state.containers: + if not value is Dictionary: + continue + var entry: Dictionary = value + var coords = entry.get("position") + if not coords is Array or coords.size() != 3 or not entry.get("slots") is Array: + continue + var block_id := int(entry.get("block_id", 0)) + if block_id != BlockRegistry.BLOCK_CHEST and block_id != BlockRegistry.BLOCK_FURNACE: + continue + var position := Vector3i(int(coords[0]), int(coords[1]), int(coords[2])) + if containers.has(position): + continue + var inventory := _ensure(position, block_id, CHEST_SIZE if block_id == BlockRegistry.BLOCK_CHEST else 3) + inventory.restore(entry.slots) + if block_id == BlockRegistry.BLOCK_FURNACE: + var furnace: Dictionary = containers[position] + var progress := float(entry.get("progress", 0.0)) + var burn := float(entry.get("burn_remaining", 0.0)) + furnace.progress = clampf(progress, 0.0, CraftingRecipes.SMELT_SECONDS) if is_finite(progress) else 0.0 + furnace.burn_remaining = clampf(burn, 0.0, 80.0) if is_finite(burn) else 0.0 + furnace.recipe_input = int(entry.get("recipe_input", 0)) + changed.emit() diff --git a/game/block_containers.gd.uid b/game/block_containers.gd.uid new file mode 100644 index 0000000..a69d4eb --- /dev/null +++ b/game/block_containers.gd.uid @@ -0,0 +1 @@ +uid://buw5esv1lxnuo diff --git a/game/crafting_recipes.gd b/game/crafting_recipes.gd new file mode 100644 index 0000000..b3fc8df --- /dev/null +++ b/game/crafting_recipes.gd @@ -0,0 +1,121 @@ +class_name CraftingRecipes +extends RefCounted + +## Shapeless counts; table-only recipes require the caller's nearby-table check. +## Each definition has id, ingredients (item ID -> count), output, count, table, category. +const B = preload("res://world/block_registry.gd") +const I = preload("res://game/item_registry.gd") +const RECIPES := [ + {"id": "planks_oak", "ingredients": {B.BLOCK_LOG: 1}, "output": B.BLOCK_PLANKS, "count": 4, "table": false, "category": "Basics"}, + {"id": "planks_spruce", "ingredients": {B.BLOCK_SPRUCE_LOG: 1}, "output": B.BLOCK_PLANKS, "count": 4, "table": false, "category": "Basics"}, + {"id": "planks_birch", "ingredients": {B.BLOCK_BIRCH_LOG: 1}, "output": B.BLOCK_PLANKS, "count": 4, "table": false, "category": "Basics"}, + {"id": "planks_acacia", "ingredients": {B.BLOCK_ACACIA_LOG: 1}, "output": B.BLOCK_PLANKS, "count": 4, "table": false, "category": "Basics"}, + {"id": "planks_jungle", "ingredients": {B.BLOCK_JUNGLE_LOG: 1}, "output": B.BLOCK_PLANKS, "count": 4, "table": false, "category": "Basics"}, + {"id": "planks_mangrove", "ingredients": {B.BLOCK_MANGROVE_LOG: 1}, "output": B.BLOCK_PLANKS, "count": 4, "table": false, "category": "Basics"}, + {"id": "sticks", "ingredients": {B.BLOCK_PLANKS: 2}, "output": I.ITEM_STICK, "count": 4, "table": false, "category": "Basics"}, + {"id": "bamboo_sticks", "ingredients": {B.BLOCK_BAMBOO: 2}, "output": I.ITEM_STICK, "count": 1, "table": false, "category": "Basics"}, + {"id": "crafting_table", "ingredients": {B.BLOCK_PLANKS: 4}, "output": B.BLOCK_CRAFTING_TABLE, "count": 1, "table": false, "category": "Basics"}, + {"id": "chest", "ingredients": {B.BLOCK_PLANKS: 8}, "output": B.BLOCK_CHEST, "count": 1, "table": true, "category": "Storage"}, + {"id": "furnace", "ingredients": {B.BLOCK_COBBLESTONE: 8}, "output": B.BLOCK_FURNACE, "count": 1, "table": true, "category": "Basics"}, + {"id": "wood_pickaxe", "ingredients": {B.BLOCK_PLANKS: 3, I.ITEM_STICK: 2}, "output": I.ITEM_WOOD_PICKAXE, "count": 1, "table": true, "category": "Tools"}, + {"id": "wood_axe", "ingredients": {B.BLOCK_PLANKS: 3, I.ITEM_STICK: 2}, "output": I.ITEM_WOOD_AXE, "count": 1, "table": true, "category": "Tools"}, + {"id": "wood_shovel", "ingredients": {B.BLOCK_PLANKS: 1, I.ITEM_STICK: 2}, "output": I.ITEM_WOOD_SHOVEL, "count": 1, "table": true, "category": "Tools"}, + {"id": "stone_pickaxe", "ingredients": {B.BLOCK_COBBLESTONE: 3, I.ITEM_STICK: 2}, "output": I.ITEM_STONE_PICKAXE, "count": 1, "table": true, "category": "Tools"}, + {"id": "stone_axe", "ingredients": {B.BLOCK_COBBLESTONE: 3, I.ITEM_STICK: 2}, "output": I.ITEM_STONE_AXE, "count": 1, "table": true, "category": "Tools"}, + {"id": "stone_shovel", "ingredients": {B.BLOCK_COBBLESTONE: 1, I.ITEM_STICK: 2}, "output": I.ITEM_STONE_SHOVEL, "count": 1, "table": true, "category": "Tools"}, + {"id": "iron_pickaxe", "ingredients": {I.ITEM_IRON_INGOT: 3, I.ITEM_STICK: 2}, "output": I.ITEM_IRON_PICKAXE, "count": 1, "table": true, "category": "Tools"}, + {"id": "iron_axe", "ingredients": {I.ITEM_IRON_INGOT: 3, I.ITEM_STICK: 2}, "output": I.ITEM_IRON_AXE, "count": 1, "table": true, "category": "Tools"}, + {"id": "iron_shovel", "ingredients": {I.ITEM_IRON_INGOT: 1, I.ITEM_STICK: 2}, "output": I.ITEM_IRON_SHOVEL, "count": 1, "table": true, "category": "Tools"}, + {"id": "torches", "ingredients": {I.ITEM_COAL: 1, I.ITEM_STICK: 1}, "output": B.BLOCK_TORCH, "count": 4, "table": false, "category": "Lighting"}, + {"id": "charcoal_torches", "ingredients": {I.ITEM_CHARCOAL: 1, I.ITEM_STICK: 1}, "output": B.BLOCK_TORCH, "count": 4, "table": false, "category": "Lighting"}, + {"id": "flint", "ingredients": {B.BLOCK_GRAVEL: 1}, "output": I.ITEM_FLINT, "count": 1, "table": false, "category": "Basics", "description": "Knap gravel into flint."}, + {"id": "flint_and_steel", "ingredients": {I.ITEM_FLINT: 1, I.ITEM_IRON_INGOT: 1}, "output": I.ITEM_FLINT_AND_STEEL, "count": 1, "table": true, "category": "Tools"}, + {"id": "bowls", "ingredients": {B.BLOCK_PLANKS: 3}, "output": I.ITEM_BOWL, "count": 4, "table": false, "category": "Food"}, + {"id": "mushroom_stew", "ingredients": {I.ITEM_BOWL: 1, B.BLOCK_BROWN_MUSHROOM: 1, B.BLOCK_RED_MUSHROOM: 1}, "output": I.ITEM_MUSHROOM_STEW, "count": 1, "table": false, "category": "Food"}, + {"id": "melon_slices", "ingredients": {B.BLOCK_MELON: 1}, "output": I.ITEM_MELON_SLICE, "count": 4, "table": false, "category": "Food"}, + {"id": "melon", "ingredients": {I.ITEM_MELON_SLICE: 4}, "output": B.BLOCK_MELON, "count": 1, "table": false, "category": "Building"}, + {"id": "deepstone_cobblestone", "ingredients": {B.BLOCK_DEEPSTONE: 1}, "output": B.BLOCK_COBBLESTONE, "count": 1, "table": false, "category": "Building"}, +] + +## Input ID -> output ID. One input and ten seconds produce one output. +const SMELTING := { + B.BLOCK_IRON_ORE: I.ITEM_IRON_INGOT, B.BLOCK_GOLD_ORE: I.ITEM_GOLD_INGOT, + B.BLOCK_COAL_ORE: I.ITEM_COAL, B.BLOCK_COBBLESTONE: B.BLOCK_STONE, + B.BLOCK_LOG: I.ITEM_CHARCOAL, B.BLOCK_SPRUCE_LOG: I.ITEM_CHARCOAL, + B.BLOCK_BIRCH_LOG: I.ITEM_CHARCOAL, B.BLOCK_ACACIA_LOG: I.ITEM_CHARCOAL, + B.BLOCK_JUNGLE_LOG: I.ITEM_CHARCOAL, B.BLOCK_MANGROVE_LOG: I.ITEM_CHARCOAL, + B.BLOCK_SAND: B.BLOCK_GLASS, B.BLOCK_RED_SAND: B.BLOCK_GLASS, + B.BLOCK_CLAY: B.BLOCK_TERRACOTTA, B.BLOCK_KELP: I.ITEM_DRIED_KELP, + I.ITEM_RAW_MEAT: I.ITEM_COOKED_MEAT, +} +const SMELT_SECONDS := 10.0 + + +static func get_recipe(recipe_id: String) -> Dictionary: + for recipe in RECIPES: + if recipe.id == recipe_id: + return recipe + return {} + + +static func craft(inventory: ItemInventory, recipe_id: String, at_table: bool, batches: int = 1) -> bool: + var trial := _craft_trial(inventory, get_recipe(recipe_id), at_table, batches) + if trial == null: + return false + inventory.restore(trial.persistent_state()) + return true + + +static func _craft_trial(inventory: ItemInventory, recipe: Dictionary, at_table: bool, batches: int) -> ItemInventory: + if recipe.is_empty() or batches <= 0 or (recipe.table and not at_table): + return null + for id in recipe.ingredients: + # Divide before multiplying to reject oversized requests without overflow. + if batches > inventory.count_item(id) / int(recipe.ingredients[id]): + return null + var trial := ItemInventory.new(inventory.slots.size()) + trial.restore(inventory.persistent_state()) + for id in recipe.ingredients: + var remaining: int = int(recipe.ingredients[id]) * batches + for index in trial.slots.size(): + if trial.slots[index].get("id", 0) != id or remaining == 0: + continue + var amount := mini(remaining, int(trial.slots[index].count)) + trial.remove_at(index, amount) + remaining -= amount + if trial.add_item(recipe.output, int(recipe.count) * batches) != 0: + return null + return trial + + +static func max_craftable(inventory: ItemInventory, recipe_id: String, at_table: bool) -> int: + var recipe := get_recipe(recipe_id) + if recipe.is_empty() or (recipe.table and not at_table): + return 0 + var limit := 64 + for id in recipe.ingredients: + limit = mini(limit, inventory.count_item(id) / int(recipe.ingredients[id])) + # Capacity is not monotonic: a larger batch may empty an ingredient slot. + for batches in range(limit, 0, -1): + if _craft_trial(inventory, recipe, at_table, batches) != null: + return batches + return 0 + + +static func blocking_reason(inventory: ItemInventory, recipe_id: String, at_table: bool) -> String: + var recipe := get_recipe(recipe_id) + if recipe.is_empty(): + return "Unknown recipe" + if recipe.table and not at_table: + return "Requires a crafting table" + var missing: PackedStringArray = [] + for id in recipe.ingredients: + var needed: int = int(recipe.ingredients[id]) - inventory.count_item(id) + if needed > 0: + var label: String = I.get_item_name(id) if I.is_item(id) else B.BLOCK_DEFS[id][1] + missing.append("%d %s" % [needed, label.to_lower()]) + if not missing.is_empty(): + return "Missing: " + ", ".join(missing) + if _craft_trial(inventory, recipe, at_table, 1) == null: + return "Inventory full: make room for the output" + return "" diff --git a/game/crafting_recipes.gd.uid b/game/crafting_recipes.gd.uid new file mode 100644 index 0000000..499a5fe --- /dev/null +++ b/game/crafting_recipes.gd.uid @@ -0,0 +1 @@ +uid://bqafi8tag62yj diff --git a/game/game_mode.gd b/game/game_mode.gd new file mode 100644 index 0000000..e6b3a51 --- /dev/null +++ b/game/game_mode.gd @@ -0,0 +1,19 @@ +class_name GameMode +extends RefCounted + +const CREATIVE := 0 +const SURVIVAL := 1 + + +static func is_valid(value: Variant) -> bool: + return (typeof(value) == TYPE_INT or typeof(value) == TYPE_FLOAT) \ + and (value == CREATIVE or value == SURVIVAL) + + +static func display_name(mode: int) -> String: + match mode: + CREATIVE: + return "Creative" + SURVIVAL: + return "Survival" + return "Unknown" diff --git a/game/game_mode.gd.uid b/game/game_mode.gd.uid new file mode 100644 index 0000000..39ed0b0 --- /dev/null +++ b/game/game_mode.gd.uid @@ -0,0 +1 @@ +uid://vyqbf7cuogpo diff --git a/game/item_drops.gd b/game/item_drops.gd new file mode 100644 index 0000000..5f94ec3 --- /dev/null +++ b/game/item_drops.gd @@ -0,0 +1,183 @@ +class_name ItemDrops +extends Node3D + +const ACTIVE_DISTANCE := 48.0 +const MAGNET_DISTANCE := 2.4 +const RADIUS := 0.16 +const STEP := 0.08 +const PICKUP_DELAY := 0.6 + +var _world: VoxelWorld +var _player: Player +var _inventory: ItemInventory +var _drops: Array[Dictionary] = [] + + +func setup(world: VoxelWorld, player: Player, inventory: ItemInventory) -> void: + _world = world + _player = player + _inventory = inventory + process_mode = Node.PROCESS_MODE_PAUSABLE + + +## Positions are world-space cube centers; the caller transfers ownership of stack. +func spawn_drop(position: Vector3, stack: Dictionary) -> void: + var id := int(stack.get("id", 0)) + var count := int(stack.get("count", 0)) + if not position.is_finite() or not ItemRegistry.is_valid(id) or count <= 0: + return + var maximum := ItemRegistry.max_durability(id) + var durability := int(stack.get("durability", maximum)) + durability = maximum if durability < 0 else clampi(durability, 0, maximum) + if maximum > 0 and durability == 0: + return + _drops.append({"position": position, + "stack": {"id": id, "count": count, "durability": durability}, + "velocity": Vector3(randf_range(-1.8, 1.8), 3.0, randf_range(-1.8, 1.8)), + "age": 0.0, "phase": randf() * TAU, "visual": null}) + + +func persistent_state() -> Array: + var state: Array = [] + for drop in _drops: + if int(drop.stack.count) <= 0: + continue + var at: Vector3 = drop.position + state.append({"position": [at.x, at.y, at.z], "stack": drop.stack.duplicate(true)}) + return state + + +## Replaces existing drops. Motion is deliberately restarted at rest on load. +func restore(state: Array) -> void: + for drop in _drops: + if is_instance_valid(drop.visual): + drop.visual.queue_free() + _drops.clear() + for entry in state: + if not entry is Dictionary or not entry.get("stack") is Dictionary: + continue + var coordinates: Variant = entry.get("position") + if not coordinates is Array or coordinates.size() != 3: + continue + if not coordinates.all(func(value: Variant) -> bool: return value is float or value is int): + continue + var previous := _drops.size() + spawn_drop(Vector3(coordinates[0], coordinates[1], coordinates[2]), entry.stack) + if _drops.size() > previous: + _drops.back().velocity = Vector3.ZERO + + +func _physics_process(delta: float) -> void: + if _world == null or not is_instance_valid(_player) or _inventory == null \ + or get_tree().paused or delta <= 0.0 or not is_finite(delta): + return + # Cap catch-up work after a stall, not the number or lifetime of stored drops. + var dt := minf(delta, 0.1) + var target := _player.global_position + Vector3.UP * 0.8 + for index in range(_drops.size() - 1, -1, -1): + var drop := _drops[index] + var nearby: bool = drop.position.distance_squared_to(target) <= ACTIVE_DISTANCE * ACTIVE_DISTANCE + if is_instance_valid(drop.visual): + drop.visual.visible = nearby + if not nearby or not _resident(drop.position): + continue + drop.age += dt + var capacity := _capacity(drop.stack) if not _player.dead else 0 + var magnet: bool = capacity > 0 and drop.age >= PICKUP_DELAY \ + and drop.position.distance_to(target) < MAGNET_DISTANCE + if magnet: + var offset: Vector3 = target - Vector3(drop.position) + drop.velocity = offset.normalized() * minf(7.0, offset.length() / dt) + else: + drop.velocity.y = maxf(-24.0, float(drop.velocity.y) - 18.0 * dt) + _move(drop, dt) + if magnet and drop.position.distance_to(target) < 0.22: + var amount := mini(capacity, int(drop.stack.count)) + # Update ownership before add_item emits changed (listeners may save). + drop.stack.count -= amount + drop.stack.count += _inventory.add_item(int(drop.stack.id), amount, int(drop.stack.durability)) + if int(drop.stack.count) == 0: + if is_instance_valid(drop.visual): + drop.visual.queue_free() + _drops.remove_at(index) + continue + if not is_instance_valid(drop.visual): + drop.visual = _make_visual(int(drop.stack.id)) + var visual: Node3D = drop.visual + visual.global_position = drop.position + Vector3.UP * (0.06 + sin(float(drop.age) * 3.0 + float(drop.phase)) * 0.04) + visual.rotation.y = float(drop.age) * 1.6 + float(drop.phase) + + +func _capacity(stack: Dictionary) -> int: + var capacity := 0 + var limit := ItemRegistry.stack_limit(int(stack.id)) + for slot in _inventory.slots: + if slot.is_empty(): + capacity += limit + elif int(slot.id) == int(stack.id) and int(slot.durability) == int(stack.durability): + capacity += maxi(0, limit - int(slot.count)) + return capacity + + +func _resident(at: Vector3) -> bool: + # get_block_world returns air for missing/LOD chunks; never simulate that air. + for x in [-RADIUS, RADIUS]: + for z in [-RADIUS, RADIUS]: + if not _world.is_collision_ready_at(at + Vector3(x, 0, z)): + return false + return true + + +func _blocked(at: Vector3) -> bool: + var low := Vector3i((at - Vector3.ONE * RADIUS).floor()) + var high := Vector3i((at + Vector3.ONE * RADIUS).floor()) + var registry := _world.get_registry() + for x in range(low.x, high.x + 1): + for y in range(low.y, high.y + 1): + for z in range(low.z, high.z + 1): + var id := _world.get_block_world(Vector3i(x, y, z)) + if y < 0 or (id != BlockRegistry.BLOCK_AIR and not registry.is_water_id(id) \ + and not registry.has_flag(id, BlockRegistry.FLAG_CROSS)): + return true + return false + + +func _move(drop: Dictionary, dt: float) -> void: + var motion: Vector3 = drop.velocity * dt + var steps := maxi(1, ceili(motion.length() / STEP)) + var step := motion / float(steps) + for substep in steps: + for axis in [1, 0, 2]: + if step[axis] == 0.0: + continue + var next: Vector3 = drop.position + next[axis] += step[axis] + if not _resident(next): + return + if _blocked(next): + if axis == 1 and step.y < 0.0: + drop.velocity.x = move_toward(float(drop.velocity.x), 0.0, 12.0 * dt) + drop.velocity.z = move_toward(float(drop.velocity.z), 0.0, 12.0 * dt) + drop.velocity[axis] = 0.0 + step[axis] = 0.0 + else: + drop.position = next + + +func _make_visual(id: int) -> Node3D: + var visual := Node3D.new() + add_child(visual) + if not ItemRegistry.is_item(id): + var cube := MeshInstance3D.new() + cube.mesh = _world.make_block_mesh(id) + cube.scale = Vector3.ONE * 0.24 + visual.add_child(cube) + else: + var texture := ItemRegistry.make_icon(id) + var icon := Sprite3D.new() + icon.texture = texture + icon.pixel_size = 0.34 / float(texture.get_width()) + icon.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + icon.billboard = BaseMaterial3D.BILLBOARD_ENABLED + visual.add_child(icon) + return visual diff --git a/game/item_drops.gd.uid b/game/item_drops.gd.uid new file mode 100644 index 0000000..0e18738 --- /dev/null +++ b/game/item_drops.gd.uid @@ -0,0 +1 @@ +uid://di7g8cn4gomir diff --git a/game/item_inventory.gd b/game/item_inventory.gd new file mode 100644 index 0000000..ff93b67 --- /dev/null +++ b/game/item_inventory.gd @@ -0,0 +1,148 @@ +class_name ItemInventory +extends RefCounted + +signal changed + +const HOTBAR_SIZE := 10 +const DEFAULT_SIZE := 40 +var slots: Array[Dictionary] = [] + + +func _init(size: int = DEFAULT_SIZE) -> void: + slots.resize(maxi(0, size)) + for index in slots.size(): + slots[index] = {} + + +## Returns the number of items that did not fit. Tools never stack. +func add_item(id: int, count: int = 1, durability: int = -1) -> int: + if count <= 0: + return 0 + if not ItemRegistry.is_valid(id): + return count + var maximum := ItemRegistry.max_durability(id) + var health := maximum if durability < 0 else clampi(durability, 0, maximum) + if maximum > 0 and health == 0: + return count + var remaining := count + var limit := ItemRegistry.stack_limit(id) + for slot in slots: + if slot.get("id", 0) == id and int(slot.get("durability", 0)) == health: + var amount := mini(remaining, limit - int(slot.count)) + slot.count += amount + remaining -= amount + for index in slots.size(): + if remaining == 0: + break + if slots[index].is_empty(): + var amount := mini(remaining, limit) + slots[index] = {"id": id, "count": amount, "durability": health} + remaining -= amount + if remaining != count: + changed.emit() + return remaining + + +func remove_at(index: int, count: int = 1) -> bool: + if index < 0 or index >= slots.size() or count <= 0: + return false + if int(slots[index].get("count", 0)) < count: + return false + slots[index].count -= count + if slots[index].count == 0: + slots[index] = {} + changed.emit() + return true + + +## True when a tool was worn, including the use that breaks it. +func wear_tool(index: int) -> bool: + if index < 0 or index >= slots.size() or slots[index].is_empty(): + return false + if ItemRegistry.max_durability(int(slots[index].id)) == 0: + return false + slots[index].durability -= 1 + if slots[index].durability <= 0: + slots[index] = {} + changed.emit() + return true + + +## Split moves the rounded-up half; full moves merge or swap unlike stacks. +func move_stack(from: int, to: int, split: bool = false) -> bool: + if from < 0 or to < 0 or from >= slots.size() or to >= slots.size() or from == to: + return false + var source := slots[from] + var target := slots[to] + if source.is_empty(): + return false + var amount: int = ceili(float(source.count) / 2.0) if split else int(source.count) + if target.is_empty(): + slots[to] = source.duplicate() + slots[to].count = amount + elif target.id == source.id and target.durability == source.durability: + amount = mini(amount, ItemRegistry.stack_limit(int(target.id)) - int(target.count)) + if amount <= 0: + return false + target.count += amount + else: + if split: + return false + slots[from] = target + slots[to] = source + changed.emit() + return true + source.count -= amount + if source.count == 0: + slots[from] = {} + changed.emit() + return true + + +func count_item(id: int) -> int: + var count := 0 + for slot in slots: + if slot.get("id", 0) == id: + count += int(slot.count) + return count + + +func persistent_state() -> Array: + return slots.duplicate(true) + + +## Restores positions, rejects unknown IDs, and clamps malformed stack values. +func restore(state: Array) -> void: + for index in slots.size(): + slots[index] = {} + if index >= state.size() or not state[index] is Dictionary: + continue + var entry: Dictionary = state[index] + var id := int(entry.get("id", 0)) + if not ItemRegistry.is_valid(id): + continue + var count := clampi(int(entry.get("count", 0)), 0, ItemRegistry.stack_limit(id)) + var maximum := ItemRegistry.max_durability(id) + var health := clampi(int(entry.get("durability", maximum)), 0, maximum) + if count > 0 and (maximum == 0 or health > 0): + slots[index] = {"id": id, "count": count, "durability": health} + changed.emit() + + +## Replaces legacy counts; returns overflow so callers can retain excess items. +func migrate_counts(counts: Dictionary) -> Dictionary: + restore([]) + var overflow := {} + var ids: Array[int] = [] + var totals := {} + for key in counts: + var id := int(key) + if not totals.has(id): + ids.append(id) + totals[id] = int(totals.get(id, 0)) + maxi(0, int(counts[key])) + ids.sort() + for id in ids: + var remainder := add_item(id, totals[id]) + if remainder > 0: + overflow[id] = remainder + return overflow diff --git a/game/item_inventory.gd.uid b/game/item_inventory.gd.uid new file mode 100644 index 0000000..09e77d4 --- /dev/null +++ b/game/item_inventory.gd.uid @@ -0,0 +1 @@ +uid://c14dh0fb01mbr diff --git a/game/item_registry.gd b/game/item_registry.gd index d5d6053..501a0ed 100644 --- a/game/item_registry.gd +++ b/game/item_registry.gd @@ -5,26 +5,151 @@ extends RefCounted ## ever entering chunk data while allowing the existing inventory dictionary ## and hotbar to carry blocks and items together. const ITEM_FLINT_AND_STEEL := 1000 +const ITEM_WOOD_PICKAXE := 1001 +const ITEM_WOOD_AXE := 1002 +const ITEM_WOOD_SHOVEL := 1003 +const ITEM_STONE_PICKAXE := 1004 +const ITEM_STONE_AXE := 1005 +const ITEM_STONE_SHOVEL := 1006 +const ITEM_IRON_PICKAXE := 1007 +const ITEM_IRON_AXE := 1008 +const ITEM_IRON_SHOVEL := 1009 +const ITEM_STICK := 1010 +const ITEM_COAL := 1011 +const ITEM_IRON_INGOT := 1012 +const ITEM_RAW_MEAT := 1013 +const ITEM_COOKED_MEAT := 1014 +const ITEM_DRIED_KELP := 1015 +const ITEM_GOLD_INGOT := 1016 +const ITEM_CHARCOAL := 1017 +const ITEM_BOWL := 1018 +const ITEM_MUSHROOM_STEW := 1019 +const ITEM_MELON_SLICE := 1020 +const ITEM_FLINT := 1021 + +const ITEM_NAMES := [ + "FLINT AND STEEL", "WOOD PICKAXE", "WOOD AXE", "WOOD SHOVEL", + "STONE PICKAXE", "STONE AXE", "STONE SHOVEL", + "IRON PICKAXE", "IRON AXE", "IRON SHOVEL", "STICK", "COAL", + "IRON INGOT", "RAW MEAT", "COOKED MEAT", "DRIED KELP", "GOLD INGOT", + "CHARCOAL", "BOWL", "MUSHROOM STEW", "MELON SLICE", "FLINT", +] static var _icon_cache: Dictionary = {} static func is_item(item_id: int) -> bool: - return item_id == ITEM_FLINT_AND_STEEL + return item_id >= ITEM_FLINT_AND_STEEL and item_id <= ITEM_FLINT -static func get_item_name(item_id: int) -> String: +static func is_valid(item_id: int) -> bool: + return is_item(item_id) or (item_id > BlockRegistry.BLOCK_AIR and item_id < BlockRegistry.BLOCK_DEFS.size()) + + +static func tool_kind(item_id: int) -> String: + if item_id < ITEM_WOOD_PICKAXE or item_id > ITEM_IRON_SHOVEL: + return "" + return ["pickaxe", "axe", "shovel"][(item_id - ITEM_WOOD_PICKAXE) % 3] + + +static func tool_tier(item_id: int) -> int: + if tool_kind(item_id).is_empty(): + return 0 + return 1 + (item_id - ITEM_WOOD_PICKAXE) / 3 + + +static func max_durability(item_id: int) -> int: if item_id == ITEM_FLINT_AND_STEEL: - return "FLINT AND STEEL" + return 64 + return [0, 59, 131, 250][tool_tier(item_id)] + + +static func stack_limit(item_id: int) -> int: + if not is_valid(item_id): + return 0 + return 1 if max_durability(item_id) > 0 else 64 + + +static func food_value(item_id: int) -> float: + match item_id: + ITEM_RAW_MEAT: + return 3.0 + ITEM_COOKED_MEAT: + return 8.0 + ITEM_DRIED_KELP: + return 1.0 + ITEM_MUSHROOM_STEW: + return 6.0 + ITEM_MELON_SLICE: + return 2.0 + return 0.0 + + +static func fuel_seconds(item_id: int) -> float: + match item_id: + ITEM_COAL, ITEM_CHARCOAL: + return 80.0 + ITEM_STICK: + return 5.0 + ITEM_WOOD_PICKAXE, ITEM_WOOD_AXE, ITEM_WOOD_SHOVEL, BlockRegistry.BLOCK_PLANKS: + return 15.0 + BlockRegistry.BLOCK_LOG, BlockRegistry.BLOCK_SPRUCE_LOG, BlockRegistry.BLOCK_BIRCH_LOG, BlockRegistry.BLOCK_ACACIA_LOG, BlockRegistry.BLOCK_JUNGLE_LOG, BlockRegistry.BLOCK_MANGROVE_LOG: + return 15.0 + return 0.0 + + +static func harvest_drop(block_id: int) -> Dictionary: + match block_id: + BlockRegistry.BLOCK_STONE: + return {"id": BlockRegistry.BLOCK_COBBLESTONE, "count": 1} + BlockRegistry.BLOCK_COAL_ORE: + return {"id": ITEM_COAL, "count": 1} + return {"id": block_id, "count": 1} + + +static func get_item_name(item_id: int) -> String: + if is_item(item_id): + return ITEM_NAMES[item_id - ITEM_FLINT_AND_STEEL] return "UNKNOWN ITEM" static func make_icon(item_id: int, size: int = 48) -> Texture2D: - if item_id != ITEM_FLINT_AND_STEEL: + if not is_item(item_id): return null var key := "%d@%d" % [item_id, size] if _icon_cache.has(key): return _icon_cache[key] + if item_id != ITEM_FLINT_AND_STEEL: + var glyph := Image.create(16, 16, false, Image.FORMAT_RGBA8) + glyph.fill(Color.TRANSPARENT) + var kind := tool_kind(item_id) + var color := Color("b9c5c9") + if not kind.is_empty() or item_id == ITEM_STICK: + glyph.fill_rect(Rect2i(7, 3, 2, 11), Color("906139")) + if not kind.is_empty(): + color = [Color("906139"), Color("899096"), color][tool_tier(item_id) - 1] + var tool_head := Rect2i(3, 2, 10, 3) + if kind == "axe": + tool_head = Rect2i(3, 2, 6, 6) + elif kind == "shovel": + tool_head = Rect2i(5, 2, 6, 5) + glyph.fill_rect(tool_head, color) + else: + match item_id: + ITEM_COAL, ITEM_CHARCOAL: color = Color("343b42") + ITEM_BOWL: color = Color("906139") + ITEM_MUSHROOM_STEW: color = Color("bc8151") + ITEM_MELON_SLICE: color = Color("dc615c") + ITEM_FLINT: color = Color("59646e") + ITEM_GOLD_INGOT: color = Color("e9bb45") + ITEM_RAW_MEAT: color = Color("cd6464") + ITEM_COOKED_MEAT: color = Color("985b39") + ITEM_DRIED_KELP: color = Color("4c7144") + glyph.fill_rect(Rect2i(3, 5, 10, 7), color.darkened(0.3)) + glyph.fill_rect(Rect2i(4, 4, 8, 6), color) + glyph.resize(size, size, Image.INTERPOLATE_NEAREST) + _icon_cache[key] = ImageTexture.create_from_image(glyph) + return _icon_cache[key] var image := Image.create(size, size, false, Image.FORMAT_RGBA8) image.fill(Color(0, 0, 0, 0)) var scale := float(size) / 48.0 diff --git a/game/main.gd b/game/main.gd index 8a4f529..3400d32 100644 --- a/game/main.gd +++ b/game/main.gd @@ -7,7 +7,6 @@ const WorldgenOverlayScene := preload("res://ui/worldgen_overlay.tscn") const MinimapScene := preload("res://ui/minimap.tscn") const MapOverlayScene := preload("res://ui/map_overlay.tscn") -const HOTBAR: Array[int] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 27, BlockRegistry.BLOCK_TNT, BlockRegistry.BLOCK_NUKE, ItemRegistry.ITEM_FLINT_AND_STEEL] const INITIAL_INVENTORY := {1: 64, 2: 64, 3: 64, 4: 64, 5: 32, 6: 32, 7: 32, 8: 32, 9: 16, 10: 32, 27: 32, BlockRegistry.BLOCK_TNT: 16, BlockRegistry.BLOCK_NUKE: 4, ItemRegistry.ITEM_FLINT_AND_STEEL: 1} const STATS_INTERVAL := 0.25 const NEAR_SHADOW_DISTANCE := 6.0 @@ -22,7 +21,7 @@ const UNDERWATER_DEEP_COLOR := Color(0.02, 0.08, 0.16) const CAVE_FADE_SECONDS := 0.65 const DYNAMIC_RESOLUTION_INTERVAL := 0.5 const DYNAMIC_RESOLUTION_SMOOTHING := 0.2 -const SESSION_STATE_VERSION := 1 +const SESSION_STATE_VERSION := 2 const AUTOSAVE_RETRY_SECONDS := 30.0 @onready var world: VoxelWorld = $World @@ -45,7 +44,12 @@ var slot_key_labels: Array[Label] = [] var slot_count_labels: Array[Label] = [] var selected_slot := 0 var status_time := 6.0 -var inventory: Dictionary = INITIAL_INVENTORY.duplicate() +var inventory := ItemInventory.new() +var containers := BlockContainers.new() +var _drops: ItemDrops +var _survival_ui: SurvivalUI +var _inventory_overflow: Dictionary = {} +var _container_check_time := 0.0 var _pause_menu: PauseMenu var _stats_time := 0.0 var _underwater_amount := 0.0 @@ -72,10 +76,12 @@ var _dynamic_resolution_active := false var _world_storage: WorldStorage var _autosave_time := 0.0 var _storage_warning_shown := false +var _game_mode: int = GameMode.SURVIVAL func _ready() -> void: var saved_state := _prepare_world_storage() + player.game_mode = _game_mode UITheme.apply(_hud_root) _style_hud() _apply_config() @@ -84,6 +90,11 @@ func _ready() -> void: GameConfig.interface_scale_changed.connect(_apply_hud_text_layout) _build_crosshair() _build_pause_menu() + _survival_ui = SurvivalUI.new() + add_child(_survival_ui) + _survival_ui.setup(_hud_root, player) + _survival_ui.respawn_requested.connect(_on_respawn) + _survival_ui.menu_requested.connect(_on_new_world) _connect_player() _inventory_overlay.opened.connect(_on_inventory_opened) _inventory_overlay.closed.connect(_on_inventory_closed) @@ -127,7 +138,17 @@ func _ready() -> void: _weather.ambience_changed.connect(_on_weather_ambience) _inventory_overlay.weather_toggled.connect(_on_weather_toggled) _restore_session_state(saved_state) - player.set_selected_block(HOTBAR[selected_slot]) + _drops = ItemDrops.new() + add_child(_drops) + _drops.setup(world, player, inventory) + if saved_state.get("drops") is Array: + _drops.restore(saved_state.drops) + for item_id in _inventory_overflow: + _drops.spawn_drop(player.global_position, {"id": int(item_id), "count": int(_inventory_overflow[item_id]), "durability": ItemRegistry.max_durability(int(item_id))}) + _inventory_overflow.clear() + inventory.changed.connect(_update_inventory_display) + _inventory_overlay.configure_inventory(inventory, world) + _inventory_overlay.set_game_mode(_game_mode) _update_inventory_display() _show_control_hint() var shadow_capture := ShadowCaptureScript.new() @@ -142,6 +163,8 @@ func _ready() -> void: _photo_mode.status_requested.connect(set_status) _photo_mode.camera_mode_changed.connect(_on_photo_camera_changed) add_child(_photo_mode) + if player.dead: + _on_player_died("Saved expedition") func _get_shadow_capture_state() -> Dictionary: @@ -230,6 +253,11 @@ func _unhandled_input(event: InputEvent) -> void: func _process(delta: float) -> void: if not get_tree().paused: + containers.tick(delta) + _container_check_time -= delta + if _container_check_time <= 0.0: + _container_check_time = 0.5 + _check_removed_containers() _autosave_time += delta var autosave_interval := GameConfig.get_autosave_interval() if autosave_interval > 0.0 and _autosave_time >= autosave_interval: @@ -333,18 +361,20 @@ func _apply_config() -> void: func _prepare_world_storage() -> Dictionary: + _game_mode = GameConfig.get_game_mode() _world_storage = WorldStorage.new() var metadata: Dictionary = {} if GameConfig.has_active_world(): metadata = _world_storage.open_world(GameConfig.active_world_id) if metadata.is_empty(): - metadata = _world_storage.create_world(GameConfig.world) + metadata = _world_storage.create_world(GameConfig.world, {}, "", true, _game_mode) if metadata.is_empty(): push_warning("World storage is unavailable; continuing without persistence") _world_storage = null GameConfig.clear_active_world() return {} GameConfig.activate_world(metadata) + _game_mode = GameConfig.get_game_mode() return _migrate_session_state(metadata.get("state", {})) @@ -356,26 +386,31 @@ func _migrate_session_state(value: Variant) -> Dictionary: if version > SESSION_STATE_VERSION: push_warning("Save uses unsupported session state version %d" % version) return {} - # Version 0 saves used the same fields but did not carry an explicit tag. - state["state_version"] = SESSION_STATE_VERSION + # Keep the source version until inventory restoration has migrated its rows. return state func _restore_session_state(state: Dictionary) -> void: + _inventory_overflow.clear() if state.is_empty(): + _inventory_overflow = inventory.migrate_counts(INITIAL_INVENTORY if _game_mode == GameMode.CREATIVE else {}) return var saved_inventory: Variant = state.get("inventory", null) - if state.has("inventory") and typeof(saved_inventory) == TYPE_ARRAY: - var restored_inventory: Dictionary = {} - for entry in saved_inventory: - if typeof(entry) != TYPE_ARRAY or entry.size() != 2: - continue - var item_id := int(entry[0]) - var count := clampi(int(entry[1]), 0, 9999) - if item_id > 0: - restored_inventory[item_id] = count - inventory = restored_inventory - selected_slot = clampi(int(state.get("selected_slot", 0)), 0, HOTBAR.size() - 1) + if saved_inventory is Array: + if int(state.get("state_version", 0)) >= 2: + inventory.restore(saved_inventory) + else: + var counts := {} + for entry in saved_inventory: + if entry is Array and entry.size() == 2: + var item_id := int(entry[0]) + counts[item_id] = int(counts.get(item_id, 0)) + maxi(int(entry[1]), 0) + _inventory_overflow = inventory.migrate_counts(counts) + else: + _inventory_overflow = inventory.migrate_counts(INITIAL_INVENTORY if _game_mode == GameMode.CREATIVE else {}) + if state.get("containers") is Dictionary: + containers.restore(state.containers) + selected_slot = clampi(int(state.get("selected_slot", 0)), 0, ItemInventory.HOTBAR_SIZE - 1) var day_state: Variant = state.get("day_night", {}) if typeof(day_state) == TYPE_DICTIONARY: _day_night.restore_persistent_state(day_state) @@ -387,17 +422,13 @@ func _restore_session_state(state: Dictionary) -> void: func _build_persistent_state() -> Dictionary: - var inventory_rows: Array = [] - var item_ids: Array[int] = [] - for key in inventory: - item_ids.append(int(key)) - item_ids.sort() - for item_id in item_ids: - inventory_rows.append([item_id, int(inventory[item_id])]) + _check_removed_containers() return { "state_version": SESSION_STATE_VERSION, "player": player.persistent_state(), - "inventory": inventory_rows, + "inventory": inventory.persistent_state(), + "containers": containers.persistent_state(), + "drops": _drops.persistent_state() if is_instance_valid(_drops) else [], "selected_slot": selected_slot, "day_night": _day_night.persistent_state(), "weather": _weather.persistent_state(), @@ -482,7 +513,10 @@ func _apply_graphics() -> void: func _connect_player() -> void: - player.block_broken.connect(_on_block_broken) + player.mined_block.connect(_on_mined_block) + player.block_picked.connect(_on_block_picked) + player.block_interacted.connect(_on_block_interacted) + player.died.connect(_on_player_died) player.block_placed.connect(_on_block_placed) player.item_used.connect(_on_item_used) player.status_requested.connect(set_status) @@ -490,6 +524,8 @@ func _connect_player() -> void: player.slot_selected.connect(select_slot) player.pause_requested.connect(activate_pause) player.can_place_check = can_place_selected + player.interact_check = func(position: Vector3i) -> bool: + return world.get_block_world(position) in [BlockRegistry.BLOCK_CRAFTING_TABLE, BlockRegistry.BLOCK_CHEST, BlockRegistry.BLOCK_FURNACE] func _build_pause_menu() -> void: @@ -605,7 +641,7 @@ func _build_crosshair() -> void: func _build_hotbar() -> void: - var slot_count := HOTBAR.size() + var slot_count := ItemInventory.HOTBAR_SIZE var available_width := get_viewport().get_visible_rect().size.x - 24.0 _hotbar_slot_size = minf(SLOT_WIDTH, floorf( (available_width - SLOT_MARGIN * 2.0 - (slot_count - 1) * SLOT_GAP) / slot_count)) @@ -709,6 +745,8 @@ func _update_slot_styles() -> void: func activate_pause() -> void: + if player.dead: + return if _map_overlay != null and _map_overlay.visible: _map_overlay.close() return @@ -717,25 +755,36 @@ func activate_pause() -> void: func _on_inventory_opened() -> void: + if player.dead or _pause_menu.visible or (_photo_mode != null and _photo_mode.is_camera_active()): + _inventory_overlay.close_panel() + return + player._cancel_mining() if _map_overlay != null and _map_overlay.visible: _map_overlay.close() - _inventory_overlay.show_inventory(inventory, world) _inventory_overlay.set_weather_state(_weather.is_raining()) get_tree().paused = true Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) func _on_inventory_closed() -> void: + if player.dead or _pause_menu.visible: + get_tree().paused = true + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + return get_tree().paused = false Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) func _on_inventory_time_selected(hours: float) -> void: + if _game_mode != GameMode.CREATIVE: + return _day_night.set_time(hours) set_status("Time set to %s" % _day_night.get_clock_text()) func _on_weather_toggled() -> void: + if _game_mode != GameMode.CREATIVE: + return _weather.toggle() _inventory_overlay.set_weather_state(_weather.is_raining()) @@ -833,11 +882,15 @@ func _on_quit_game() -> void: func _on_slot_cycled(direction: int) -> void: - select_slot(wrapi(selected_slot + direction, 0, HOTBAR.size())) + select_slot(wrapi(selected_slot + direction, 0, ItemInventory.HOTBAR_SIZE)) -func _on_block_broken(block_id: int) -> void: - collect_block(block_id) +func _on_mined_block(position: Vector3i, block_id: int, harvest: bool) -> void: + if _game_mode == GameMode.SURVIVAL: + inventory.wear_tool(selected_slot) + _drop_container_contents(position) + if harvest and _game_mode == GameMode.SURVIVAL: + _drops.spawn_drop(Vector3(position) + Vector3.ONE * 0.5, ItemRegistry.harvest_drop(block_id)) set_status("Mined %s" % world.get_block_name(block_id)) @@ -846,7 +899,89 @@ func _on_block_placed(block_id: int) -> void: set_status("Placed %s" % world.get_block_name(block_id)) +func _on_block_picked(block_id: int) -> void: + if not ItemRegistry.is_valid(block_id): + return + if _game_mode == GameMode.CREATIVE: + inventory.slots[selected_slot] = {"id": block_id, "count": ItemRegistry.stack_limit(block_id), + "durability": ItemRegistry.max_durability(block_id)} + inventory.changed.emit() + _update_inventory_display() + return + for index in inventory.slots.size(): + if int(inventory.slots[index].get("id", 0)) != block_id: + continue + if index < ItemInventory.HOTBAR_SIZE: + select_slot(index) + else: + inventory.move_stack(index, selected_slot) + return + set_status("You do not own %s" % _item_name(block_id)) + + +func _on_block_interacted(position: Vector3i) -> void: + if not player.dead: + _inventory_overlay.open_station(position, world.get_block_world(position), containers) + + +func _drop_container_contents(position: Vector3i) -> void: + if _game_mode == GameMode.CREATIVE: + containers.remove(position) + return + if not is_instance_valid(_drops): + return + for stack in containers.remove(position): + if not stack.is_empty(): + _drops.spawn_drop(Vector3(position) + Vector3.ONE * 0.5, stack) + + +func _check_removed_containers() -> void: + if world == null or not is_instance_valid(_drops): + return + for position: Vector3i in containers.containers.keys(): + # get_block_world returns air for unloaded/LOD chunks, not actual absence. + var chunk := world._loaded_chunk_for(position) + if chunk == null or chunk.lod: + continue + if world.get_block_world(position) != int(containers.containers[position].block_id): + _drop_container_contents(position) + + +func _on_player_died(cause: String) -> void: + if _photo_mode != null and _photo_mode.is_camera_active(): + _photo_mode.set_free_camera(false) + if _inventory_overlay.visible: + _inventory_overlay.close_panel() + if _map_overlay != null and _map_overlay.visible: + _map_overlay.close() + if _pause_menu.visible: + _pause_menu.close_menu() + _survival_ui.show_death(cause) + get_tree().paused = true + Input.set_mouse_mode(Input.MOUSE_MODE_VISIBLE) + + +func _on_respawn() -> void: + player.respawn() + _survival_ui.hide_death() + get_tree().paused = false + Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) + _update_inventory_display() + _flush_world_save() + + func _on_item_used(item_id: int, block_position: Vector3i) -> void: + if player.dead or not can_place_selected() or int(inventory.slots[selected_slot].get("id", 0)) != item_id: + return + var food := ItemRegistry.food_value(item_id) + if food > 0.0: + if player.eat(food): + inventory.remove_at(selected_slot) + if item_id == ItemRegistry.ITEM_MUSHROOM_STEW: + var overflow := inventory.add_item(ItemRegistry.ITEM_BOWL) + if overflow > 0: + _drops.spawn_drop(player.global_position, {"id": ItemRegistry.ITEM_BOWL, "count": overflow}) + return if item_id != ItemRegistry.ITEM_FLINT_AND_STEEL: return var result := world.use_flint_and_steel(block_position, player.target_normal) @@ -856,6 +991,9 @@ func _on_item_used(item_id: int, block_position: Vector3i) -> void: if result.has("blocked"): set_status("Too wet to light" if result["blocked"] == "wet" else "Already burning") return + if _game_mode == GameMode.SURVIVAL: + inventory.wear_tool(selected_slot) + _check_removed_containers() if int(result.get("ignited", 0)) > 0: AudioManager.play_block_place(BlockRegistry.BLOCK_FIRE, Vector3(block_position) + Vector3(0.5, 0.5, 0.5)) set_status("Lit fire") @@ -865,49 +1003,49 @@ func _on_item_used(item_id: int, block_position: Vector3i) -> void: func select_slot(index: int) -> void: - selected_slot = clampi(index, 0, HOTBAR.size() - 1) + if selected_slot != clampi(index, 0, ItemInventory.HOTBAR_SIZE - 1) and player != null: + player._cancel_mining() + selected_slot = clampi(index, 0, ItemInventory.HOTBAR_SIZE - 1) _update_slot_styles() - Motion.pulse(slot_panels[selected_slot]) - if player: - player.set_selected_block(HOTBAR[selected_slot]) - if _selection_chip != null and world != null: - _selection_chip.text = "%s · ×%d" % [ - _item_name(HOTBAR[selected_slot]).to_lower(), - inventory.get(HOTBAR[selected_slot], 0)] - set_status("Selected %s (%d)" % [_item_name(HOTBAR[selected_slot]), inventory.get(HOTBAR[selected_slot], 0)]) + if selected_slot < slot_panels.size(): + Motion.pulse(slot_panels[selected_slot]) + _update_inventory_display() func can_place_selected() -> bool: - return inventory.get(HOTBAR[selected_slot], 0) > 0 - - -func collect_block(block_id: int) -> void: - inventory[block_id] = inventory.get(block_id, 0) + 1 - _update_inventory_display() + return int(inventory.slots[selected_slot].get("count", 0)) > 0 func consume_selected_block() -> void: - var block_id: int = HOTBAR[selected_slot] - inventory[block_id] = maxi(inventory.get(block_id, 0) - 1, 0) - _update_inventory_display() + if _game_mode == GameMode.SURVIVAL: + inventory.remove_at(selected_slot) func _update_inventory_display() -> void: var registry := world.get_registry() for index in slot_icons.size(): - var item_id: int = HOTBAR[index] - if ItemRegistry.is_item(item_id): + var stack := inventory.slots[index] + var item_id := int(stack.get("id", 0)) + if item_id == 0: + slot_icons[index].texture = null + elif ItemRegistry.is_item(item_id): slot_icons[index].texture = ItemRegistry.make_icon(item_id, int(_hotbar_icon_size)) elif registry != null: slot_icons[index].texture = BlockIcon.make_icon(registry, item_id, int(_hotbar_icon_size)) - slot_count_labels[index].text = "×%d" % inventory.get(item_id, 0) + slot_count_labels[index].text = str(stack.get("count", "")) + if ItemRegistry.max_durability(item_id) > 0: + slot_count_labels[index].text = "%d/%d" % [int(stack.get("durability", 0)), ItemRegistry.max_durability(item_id)] + var selected := inventory.slots[selected_slot] + var selected_id := int(selected.get("id", 0)) + player.set_selected_block(selected_id) if _selection_chip != null: _selection_chip.text = "%s · ×%d" % [ - _item_name(HOTBAR[selected_slot]).to_lower(), - inventory.get(HOTBAR[selected_slot], 0)] + _item_name(selected_id).to_lower(), selected.get("count", 0)] func _item_name(item_id: int) -> String: + if item_id == 0: + return "Empty hand" if ItemRegistry.is_item(item_id): return ItemRegistry.get_item_name(item_id) return world.get_block_name(item_id) @@ -922,9 +1060,10 @@ func set_status(message: String) -> void: ## Startup/rebind toast for the core controls; called again when the pause ## menu's Settings close so a mid-game remap is reflected. func _show_control_hint() -> void: - set_status("%s move double-tap %s to fly %s inventory %s map %s minimap ESC pause" % [ + var jump_hint := "double-tap %s to fly" if _game_mode == GameMode.CREATIVE else "%s jump" + set_status("%s move %s %s inventory %s map %s minimap ESC pause" % [ GameConfig.input_move_hint(), - GameConfig.input_key("jump"), + jump_hint % GameConfig.input_key("jump"), GameConfig.input_key("inventory"), GameConfig.input_key("map_overlay"), GameConfig.input_key("minimap"), diff --git a/game/survival_ui.gd b/game/survival_ui.gd new file mode 100644 index 0000000..da2fb8f --- /dev/null +++ b/game/survival_ui.gd @@ -0,0 +1,94 @@ +class_name SurvivalUI +extends CanvasLayer + +signal respawn_requested +signal menu_requested + +var _vitals: Label +var _death: Control +var _cause: Label +var _respawn: Button +var _player: Player +var _previous_focus: Control + + +func setup(hud: Control, player: Player) -> void: + process_mode = Node.PROCESS_MODE_ALWAYS + layer = 30 + _player = player + var vitals_center := CenterContainer.new() + hud.add_child(vitals_center) + vitals_center.set_anchors_and_offsets_preset(Control.PRESET_TOP_WIDE) + vitals_center.offset_top = 16 + vitals_center.mouse_filter = Control.MOUSE_FILTER_IGNORE + _vitals = Label.new() + _vitals.name = "Vitals" + vitals_center.add_child(_vitals) + _vitals.horizontal_alignment = HORIZONTAL_ALIGNMENT_CENTER + _vitals.mouse_filter = Control.MOUSE_FILTER_IGNORE + _vitals.add_theme_stylebox_override("normal", UITheme.chip_style()) + UITheme.apply_font_size(_vitals, 14) + player.vitals_changed.connect(_update_vitals) + _update_vitals() + _death = Control.new() + add_child(_death) + _death.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + UITheme.apply(_death) + var dim := ColorRect.new() + dim.color = Color(UITheme.VOID, 0.85) + _death.add_child(dim) + dim.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var center := CenterContainer.new() + _death.add_child(center) + center.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + var panel := PanelContainer.new() + panel.add_theme_stylebox_override("panel", UITheme.modal_style()) + center.add_child(panel) + var box := VBoxContainer.new() + box.custom_minimum_size.x = 300 + box.add_theme_constant_override("separation", 16) + panel.add_child(box) + var heading := Label.new() + heading.text = "Expedition Ended" + UITheme.style_heading(heading) + box.add_child(heading) + _cause = Label.new() + box.add_child(_cause) + _respawn = Button.new() + _respawn.text = "Respawn" + UITheme.style_button_primary(_respawn) + box.add_child(_respawn) + _respawn.pressed.connect(func() -> void: respawn_requested.emit()) + var menu := Button.new() + menu.text = "Main Menu" + UITheme.style_button_ghost(menu) + box.add_child(menu) + menu.pressed.connect(func() -> void: menu_requested.emit()) + _death.hide() + + +func _update_vitals() -> void: + _vitals.visible = _player.game_mode == GameMode.SURVIVAL + _vitals.text = "Health %d/20 Hunger %d/20 Air %d/10" % [ceili(_player.health), ceili(_player.hunger), ceili(_player.air)] + + +func show_death(cause: String) -> void: + if _player.game_mode != GameMode.SURVIVAL: + return + _previous_focus = get_viewport().gui_get_focus_owner() + _cause.text = cause.capitalize() + _death.show() + _respawn.grab_focus() + + +func hide_death() -> void: + _death.hide() + if is_instance_valid(_previous_focus) and _previous_focus.is_visible_in_tree(): + _previous_focus.grab_focus() + + +func _unhandled_input(event: InputEvent) -> void: + if _death.visible: + if event.is_action_pressed("ui_cancel"): + _respawn.grab_focus() + get_viewport().set_input_as_handled() diff --git a/game/survival_ui.gd.uid b/game/survival_ui.gd.uid new file mode 100644 index 0000000..2b5952b --- /dev/null +++ b/game/survival_ui.gd.uid @@ -0,0 +1 @@ +uid://cbdhf3iux6rcy diff --git a/player/player.gd b/player/player.gd index d46ade8..a330f85 100644 --- a/player/player.gd +++ b/player/player.gd @@ -8,6 +8,11 @@ signal status_requested(message: String) signal slot_cycled(direction: int) signal slot_selected(index: int) signal pause_requested +signal died(cause: String) +signal vitals_changed() +signal block_picked(block_id: int) +signal block_interacted(position: Vector3i) +signal mined_block(position: Vector3i, block_id: int, harvest: bool) const WALK_SPEED := 4.6 const SPRINT_SPEED := 6.8 @@ -25,9 +30,31 @@ const SPRINT_FOV_BOOST := 6.0 const FOV_LERP_SPEED := 8.0 const FOOTSTEP_STRIDE := 1.7 const FOOTSTEP_MIN_SPEED := 0.6 +const MAX_HEALTH := 20.0 +const MAX_HUNGER := 20.0 +const MAX_AIR := 10.0 +const CROUCH_SPEED := 1.8 var world: VoxelWorld +var game_mode: int = GameMode.SURVIVAL var can_place_check: Callable = Callable() +var interact_check: Callable = Callable() +var health := MAX_HEALTH +var hunger := MAX_HUNGER +var air := MAX_AIR +var dead := false +var crouching := false +var third_person := false +var mining_progress := 0.0 +var _mining := false +var _mining_position := Vector3i.ZERO +var _mining_id := 0 +var _mining_tool := 0 +var _survival_clock := 0.0 +var _regen_clock := 0.0 +var _fall_start := NAN +var _head_height := 1.65 +var _effects: PlayerEffects var selected_block := 1 var target_block := Vector3i.ZERO var target_normal := Vector3i.UP @@ -47,6 +74,10 @@ var _held_block: MeshInstance3D func _ready() -> void: camera.current = true + _head_height = head.position.y + _effects = PlayerEffects.new() + add_child(_effects) + _effects.setup(self) spawn_position = global_position apply_settings() Input.set_mouse_mode(Input.MOUSE_MODE_CAPTURED) @@ -70,6 +101,8 @@ func setup_world(world_node: VoxelWorld) -> void: func set_selected_block(block_id: int) -> void: + if selected_block != block_id: + _cancel_mining() selected_block = block_id _update_held_block() @@ -85,6 +118,10 @@ func persistent_state() -> Dictionary: "yaw": rotation.y, "pitch": head.rotation.x if head != null else 0.0, "flying": flying, + "health": health, + "hunger": hunger, + "air": air, + "dead": dead, "spawn": [spawn_position.x, spawn_position.y, spawn_position.z], } @@ -96,13 +133,27 @@ func restore_persistent_state(state: Dictionary) -> bool: var restored := Vector3(float(position_value[0]), float(position_value[1]), float(position_value[2])) if not is_finite(restored.x) or not is_finite(restored.y) or not is_finite(restored.z): return false - if restored.y < 0.0: + if restored.y < 0.0 and (game_mode == GameMode.CREATIVE or ( + not bool(state.get("dead", false)) + and _restore_vital(state.get("health", MAX_HEALTH), MAX_HEALTH) > 0.0)): return false global_position = restored rotation.y = float(state.get("yaw", 0.0)) if head != null: head.rotation.x = clampf(float(state.get("pitch", 0.0)), -pitch_limit, pitch_limit) - flying = bool(state.get("flying", false)) + flying = game_mode == GameMode.CREATIVE and bool(state.get("flying", false)) + health = _restore_vital(state.get("health", MAX_HEALTH), MAX_HEALTH) + hunger = _restore_vital(state.get("hunger", MAX_HUNGER), MAX_HUNGER) + air = _restore_vital(state.get("air", MAX_AIR), MAX_AIR) + dead = bool(state.get("dead", false)) or health <= 0.0 + if game_mode == GameMode.CREATIVE: + health = MAX_HEALTH + hunger = MAX_HUNGER + air = MAX_AIR + dead = false + if dead: + health = 0.0 + _reset_survival_motion() velocity = Vector3.ZERO var spawn_value: Variant = state.get("spawn", []) if typeof(spawn_value) == TYPE_ARRAY and spawn_value.size() == 3: @@ -114,21 +165,89 @@ func restore_persistent_state(state: Dictionary) -> bool: spawn_position = restored else: spawn_position = restored + vitals_changed.emit() return true +func _restore_vital(value: Variant, maximum: float) -> float: + if typeof(value) != TYPE_FLOAT and typeof(value) != TYPE_INT: + return maximum + var number := float(value) + return clampf(number, 0.0, maximum) if is_finite(number) else maximum + + +func take_damage(amount: float, cause: String = "injury") -> void: + if game_mode == GameMode.CREATIVE or dead or not is_finite(amount) or amount <= 0.0: + return + health = maxf(0.0, health - amount) + if health <= 0.0: + dead = true + _reset_survival_motion() + if _highlight != null: + _highlight.hide() + if _held_block != null: + _held_block.hide() + vitals_changed.emit() + if dead: + died.emit(cause) + + +## Returns false when food should not be consumed by the inventory owner. +func eat(amount: float) -> bool: + if game_mode == GameMode.CREATIVE or dead or not is_finite(amount) or amount <= 0.0 or hunger >= MAX_HUNGER: + return false + hunger = minf(MAX_HUNGER, hunger + amount) + vitals_changed.emit() + return true + + +func respawn() -> void: + _reset_survival_motion() + flying = false + crouching = false + global_position = spawn_position + if world != null: + world.setup_player(self, true) + global_position = world.find_safe_spawn(spawn_position) + world.setup_player(self, true) + health = MAX_HEALTH + hunger = MAX_HUNGER + air = MAX_AIR + dead = false + vitals_changed.emit() + + +func _reset_survival_motion() -> void: + velocity = Vector3.ZERO + _fall_start = NAN + _survival_clock = 0.0 + _regen_clock = 0.0 + _last_jump_time = -10.0 + _cancel_mining() + + ## Detached photo-mode camera: freeze player simulation and hide the targeting ## highlight and the first-person held block so the composition cannot be ## disturbed or the hand model caught in the shot. func set_photo_mode(enabled: bool) -> void: + _cancel_mining() process_mode = Node.PROCESS_MODE_DISABLED if enabled else Node.PROCESS_MODE_INHERIT if enabled and _highlight != null: _highlight.visible = false if _held_block != null: - _held_block.visible = not enabled + _held_block.visible = not enabled and not third_person and selected_block != BlockRegistry.BLOCK_AIR func _unhandled_input(event: InputEvent) -> void: + if dead: + return + if event.is_action_pressed("pick_block") and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + _pick_target() + get_viewport().set_input_as_handled() + return + if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: + _cancel_mining() + return if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: var sensitivity := GameConfig.get_mouse_sensitivity() rotation.y -= event.relative.x * sensitivity @@ -166,20 +285,44 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() elif event.is_action_pressed("jump"): _handle_double_tap_jump() + elif event.is_action_pressed("third_person"): + third_person = not third_person + _cancel_mining() func _physics_process(delta: float) -> void: + if dead: + return if global_position.y < FALL_RESET_Y: - global_position = spawn_position - velocity = Vector3.ZERO - _update_target() + if game_mode == GameMode.CREATIVE: + respawn() + else: + take_damage(MAX_HEALTH, "void") + return + crouching = not flying and Input.is_action_pressed("crouch") + head.position.y = _head_height - (0.35 if crouching else 0.0) + _effects.update_view(delta) # Never simulate movement in a chunk whose collision has not committed yet. # Extreme streaming and boosted flight can otherwise outrun the nearest-first # worker queue; a flying player could then descend straight through visible # terrain while the authoritative collision shape was still being built. if world != null and not world.is_collision_ready_at(global_position): velocity = Vector3.ZERO + _fall_start = NAN + _cancel_mining() + has_target = false + if _highlight != null: + _highlight.hide() + return + _update_target() + _update_survival(delta) + if dead: return + if _mining and (not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) or Input.mouse_mode != Input.MOUSE_MODE_CAPTURED): + _cancel_mining() + if not _mining and has_target and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: + _break_target() + _tick_mining(delta) var input_vector := Input.get_vector("move_left", "move_right", "move_forward", "move_backward") var direction := Vector3(input_vector.x, 0.0, input_vector.y).rotated(Vector3.UP, rotation.y) if direction.length_squared() > 1.0: @@ -194,8 +337,8 @@ func _physics_process(delta: float) -> void: velocity.y = move_toward(velocity.y, vertical * fly_speed, fly_accel * delta) velocity.z = move_toward(velocity.z, direction.z * fly_speed, fly_accel * delta) else: - sprinting = Input.is_action_pressed("sprint") and input_vector.length_squared() > 0.01 - var speed := SPRINT_SPEED if sprinting else WALK_SPEED + sprinting = not crouching and hunger > 6.0 and Input.is_action_pressed("sprint") and input_vector.length_squared() > 0.01 + var speed := CROUCH_SPEED if crouching else (SPRINT_SPEED if sprinting else WALK_SPEED) var acceleration := GROUND_ACCEL if is_on_floor() else AIR_ACCEL velocity.x = move_toward(velocity.x, direction.x * speed, acceleration * delta) velocity.z = move_toward(velocity.z, direction.z * speed, acceleration * delta) @@ -205,6 +348,8 @@ func _physics_process(delta: float) -> void: velocity.y = JUMP_VELOCITY else: velocity.y = -0.5 + if crouching and is_on_floor(): + _protect_edge(delta) if world != null: var intended_position := global_position + velocity * delta var loaded_fraction := world.loaded_motion_fraction(global_position, intended_position) @@ -230,7 +375,17 @@ func _physics_process(delta: float) -> void: var constrained_position := global_position + velocity * delta if velocity.y < 0.0 and not world.is_collision_ready_at(constrained_position): velocity.y = 0.0 + var was_grounded := is_on_floor() + if flying or _water_at(global_position + Vector3.UP * 0.1): + _fall_start = NAN + elif not was_grounded: + _fall_start = global_position.y if is_nan(_fall_start) else maxf(_fall_start, global_position.y) move_and_slide() + if not flying and is_on_floor() and not is_nan(_fall_start): + var distance := _fall_start - global_position.y + _fall_start = NAN + if distance > 3.0 and not _water_at(global_position + Vector3.UP * 0.1): + take_damage(floorf(distance - 3.0), "fall") if camera: var target_fov := base_fov + SPRINT_FOV_BOOST if sprinting and direction.length_squared() > 0.0 else base_fov camera.fov = lerpf(camera.fov, target_fov, clampf(delta * FOV_LERP_SPEED, 0.0, 1.0)) @@ -272,7 +427,7 @@ func _update_target() -> void: if world == null: return has_target = false - var result := _voxel_raycast(camera.global_position, -camera.global_transform.basis.z, REACH) + var result := _voxel_raycast(head.global_position, -camera.global_transform.basis.z, REACH) if result.is_empty(): _highlight.visible = false return @@ -327,17 +482,70 @@ func _voxel_raycast(origin: Vector3, direction_value: Vector3, max_distance: flo return {} +func _pick_target() -> void: + if not dead and has_target and world != null: + block_picked.emit(world.get_block_world(target_block)) + + func _break_target() -> void: - if not has_target or world == null: + if dead or not has_target or world == null: return - var removed := world.break_block(target_block) - if removed != BlockRegistry.BLOCK_AIR: - block_broken.emit(removed) - AudioManager.play_block_break(removed, Vector3(target_block) + Vector3(0.5, 0.5, 0.5)) + _mining = true + _mining_position = target_block + _mining_id = world.get_block_world(target_block) + _mining_tool = selected_block + mining_progress = 0.0 + if game_mode == GameMode.CREATIVE: + _tick_mining(0.0) + + +func _cancel_mining() -> void: + _mining = false + mining_progress = 0.0 + if _effects != null: + _effects.hide_cracks() + + +func _tick_mining(delta: float) -> void: + if not _mining or dead or world == null: + return + if not has_target or target_block != _mining_position or selected_block != _mining_tool \ + or world.get_block_world(target_block) != _mining_id: + _cancel_mining() + return + var seconds := BlockRegistry.break_seconds(_mining_id, _mining_tool) + if seconds < 0.0 or not is_finite(seconds): + _cancel_mining() + return + mining_progress = 1.0 if game_mode == GameMode.CREATIVE else minf(1.0, mining_progress + delta / maxf(0.05, seconds)) + if _effects != null: + _effects.show_cracks(_mining_position, mining_progress) + if mining_progress < 1.0: + return + var position_mined := _mining_position + var harvest := BlockRegistry.can_harvest(_mining_id, _mining_tool) + _cancel_mining() + var removed := world.break_block(position_mined) + if removed == BlockRegistry.BLOCK_AIR: + return + if _effects != null: + _effects.burst(position_mined) + block_broken.emit(removed) + mined_block.emit(position_mined, removed, harvest) + AudioManager.play_block_break(removed, Vector3(position_mined) + Vector3(0.5, 0.5, 0.5)) func _place_target() -> void: - if not has_target or world == null: + if dead or world == null: + return + _cancel_mining() + if has_target and interact_check.is_valid() and interact_check.call(target_block): + block_interacted.emit(target_block) + return + if ItemRegistry.food_value(selected_block) > 0.0: + item_used.emit(selected_block, target_block if has_target else Vector3i.ZERO) + return + if not has_target: return if can_place_check.is_valid() and not can_place_check.call(): status_requested.emit("No %s left" % _selected_name()) @@ -354,10 +562,13 @@ func _place_target() -> void: func _handle_double_tap_jump() -> void: + if game_mode != GameMode.CREATIVE or dead: + return var now := Time.get_ticks_msec() / 1000.0 if now - _last_jump_time <= DOUBLE_TAP_TIME: flying = not flying velocity.y = 0.0 + _fall_start = NAN _last_jump_time = -10.0 status_requested.emit("Flying enabled" if flying else "Flying disabled") else: @@ -392,9 +603,14 @@ func _create_held_block() -> void: func _update_held_block() -> void: if _held_block and world: - _held_block.visible = not ItemRegistry.is_item(selected_block) + _held_block.visible = not third_person and selected_block != BlockRegistry.BLOCK_AIR if ItemRegistry.is_item(selected_block): - _held_block.mesh = null + var tool_mesh := BoxMesh.new() + tool_mesh.size = Vector3(0.18, 1.2, 0.18) + _held_block.mesh = tool_mesh + var tool_material := StandardMaterial3D.new() + tool_material.albedo_color = Color(0.55, 0.36, 0.19) + _held_block.material_override = tool_material return _held_block.mesh = world.make_block_mesh(selected_block) _held_block.material_override = world.get_blocks_material() @@ -410,3 +626,77 @@ func _player_occupies(block_position: Vector3i) -> bool: var block_box := AABB(Vector3(block_position), Vector3.ONE) var player_box := AABB(global_position + Vector3(-0.34, 0.05, -0.34), Vector3(0.68, 1.85, 0.68)) return block_box.intersects(player_box) + + +func _head_in_water() -> bool: + return head != null and _water_at(head.global_position) + + +func _water_at(at: Vector3) -> bool: + if world == null: + return false + var cell := Vector3i(at.floor()) + var id := world.get_block_world(cell) + var registry := world.get_registry() + if registry != null and registry.has_flag(id, BlockRegistry.FLAG_CROSS): + id = world.get_block_world(cell + Vector3i.UP) + return id == BlockRegistry.BLOCK_WATER or (id >= BlockRegistry.BLOCK_WATER_FLOW_7 and id <= BlockRegistry.BLOCK_WATER_FLOW_1) + + +func _update_survival(delta: float) -> void: + if game_mode == GameMode.CREATIVE or dead or world == null or world.get_registry() == null: + return + var previous := Vector3(health, hunger, air) + var moving := Vector2(velocity.x, velocity.z).length() > 0.6 + var draining := 0.004 + if moving and not flying: + draining = 0.06 if Input.is_action_pressed("sprint") and not crouching else 0.02 + hunger = maxf(0.0, hunger - delta * draining) + var submerged := _head_in_water() + air = maxf(0.0, air - delta) if submerged else minf(MAX_AIR, air + delta * 4.0) + _survival_clock += delta + while _survival_clock >= 1.0 and not dead: + _survival_clock -= 1.0 + var feet_id := world.get_block_world(Vector3i((global_position + Vector3.UP * 0.1).floor())) + var ground_cell := Vector3i((global_position - Vector3.UP * 0.1).floor()) + var ground_id := world.get_block_world(ground_cell) + var head_id := world.get_block_world(Vector3i(head.global_position.floor())) + if feet_id == BlockRegistry.BLOCK_LAVA or head_id == BlockRegistry.BLOCK_LAVA or ground_id == BlockRegistry.BLOCK_LAVA: + take_damage(4.0, "lava") + elif feet_id == BlockRegistry.BLOCK_FIRE or head_id == BlockRegistry.BLOCK_FIRE \ + or world._burning.has(ground_cell): + take_damage(2.0, "fire") + if submerged and air <= 0.0: + take_damage(2.0, "drowning") + elif world.get_registry().is_opaque(head_id): + take_damage(1.0, "suffocation") + if hunger <= 0.0: + take_damage(1.0, "starvation") + if not dead and hunger >= 18.0 and health < MAX_HEALTH: + _regen_clock += delta + if _regen_clock >= 4.0: + _regen_clock = 0.0 + health = minf(MAX_HEALTH, health + 1.0) + hunger = maxf(0.0, hunger - 0.5) + else: + _regen_clock = 0.0 + if previous != Vector3(health, hunger, air): + vitals_changed.emit() + + +func _has_support(at: Vector3) -> bool: + if world == null or not world.is_collision_ready_at(at): + return false + var query := PhysicsRayQueryParameters3D.create(at + Vector3.UP * 0.1, at - Vector3.UP * 0.65, 1, [get_rid()]) + return not get_world_3d().direct_space_state.intersect_ray(query).is_empty() + + +func _protect_edge(delta: float) -> void: + # Check axes separately, then the diagonal, so sneaking slides along ledges. + if not _has_support(global_position + Vector3(velocity.x * delta, 0.0, 0.0)): + velocity.x = 0.0 + if not _has_support(global_position + Vector3(0.0, 0.0, velocity.z * delta)): + velocity.z = 0.0 + if not _has_support(global_position + Vector3(velocity.x * delta, 0.0, velocity.z * delta)): + velocity.x = 0.0 + velocity.z = 0.0 diff --git a/player/player_effects.gd b/player/player_effects.gd new file mode 100644 index 0000000..d65e274 --- /dev/null +++ b/player/player_effects.gd @@ -0,0 +1,118 @@ +class_name PlayerEffects +extends Node3D + +var player: Player +var cracks: MeshInstance3D +var body: MeshInstance3D +var _crack_material: StandardMaterial3D +var _swing := 0.0 +var _hit_time := 0.0 + + +func setup(owner_player: Player) -> void: + player = owner_player + cracks = MeshInstance3D.new() + var box := BoxMesh.new() + box.size = Vector3.ONE * 1.006 + cracks.mesh = box + cracks.top_level = true + cracks.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + _crack_material = StandardMaterial3D.new() + _crack_material.shading_mode = BaseMaterial3D.SHADING_MODE_UNSHADED + _crack_material.transparency = BaseMaterial3D.TRANSPARENCY_ALPHA + _crack_material.texture_filter = BaseMaterial3D.TEXTURE_FILTER_NEAREST + # A small branching fracture texture, repeated on all six box faces. + var image := Image.create(32, 32, false, Image.FORMAT_RGBA8) + image.fill(Color.TRANSPARENT) + for y in range(32): + var x := 15 + roundi(sin(float(y) * 0.8) * 2.0) + image.set_pixel(x, y, Color(0.04, 0.025, 0.02, 1.0)) + if y >= 9 and y < 25: + image.set_pixel(clampi(x + y - 9, 0, 31), y, Color(0.04, 0.025, 0.02, 1.0)) + if y >= 15: + image.set_pixel(clampi(x - (y - 15), 0, 31), y, Color(0.04, 0.025, 0.02, 1.0)) + _crack_material.albedo_texture = ImageTexture.create_from_image(image) + cracks.material_override = _crack_material + add_child(cracks) + cracks.hide() + body = MeshInstance3D.new() + var capsule := CapsuleMesh.new() + capsule.radius = 0.3 + capsule.height = 1.8 + body.mesh = capsule + body.position.y = 0.9 + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.18, 0.36, 0.43) + body.material_override = material + add_child(body) + body.hide() + + +func hide_cracks() -> void: + if cracks != null: + cracks.hide() + + +func show_cracks(cell: Vector3i, progress: float) -> void: + cracks.global_position = Vector3(cell) + Vector3.ONE * 0.5 + _crack_material.albedo_color.a = clampf(progress * 1.5, 0.15, 1.0) + var stage := 1.0 + floorf(progress * 3.0) + _crack_material.uv1_scale = Vector3(stage, stage, 1.0) + cracks.show() + + +func burst(cell: Vector3i, hit: bool = false) -> void: + var particles := CPUParticles3D.new() + particles.top_level = true + particles.amount = 5 if hit else 18 + particles.lifetime = 0.55 + particles.one_shot = true + particles.explosiveness = 1.0 + particles.direction = Vector3.UP + particles.spread = 100.0 + particles.initial_velocity_min = 1.3 + particles.initial_velocity_max = 3.0 + particles.gravity = Vector3(0.0, -9.0, 0.0) + particles.emission_shape = CPUParticles3D.EMISSION_SHAPE_BOX + particles.emission_box_extents = Vector3.ONE * 0.35 + var mesh := BoxMesh.new() + mesh.size = Vector3.ONE * 0.085 + var material := StandardMaterial3D.new() + material.albedo_color = Color(0.48, 0.39, 0.28) + mesh.material = material + particles.mesh = mesh + add_child(particles) + particles.global_position = Vector3(cell) + Vector3.ONE * 0.5 + if hit: + particles.global_position += Vector3(player.target_normal) * 0.55 + particles.finished.connect(particles.queue_free) + particles.restart() + + +func update_view(delta: float) -> void: + if player._mining: + _hit_time += delta + if _hit_time >= 0.25: + _hit_time = 0.0 + burst(player.target_block, true) + else: + _hit_time = 0.0 + body.visible = player.third_person + body.scale.y = 0.8 if player.crouching else 1.0 + var camera := player.camera + camera.position = Vector3.ZERO + if player.third_person: + var origin := player.head.global_position + var desired := origin + camera.global_basis.z * 3.5 + var query := PhysicsRayQueryParameters3D.create(origin, desired, 1, [player.get_rid()]) + var hit := player.get_world_3d().direct_space_state.intersect_ray(query) + var distance := 3.5 + if not hit.is_empty(): + distance = maxf(0.0, origin.distance_to(hit["position"]) - 0.25) + camera.position.z = distance + if player._held_block != null: + player._held_block.visible = not player.third_person and not player.dead and player.selected_block != BlockRegistry.BLOCK_AIR + _swing = _swing + delta * 14.0 if player._mining else 0.0 + var swing := sin(_swing) * 0.18 + player._held_block.position = Vector3(0.62 - absf(swing), -0.55 + swing, -1.05) + player._held_block.rotation_degrees = Vector3(-12.0 - absf(swing) * 160.0, -22.0, 12.0) diff --git a/player/player_effects.gd.uid b/player/player_effects.gd.uid new file mode 100644 index 0000000..f2841c7 --- /dev/null +++ b/player/player_effects.gd.uid @@ -0,0 +1 @@ +uid://ct7q7ms0opaay diff --git a/project.godot b/project.godot index 1fecabd..d456c66 100644 --- a/project.godot +++ b/project.godot @@ -36,6 +36,18 @@ wav={ [input] +pick_block={ +"deadzone": 0.5, +"events": [Object(InputEventMouseButton,"button_index":3), Object(InputEventKey,"physical_keycode":66)] +} +crouch={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"physical_keycode":67)] +} +third_person={ +"deadzone": 0.5, +"events": [Object(InputEventKey,"physical_keycode":4194336)] +} move_forward={ "deadzone": 0.5, "events": [Object(InputEventKey,"resource_local_to_scene":false,"resource_name":"","device":0,"window_id":0,"alt_pressed":false,"shift_pressed":false,"ctrl_pressed":false,"meta_pressed":false,"pressed":false,"keycode":0,"physical_keycode":87,"key_label":0,"unicode":119,"location":0,"echo":false,"script":null) diff --git a/tools/flat_survival_warning_verify.gd b/tools/flat_survival_warning_verify.gd new file mode 100644 index 0000000..0df2881 --- /dev/null +++ b/tools/flat_survival_warning_verify.gd @@ -0,0 +1,43 @@ +## Run with --headless --path . --script res://tools/flat_survival_warning_verify.gd +extends SceneTree + +var _failures := PackedStringArray() + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var panel = load("res://ui/play_panel.tscn").instantiate() + root.add_child(panel) + var config: Node = root.get_node("GameConfig") + var previous_type: int = config.world["world_type"] + config.world["world_type"] = WorldGenConfig.WORLD_TYPE_FLAT + panel._show_view(panel.View.CREATE) + _expect("resource-limited" in panel._mode_hint.text, "initial Flat Survival warning missing") + _expect("no trees or ores" in panel._mode_hint.text, "warning must explain missing resources") + for mode in [GameMode.CREATIVE, GameMode.SURVIVAL]: + panel._mode_option.select(panel._mode_option.get_item_index(mode)) + panel._mode_option.item_selected.emit(panel._mode_option.selected) + _expect(("resource-limited" in panel._mode_hint.text) == (mode == GameMode.SURVIVAL), + "warning did not follow mode selection") + for world_type in [WorldGenConfig.WORLD_TYPE_NORMAL, WorldGenConfig.WORLD_TYPE_AMPLIFIED, + WorldGenConfig.WORLD_TYPE_FLAT]: + panel._type_option.select(world_type) + panel._type_option.item_selected.emit(world_type) + _expect(("resource-limited" in panel._mode_hint.text) == + (mode == GameMode.SURVIVAL and world_type == WorldGenConfig.WORLD_TYPE_FLAT), + "warning did not follow type selection") + config.world["world_type"] = previous_type + panel.queue_free() + await process_frame + for failure in _failures: + push_error(failure) + print("FLAT SURVIVAL WARNING VERIFY: ", "PASS" if _failures.is_empty() else "FAIL") + quit(0 if _failures.is_empty() else 1) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures.append(message) diff --git a/tools/flat_survival_warning_verify.gd.uid b/tools/flat_survival_warning_verify.gd.uid new file mode 100644 index 0000000..cc154eb --- /dev/null +++ b/tools/flat_survival_warning_verify.gd.uid @@ -0,0 +1 @@ +uid://dhe2fmaaq7rlx diff --git a/tools/gameplay_integration_verify.gd b/tools/gameplay_integration_verify.gd new file mode 100644 index 0000000..39957b1 --- /dev/null +++ b/tools/gameplay_integration_verify.gd @@ -0,0 +1,178 @@ +extends SceneTree + +class TestWorld extends VoxelWorld: + func use_flint_and_steel(_position: Vector3i, _normal: Vector3i) -> Dictionary: + return {"ignited": 1} + +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + # Exercise Main ownership without starting streaming or touching world saves. + var drops_script = load("res://game/item_drops.gd") + if drops_script == null or not drops_script.can_instantiate(): + push_error("ItemDrops dependency failed to compile") + quit(1) + return + var main = load("res://game/main.gd").new() + main._game_mode = GameMode.SURVIVAL + main.player = load("res://player/player.gd").new() + main.player.game_mode = GameMode.SURVIVAL + main.world = TestWorld.new() + main.world._blocks = BlockRegistry.new() + main._status_label = Label.new() + root.add_child(main._status_label) + main._restore_session_state({}) + _expect(main.inventory.count_item(1) == 0 and main._inventory_overflow.is_empty(), "new survival inventory is empty") + main.inventory.add_item(1, 3) + main._restore_session_state({"state_version": 2, "day_night": null, "weather": null}) + _expect(main.inventory.count_item(1) == 0, "missing survival inventory falls back to empty") + # Null environment dependencies make accidental Survival handler access fail. + main._on_inventory_time_selected(12.0) + main._on_weather_toggled() + var legacy: Dictionary = main._migrate_session_state({"state_version": 1, + "inventory": [[2, 12000], [1000, 3]], "selected_slot": 99, + "day_night": null, "weather": null}) + main._restore_session_state(legacy) + _expect(main.inventory.count_item(2) + int(main._inventory_overflow.get(2, 0)) == 12000, "legacy counts survive capacity overflow without a 9999 cap") + _expect(main.inventory.count_item(1000) + int(main._inventory_overflow.get(1000, 0)) == 3, "legacy tools retain all copies") + _expect(main.selected_slot == 9, "restored selection is limited to ten slots") + main.inventory.restore([]) + main.inventory.slots[15] = {"id": 2, "count": 12, "durability": 0} + main.inventory.slots[9] = {"id": 3, "count": 7, "durability": 0} + main._on_block_picked(2) + _expect(main.inventory.slots[9].id == 2 and main.inventory.slots[15].id == 3, "pick swaps owned backpack stack into selected hotbar slot") + var before: Array = main.inventory.persistent_state() + main._on_block_picked(ItemRegistry.ITEM_IRON_PICKAXE) + _expect(main.inventory.persistent_state() == before, "pick never grants an unowned item") + main.inventory.slots[9] = {"id": ItemRegistry.ITEM_COOKED_MEAT, "count": 2, "durability": 0} + main._on_item_used(ItemRegistry.ITEM_COOKED_MEAT, Vector3i.ZERO) + _expect(main.inventory.slots[9].count == 2, "full hunger does not consume food") + main.player.hunger = 10.0 + main._on_item_used(ItemRegistry.ITEM_COOKED_MEAT, Vector3i.ZERO) + _expect(main.inventory.slots[9].count == 1 and main.player.hunger == 18.0, "successful eating consumes exactly one item") + main._restore_session_state({"state_version": 2, "inventory": before, + "day_night": null, "weather": null}) + _expect(main.inventory.persistent_state() == before, "v2 restores exact stack positions") + main.inventory.slots[0] = {"id": ItemRegistry.ITEM_WOOD_PICKAXE, "count": 1, "durability": 1} + main.inventory.slots[1] = {"id": ItemRegistry.ITEM_WOOD_PICKAXE, "count": 1, "durability": 59} + main.select_slot(0) + main.player._mining = true + main.player.mining_progress = 0.9 + main.select_slot(1) + _expect(not main.player._mining and main.player.mining_progress == 0.0, "switching identical tools cancels mining progress") + main._drops = drops_script.new() + main.add_child(main._drops) + main._on_mined_block(Vector3i.ZERO, BlockRegistry.BLOCK_STONE, true) + _expect(main.inventory.slots[1].durability == 58 and main._drops.persistent_state().size() == 1, "survival mining wears tool and drops harvested block") + _expect(main._drops.persistent_state()[0].stack.id == BlockRegistry.BLOCK_COBBLESTONE, "mined stone becomes physical cobblestone") + main._on_mined_block(Vector3i.ZERO, BlockRegistry.BLOCK_IRON_ORE, false) + _expect(main._drops.persistent_state().size() == 1 and main.inventory.slots[1].durability == 57, "failed harvest wears tool but yields no drop") + main._on_mined_block(Vector3i.ZERO, BlockRegistry.BLOCK_COAL_ORE, true) + _expect(main._drops.persistent_state()[1].stack.id == ItemRegistry.ITEM_COAL, "coal ore yields physical coal") + var food_before: Array = main.inventory.persistent_state() + for index in main.inventory.slots.size(): + main.inventory.slots[index] = {"id": BlockRegistry.BLOCK_DIRT, "count": 64, "durability": 0} + main.inventory.slots[1] = {"id": ItemRegistry.ITEM_MUSHROOM_STEW, "count": 1, "durability": 0} + main.player.hunger = 10.0 + main._on_item_used(ItemRegistry.ITEM_MUSHROOM_STEW, Vector3i.ZERO) + _expect(main.player.hunger == 16.0 and main.inventory.slots[1].id == ItemRegistry.ITEM_BOWL, "stew frees selected slot before returning bowl in full inventory") + main.inventory.slots[1] = {"id": ItemRegistry.ITEM_MUSHROOM_STEW, "count": 2, "durability": 0} + main.player.hunger = 10.0 + main._drops.restore([]) + var head := Node3D.new() + head.name = "Head" + var camera := Camera3D.new() + camera.name = "Camera3D" + head.add_child(camera) + main.player.add_child(head) + root.add_child(main.player) + main._on_item_used(ItemRegistry.ITEM_MUSHROOM_STEW, Vector3i.ZERO) + _expect(main.inventory.slots[1].count == 1 and main._drops.persistent_state().size() == 1 + and main._drops.persistent_state()[0].stack.id == ItemRegistry.ITEM_BOWL, "stacked stew drops overflow bowl without loss") + root.remove_child(main.player) + main.inventory.slots[1] = {"id": BlockRegistry.BLOCK_MELON, "count": 1, "durability": 0} + main.player.hunger = 10.0 + main._on_item_used(BlockRegistry.BLOCK_MELON, Vector3i.ZERO) + _expect(main.player.hunger == 10.0 and main.inventory.slots[1].count == 1, "melon cannot be directly eaten") + main.inventory.restore(food_before) + main._drops.restore([]) + main.inventory.slots[2] = {"id": 2, "count": 2, "durability": 0} + main.select_slot(2) + main.consume_selected_block() + _expect(main.inventory.slots[2].count == 1, "survival placement consumes one block") + main.inventory.slots[2] = {"id": ItemRegistry.ITEM_FLINT_AND_STEEL, "count": 1, "durability": 10} + main._on_item_used(ItemRegistry.ITEM_FLINT_AND_STEEL, Vector3i.ZERO) + _expect(main.inventory.slots[2].durability == 9, "survival ignition wears flint and steel") + var chest_position := Vector3i(160, 60, 160) + main.containers.ensure_chest(chest_position).add_item(2, 80) + main._check_removed_containers() + _expect(main.containers.get_inventory(chest_position) != null, "unloaded chunks do not destroy containers") + main._drop_container_contents(chest_position) + var drop_count := 0 + for drop in main._drops.persistent_state(): + drop_count += int(drop.stack.count) + _expect(drop_count == 80 and main.containers.get_inventory(chest_position) == null, "broken containers transfer every stack to physical drops") + main._drop_container_contents(chest_position) + _expect(main._drops.persistent_state().size() == 2, "container removal cannot duplicate drops") + var hud := Control.new() + root.add_child(hud) + var survival = load("res://game/survival_ui.gd").new() + root.add_child(survival) + survival.setup(hud, main.player) + _expect(survival._vitals.visible, "survival vitals visible") + survival.show_death("fall") + _expect(survival._death.visible and survival._respawn.has_focus(), "death modal opens with respawn focus") + survival.hide_death() + _expect(not survival._death.visible, "respawn dismisses death modal") + main._game_mode = GameMode.CREATIVE + main.player.game_mode = GameMode.CREATIVE + main._restore_session_state({}) + _expect(main.inventory.count_item(1) == 64, "creative retains starter palette") + main.select_slot(0) + main._on_block_picked(BlockRegistry.BLOCK_STONE) + _expect(main.inventory.slots[0].id == BlockRegistry.BLOCK_STONE and main.inventory.slots[0].count == 64 + and main.player.selected_block == BlockRegistry.BLOCK_STONE, "creative pick grants selected full stack") + main.consume_selected_block() + _expect(main.inventory.slots[0].count == 64, "creative placement does not consume") + main._on_block_picked(ItemRegistry.ITEM_WOOD_PICKAXE) + before = main.inventory.persistent_state() + var drops_before: Array = main._drops.persistent_state() + main.containers.ensure_chest(chest_position).add_item(2, 80) + main._on_mined_block(chest_position, BlockRegistry.BLOCK_CHEST, true) + _expect(main.inventory.persistent_state() == before and main._drops.persistent_state() == drops_before + and main.containers.get_inventory(chest_position) == null, "creative mining has no wear or block/container drops") + main._on_block_picked(ItemRegistry.ITEM_FLINT_AND_STEEL) + before = main.inventory.persistent_state() + main._on_item_used(ItemRegistry.ITEM_FLINT_AND_STEEL, Vector3i.ZERO) + _expect(main.inventory.persistent_state() == before, "creative ignition does not wear flint and steel") + var config := root.get_node("GameConfig") + var pending_mode: int = config.pending_game_mode + config.pending_game_mode = GameMode.SURVIVAL + main.consume_selected_block() + _expect(main.inventory.persistent_state() == before and main.player.game_mode == GameMode.CREATIVE, "pending selection cannot change running gameplay mode") + config.pending_game_mode = pending_mode + main._restore_session_state({"state_version": 2, "inventory": before, + "game_mode": GameMode.SURVIVAL, "day_night": null, "weather": null}) + _expect(main._game_mode == GameMode.CREATIVE and main.inventory.persistent_state() == before, "session cannot replace startup mode or reset saved inventory") + survival._update_vitals() + survival.show_death("ignored") + _expect(not survival._vitals.visible and not survival._death.visible, "creative hides vitals and death modal") + survival.free() + hud.free() + main._status_label.free() + main.player.free() + main.world.free() + main.free() + print("Gameplay integration verifier: %d failure(s)" % _failures) + quit(1 if _failures else 0) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures += 1 + push_error(message) diff --git a/tools/gameplay_integration_verify.gd.uid b/tools/gameplay_integration_verify.gd.uid new file mode 100644 index 0000000..bf27353 --- /dev/null +++ b/tools/gameplay_integration_verify.gd.uid @@ -0,0 +1 @@ +uid://bcxtdw6rk5b38 diff --git a/tools/inventory_crafting_verify.gd b/tools/inventory_crafting_verify.gd new file mode 100644 index 0000000..0f00718 --- /dev/null +++ b/tools/inventory_crafting_verify.gd @@ -0,0 +1,220 @@ +## redot --headless --path . --script res://tools/inventory_crafting_verify.gd +extends SceneTree + +var failures := 0 +var notifications := 0 + + +func _initialize() -> void: + _test_inventory() + _test_crafting() + _test_progression() + _test_containers() + _test_catalog() + print("INVENTORY CRAFTING VERIFY: %s" % ("PASS" if failures == 0 else "FAIL (%d)" % failures)) + quit(0 if failures == 0 else 1) + + +func _expect(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) + + +func _notified() -> void: + notifications += 1 + + +func _test_inventory() -> void: + var inventory := ItemInventory.new() + _expect(inventory.slots.size() == 40 and ItemInventory.HOTBAR_SIZE == 10, "default sizes") + inventory.changed.connect(_notified) + _expect(inventory.add_item(2, 70) == 0, "add stacks") + _expect(inventory.slots[0].count == 64 and inventory.slots[1].count == 6, "stack packing") + _expect(notifications == 1, "add emits once") + _expect(inventory.move_stack(0, 2, true) and inventory.slots[2].count == 32, "split stack") + _expect(inventory.move_stack(2, 1) and inventory.slots[1].count == 38, "merge stacks") + _expect(not inventory.remove_at(-1) and not inventory.remove_at(0, 100), "invalid removals") + _expect(inventory.add_item(999999, 2) == 2 and inventory.add_item(0) == 1, "reject invalid IDs") + var snapshot := inventory.persistent_state() + snapshot[0].count = 1 + _expect(inventory.slots[0].count == 32, "snapshot does not alias") + var tiny := ItemInventory.new(1) + _expect(tiny.add_item(2, 70) == 6, "overflow remainder") + var before := tiny.persistent_state() + _expect(not tiny.move_stack(0, 2) and tiny.persistent_state() == before, "invalid move is inert") + tiny.restore([]) + _expect(tiny.add_item(ItemRegistry.ITEM_WOOD_PICKAXE, 2, 2) == 1, "tools do not stack") + _expect(tiny.wear_tool(0) and tiny.slots[0].durability == 1, "wear tool") + _expect(tiny.wear_tool(0) and tiny.slots[0].is_empty(), "tool breaks") + _expect(not tiny.wear_tool(0), "empty slot not a tool") + tiny.restore([{"id": 2, "count": 999, "durability": 99}]) + _expect(tiny.slots[0] == {"id": 2, "count": 64, "durability": 0}, "restore clamps stacks") + tiny.restore([{"id": ItemRegistry.ITEM_WOOD_PICKAXE, "count": 1, "durability": 0}]) + _expect(tiny.slots[0].is_empty(), "restore rejects broken tools") + var overflow := tiny.migrate_counts({"2": 70}) + _expect(tiny.count_item(2) == 64 and overflow.get(2) == 6, "legacy counts and overflow") + inventory.restore([{"id": 2, "count": 5}, {"id": 3, "count": 4}]) + _expect(not inventory.move_stack(0, 1, true), "split never swaps unlike stacks") + _expect(inventory.move_stack(0, 1) and inventory.slots[0].id == 3, "full move swaps") + inventory.restore([{}, {"id": ItemRegistry.ITEM_FLINT_AND_STEEL, "count": 1, "durability": 12}]) + var restored := ItemInventory.new() + restored.restore(JSON.parse_string(JSON.stringify(inventory.persistent_state()))) + _expect(restored.persistent_state() == inventory.persistent_state(), "inventory JSON round trip") + + +func _test_crafting() -> void: + var inventory := ItemInventory.new(2) + inventory.add_item(BlockRegistry.BLOCK_PLANKS, 4) + inventory.add_item(1010, 3) + var before := inventory.persistent_state() + _expect(not CraftingRecipes.craft(inventory, "wood_pickaxe", false), "table requirement") + _expect(not CraftingRecipes.craft(inventory, "wood_pickaxe", true), "atomic capacity rejection") + _expect(inventory.persistent_state() == before, "failed craft retains ingredients") + inventory.remove_at(0) + notifications = 0 + inventory.changed.connect(_notified) + _expect(CraftingRecipes.craft(inventory, "wood_pickaxe", true), "craft uses freed ingredient slot") + _expect(inventory.count_item(1001) == 1 and inventory.count_item(1010) == 1, "craft exact counts") + _expect(notifications == 1, "craft emits only committed state") + before = inventory.persistent_state() + _expect(not CraftingRecipes.craft(inventory, "missing", true), "unknown recipe rejected") + _expect(not CraftingRecipes.craft(inventory, "iron_pickaxe", true), "missing ingredients rejected") + _expect(inventory.persistent_state() == before, "recipe failures inert") + var ids := {} + for recipe in CraftingRecipes.RECIPES: + _expect(recipe.category in ["Basics", "Tools", "Storage", "Food", "Building", "Lighting"], "recipe category") + _expect(not ids.has(recipe.id), "unique recipe ID") + ids[recipe.id] = true + var sample := ItemInventory.new() + for id in recipe.ingredients: + _expect(ItemRegistry.is_valid(id), "valid recipe ingredient") + sample.add_item(id, recipe.ingredients[id]) + _expect(CraftingRecipes.craft(sample, recipe.id, true), "recipe crafts: " + recipe.id) + _expect(sample.count_item(recipe.output) == recipe.count, "recipe output count") + + +func _test_progression() -> void: + var inventory := ItemInventory.new(2) + inventory.add_item(BlockRegistry.BLOCK_LOG, 16) + var before := inventory.persistent_state() + _expect(CraftingRecipes.max_craftable(inventory, "planks_oak", false) == 16, "maximum counts batches not output") + _expect(not CraftingRecipes.craft(inventory, "planks_oak", false, 17), "all requested batches required") + _expect(not CraftingRecipes.craft(inventory, "planks_oak", false, 0), "zero batch rejected") + _expect(not CraftingRecipes.craft(inventory, "planks_oak", false, -1), "negative batch rejected") + _expect(not CraftingRecipes.craft(inventory, "planks_oak", false, 9223372036854775807), "oversized batch rejected") + _expect(inventory.persistent_state() == before, "batch rejection leaves inventory unchanged") + _expect(CraftingRecipes.craft(inventory, "planks_oak", false, 16), "multi-batch commit") + _expect(inventory.count_item(BlockRegistry.BLOCK_PLANKS) == 64, "batch output exact") + inventory = ItemInventory.new(1) + inventory.add_item(BlockRegistry.BLOCK_LOG, 17) + before = inventory.persistent_state() + _expect(CraftingRecipes.max_craftable(inventory, "planks_oak", false) == 0, "output capacity limits all batches") + _expect(not CraftingRecipes.craft(inventory, "planks_oak", false, 17), "batch output overflow rejected") + _expect(inventory.persistent_state() == before, "output overflow preserves all inputs") + inventory.restore([{"id": BlockRegistry.BLOCK_LOG, "count": 16}]) + _expect(CraftingRecipes.max_craftable(inventory, "planks_oak", false) == 16, "maximum finds nonmonotonic freed-slot fit") + _expect(CraftingRecipes.blocking_reason(inventory, "planks_oak", false).contains("full"), "single batch blocked by capacity") + _expect(CraftingRecipes.craft(inventory, "planks_oak", false, 16), "full batch frees input slot") + _expect(CraftingRecipes.blocking_reason(inventory, "wood_pickaxe", false).contains("table"), "table reason") + _expect(CraftingRecipes.blocking_reason(inventory, "wood_pickaxe", true).contains("stick"), "named missing materials") + _expect(CraftingRecipes.get_recipe("missing").is_empty(), "unknown recipe lookup") + _expect(CraftingRecipes.max_craftable(inventory, "missing", true) == 0, "unknown maximum") + inventory = ItemInventory.new(2) + inventory.add_item(BlockRegistry.BLOCK_PLANKS, 9) + inventory.add_item(ItemRegistry.ITEM_STICK, 6) + before = inventory.persistent_state() + _expect(CraftingRecipes.max_craftable(inventory, "wood_pickaxe", true) == 0, "unstackable tool batch respects capacity") + _expect(not CraftingRecipes.craft(inventory, "wood_pickaxe", true, 3), "three tools cannot fit two slots") + _expect(inventory.persistent_state() == before, "tool batch failure retains ingredients") + inventory = ItemInventory.new() + inventory.add_item(BlockRegistry.BLOCK_MELON) + _expect(CraftingRecipes.craft(inventory, "melon_slices", false) and inventory.count_item(ItemRegistry.ITEM_MELON_SLICE) == 4, "melon cuts into four slices") + _expect(CraftingRecipes.craft(inventory, "melon", false) and inventory.count_item(BlockRegistry.BLOCK_MELON) == 1 + and inventory.count_item(ItemRegistry.ITEM_MELON_SLICE) == 0, "reverse melon recipe cannot duplicate food") + inventory = ItemInventory.new() + inventory.add_item(BlockRegistry.BLOCK_BAMBOO, 256) + _expect(CraftingRecipes.max_craftable(inventory, "bamboo_sticks", false) == 64, "maximum bounded at 64") + for log_id in [BlockRegistry.BLOCK_LOG, BlockRegistry.BLOCK_SPRUCE_LOG, BlockRegistry.BLOCK_BIRCH_LOG, BlockRegistry.BLOCK_ACACIA_LOG, BlockRegistry.BLOCK_JUNGLE_LOG, BlockRegistry.BLOCK_MANGROVE_LOG]: + _expect(CraftingRecipes.SMELTING[log_id] == ItemRegistry.ITEM_CHARCOAL, "all logs produce charcoal") + _expect(CraftingRecipes.SMELTING[BlockRegistry.BLOCK_RED_SAND] == BlockRegistry.BLOCK_GLASS, "red sand smelts") + _expect(CraftingRecipes.SMELTING[BlockRegistry.BLOCK_CLAY] == BlockRegistry.BLOCK_TERRACOTTA, "clay smelts") + _expect(ItemRegistry.food_value(BlockRegistry.BLOCK_MELON) == 0.0, "whole melon is not edible") + _expect(ItemRegistry.food_value(ItemRegistry.ITEM_MELON_SLICE) == 2.0 and ItemRegistry.food_value(ItemRegistry.ITEM_MUSHROOM_STEW) == 6.0, "new food nutrition") + _expect(ItemRegistry.harvest_drop(BlockRegistry.BLOCK_STONE) == {"id": BlockRegistry.BLOCK_COBBLESTONE, "count": 1}, "stone drops cobblestone") + _expect(ItemRegistry.harvest_drop(BlockRegistry.BLOCK_COAL_ORE).id == ItemRegistry.ITEM_COAL, "coal ore drops coal") + _expect(ItemRegistry.harvest_drop(BlockRegistry.BLOCK_LOG).id == BlockRegistry.BLOCK_LOG, "other blocks drop themselves") + _expect(BlockRegistry.BLOCK_PLANKS == 74 and ItemRegistry.ITEM_CHARCOAL == 1017 and ItemRegistry.ITEM_FLINT == 1021, "appended stable IDs") + _expect((BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_PLANKS][5] & BlockRegistry.FLAG_FLAMMABLE) != 0, "planks flammable") + _expect(BlockRegistry.break_seconds(BlockRegistry.BLOCK_PLANKS, ItemRegistry.ITEM_WOOD_AXE) < BlockRegistry.break_seconds(BlockRegistry.BLOCK_PLANKS), "axe speeds plank harvesting") + for fuel in [BlockRegistry.BLOCK_PLANKS, ItemRegistry.ITEM_WOOD_PICKAXE, ItemRegistry.ITEM_WOOD_AXE, ItemRegistry.ITEM_WOOD_SHOVEL, ItemRegistry.ITEM_CHARCOAL]: + _expect(ItemRegistry.fuel_seconds(fuel) > 0.0, "new fuels burn") + var containers := BlockContainers.new() + var furnace := containers.ensure_furnace(Vector3i.ZERO) + furnace.restore([{"id": BlockRegistry.BLOCK_KELP, "count": 2}, {"id": fuel, "count": 1, "durability": 1}, {}]) + containers.tick(ItemRegistry.fuel_seconds(fuel)) + _expect(furnace.slots[1].is_empty(), "fuel including worn tools consumed once") + var count := furnace.count_item(ItemRegistry.ITEM_DRIED_KELP) + containers.tick(100.0) + _expect(furnace.count_item(ItemRegistry.ITEM_DRIED_KELP) == count, "spent fuel never duplicates") + + +func _test_containers() -> void: + var model := BlockContainers.new() + var chest_pos := Vector3i(-16, 25, 4) + var furnace_pos := Vector3i(1, 2, 3) + model.tick(100.0) + _expect(model.containers.is_empty(), "tick never creates furnaces") + var chest := model.ensure_chest(chest_pos) + _expect(chest.slots.size() == 27 and model.ensure_chest(chest_pos) == chest, "chest identity and size") + _expect(model.ensure_furnace(chest_pos) == null, "container type conflict preserves chest") + chest.add_item(2, 128) + var furnace := model.ensure_furnace(furnace_pos) + _expect(furnace.slots.size() == 3, "furnace slot count") + _expect(model.can_insert(furnace_pos, 0, 14) and not model.can_insert(furnace_pos, 2, 1012), "furnace insertion rules") + furnace.restore([{"id": 14, "count": 3}, {"id": 1011, "count": 1}, {}]) + model.tick(5.0) + _expect(model.get_furnace(furnace_pos).progress == 5.0 and furnace.slots[2].is_empty(), "partial smelting") + var copy := BlockContainers.new() + copy.restore(JSON.parse_string(JSON.stringify(model.persistent_state()))) + _expect(copy.get_inventory(chest_pos).count_item(2) == 128, "chest serialization") + copy.tick(15.0) + model.tick(15.0) + _expect(copy.get_inventory(furnace_pos).persistent_state() == furnace.persistent_state(), "furnace resumes after restore") + _expect(furnace.slots[2].count == 2 and furnace.slots[0].count == 1, "multi-smelt delta") + _expect(model.get_furnace(furnace_pos).burn_remaining == 60.0, "fuel charged once") + model.tick(60.0) + _expect(furnace.slots[2].count == 3 and model.get_furnace(furnace_pos).burn_remaining == 0.0, "idle furnace burns residual fuel") + furnace.restore([{"id": 14, "count": 1}, {"id": 1011, "count": 1}, {"id": 1012, "count": 64}]) + model.tick(20.0) + _expect(furnace.slots[1].count == 1 and furnace.slots[0].count == 1, "blocked output consumes no new fuel or input") + furnace.remove_at(2) + model.tick(10.0) + _expect(furnace.slots[2].count == 64 and furnace.slots[0].is_empty(), "output merges to capacity") + var state := model.persistent_state() + model.tick(-10.0) + model.tick(NAN) + _expect(model.persistent_state() == state, "invalid delta ignored") + _expect(model.remove(chest_pos).size() == 27 and model.get_inventory(chest_pos) == null, "remove returns chest contents") + _expect(model.remove(chest_pos).is_empty(), "remove missing container") + for input_id in CraftingRecipes.SMELTING: + _expect(ItemRegistry.is_valid(input_id) and ItemRegistry.is_valid(CraftingRecipes.SMELTING[input_id]), "valid smelting IDs") + var sample := BlockContainers.new() + var slots := sample.ensure_furnace(Vector3i.ZERO) + slots.restore([{"id": input_id, "count": 1}, {"id": ItemRegistry.ITEM_CHARCOAL, "count": 1}, {}]) + sample.tick(CraftingRecipes.SMELT_SECONDS) + _expect(slots.slots[0].is_empty() and slots.slots[1].is_empty() + and slots.count_item(CraftingRecipes.SMELTING[input_id]) == 1, "smelting consumes input/fuel and produces output") + + +func _test_catalog() -> void: + for row in BlockRegistry.BLOCK_DEFS: + _expect(row.size() == 9, "block metadata complete: %s" % row[1]) + _expect(BlockRegistry.BLOCK_CRAFTING_TABLE == 71 and BlockRegistry.BLOCK_CHEST == 72 and BlockRegistry.BLOCK_FURNACE == 73, "new block IDs") + _expect(not BlockRegistry.can_harvest(11, 1007) and BlockRegistry.break_seconds(11, 1007) < 0.0, "bedrock unbreakable") + _expect(not BlockRegistry.can_harvest(16, 1007), "water unharvestable") + _expect(not BlockRegistry.can_harvest(14, 1001) and BlockRegistry.can_harvest(14, 1004), "ore tier gate") + _expect(BlockRegistry.break_seconds(3, 1001) < BlockRegistry.break_seconds(3, 0), "matching tool speeds mining") + _expect(ItemRegistry.tool_kind(1001) == "pickaxe" and ItemRegistry.tool_tier(1007) == 3, "tool catalog") + _expect(ItemRegistry.max_durability(1000) > 0 and ItemRegistry.stack_limit(1000) == 1, "flint preserved as durable tool") + _expect(ItemRegistry.food_value(1014) > ItemRegistry.food_value(1013), "cooking improves food") diff --git a/tools/inventory_crafting_verify.gd.uid b/tools/inventory_crafting_verify.gd.uid new file mode 100644 index 0000000..c0efb5e --- /dev/null +++ b/tools/inventory_crafting_verify.gd.uid @@ -0,0 +1 @@ +uid://cnmfrecd0soq8 diff --git a/tools/inventory_ui_verify.gd b/tools/inventory_ui_verify.gd new file mode 100644 index 0000000..ead2d8f --- /dev/null +++ b/tools/inventory_ui_verify.gd @@ -0,0 +1,264 @@ +extends SceneTree + +var _failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + for offset in ItemRegistry.ITEM_NAMES.size(): + var id := ItemRegistry.ITEM_FLINT_AND_STEEL + offset + var icon := ItemRegistry.make_icon(id, 48) + _expect(icon != null and icon.get_width() == 48, "every registered item has a hotbar icon") + var overlay: Node = load("res://ui/inventory_overlay.tscn").instantiate() + root.add_child(overlay) + var inventory := ItemInventory.new() + overlay.configure_inventory(inventory, null) + overlay.open_panel() + _expect(overlay._game_mode == GameMode.SURVIVAL, "standalone inventory defaults to survival") + _expect(overlay._grid.get_child_count() == 40 and inventory.count_item(1) == 0, "empty survival model displays empty slots without starter grants") + for slot in overlay._slots: + _expect(slot._count.text.is_empty() and slot._icon.texture == null, "empty slots have no count or icon") + for control in overlay._creative_controls: + _expect(not control.visible, "survival hides cheat controls and headings") + _expect(not overlay._catalog.visible and overlay._catalog_buttons.is_empty(), "survival has no creative catalog") + var cheats: Array[String] = [] + overlay.time_selected.connect(func(_hours: float) -> void: cheats.append("time")) + overlay.weather_toggled.connect(func() -> void: cheats.append("weather")) + overlay._on_time_button(12.0) + overlay._on_weather_button() + overlay._grant_catalog_item(1) + _expect(cheats.is_empty() and inventory.count_item(1) == 0, "survival guards cheat callbacks") + overlay.close_panel() + inventory.add_item(5, 12) + inventory.add_item(14, 8) + inventory.add_item(1011, 4) + overlay.configure_inventory(inventory, null) + overlay.open_panel() + await process_frame + await process_frame + _expect(overlay._grid.get_child_count() == 40, "all forty slots are visible, including empty slots") + var slot_rect := Rect2(Vector2.ZERO, overlay._slots[0].size) + _expect(slot_rect.encloses(overlay._slots[0]._count.get_rect()), "stack quantity fits inside its slot") + _expect(overlay._slots[0]._count.text == "12", "quantity label reflects live count") + _expect(overlay._panel.size.y <= root.get_visible_rect().size.y, "scrolling bounds modal height") + var data: Dictionary = overlay.drag_data(overlay._slots[0], true) + _expect(inventory.slots[0].count == 12, "starting a drag does not remove items") + _expect(overlay.drop_stack(overlay._slots[10], data), "split drag accepted") + _expect(inventory.slots[0].count == 6 and inventory.slots[10].count == 6, "rounded split preserves counts") + _expect(not overlay.drop_stack(overlay._slots[11], data), "stale drag cannot be replayed") + var containers := BlockContainers.new() + var chest_pos := Vector3i(1, 2, 3) + overlay.open_station(chest_pos, BlockRegistry.BLOCK_CHEST, containers) + data = overlay.drag_data(overlay._slots[10]) + _expect(overlay.drop_stack(overlay._slots[40], data), "backpack to chest transfer") + _expect(inventory.count_item(5) == 6 and containers.get_inventory(chest_pos).count_item(5) == 6, "chest transfer conserves count") + data = overlay.drag_data(overlay._slots[40]) + _expect(overlay.drop_stack(overlay._slots[10], data), "chest to backpack transfer") + var furnace_pos := Vector3i(4, 2, 3) + overlay.open_station(furnace_pos, BlockRegistry.BLOCK_FURNACE, containers) + var furnace := containers.get_inventory(furnace_pos) + _expect(overlay._furnace_status.text.contains("Close inventory") and overlay._furnace_status.text.contains("Creative only"), "furnace explains pause and unavailable survival meat") + for id in CraftingRecipes.SMELTING: + _expect(overlay._furnace_status.text.contains("%s -> %s" % [overlay.item_name(int(id)), overlay.item_name(int(CraftingRecipes.SMELTING[id]))]), "furnace lists every actual smelting recipe") + _expect(overlay._furnace_status.text.contains("%s: %.0fs" % [overlay.item_name(ItemRegistry.ITEM_COAL), ItemRegistry.fuel_seconds(ItemRegistry.ITEM_COAL)]), "furnace lists actual fuel duration") + data = overlay.drag_data(overlay._slots[1]) + var before := inventory.persistent_state() + _expect(not overlay.drop_stack(overlay._slots[42], data), "output rejects insertion") + _expect(not overlay.drop_stack(overlay._slots[41], data), "fuel rejects ore") + _expect(inventory.persistent_state() == before, "rejected drags leave source unchanged") + _expect(overlay.drop_stack(overlay._slots[40], data), "ore accepted in input") + data = overlay.drag_data(overlay._slots[2]) + _expect(overlay.drop_stack(overlay._slots[41], data), "coal accepted as fuel") + furnace.slots[2] = {"id": 1012, "count": 2, "durability": 0} + furnace.changed.emit() + data = overlay.drag_data(overlay._slots[42]) + _expect(not overlay.drop_stack(overlay._slots[0], data), "output cannot swap with occupied unlike stack") + _expect(furnace.slots[2].count == 2, "rejected output swap preserves output") + _expect(overlay.drop_stack(overlay._slots[3], data), "output can be extracted to empty slot") + _expect(inventory.count_item(1012) == 2 and furnace.slots[2].is_empty(), "output extraction has no duplication") + data = overlay.drag_data(overlay._slots[0]) + overlay.close_panel() + overlay.open_panel() + _expect(not overlay.drop_stack(overlay._slots[11], data), "closing invalidates pending drag") + _expect(not overlay._at_table, "normal inventory has no crafting table access") + overlay.open_station(Vector3i.ZERO, BlockRegistry.BLOCK_CRAFTING_TABLE, containers) + _expect(overlay._at_table, "crafting table enables table recipes") + var wood_before := inventory.count_item(5) + overlay._recipe_buttons[_recipe_index("planks_oak")].pressed.emit() + _expect(inventory.count_item(5) == wood_before - 1 and inventory.count_item(74) == 4, "recipe button crafts one batch") + paused = true + _expect(overlay.can_process(), "inventory remains interactive while paused") + overlay.close_panel() + paused = false + var modal_button := Button.new() + root.add_child(modal_button) + modal_button.grab_focus() + var reject_open := func() -> void: overlay.close_panel() + overlay.opened.connect(reject_open) + overlay.open_panel() + _expect(not overlay.visible and modal_button.has_focus(), "rejected inventory open preserves modal focus") + overlay.open_station(chest_pos, BlockRegistry.BLOCK_CHEST, containers) + _expect(not overlay.visible and modal_button.has_focus(), "rejected station open preserves modal focus") + modal_button.free() + overlay.queue_free() + await process_frame + await _verify_creative() + await _verify_recipe_book() + # Let the real recipe button's click cue finish before tearing down audio. + await create_timer(0.5).timeout + print("INVENTORY UI VERIFY: %s" % ("PASS" if _failures == 0 else "FAIL (%d)" % _failures)) + quit(0 if _failures == 0 else 1) + + +func _verify_creative() -> void: + var overlay: Node = load("res://ui/inventory_overlay.tscn").instantiate() + root.add_child(overlay) + var inventory := ItemInventory.new(2) + overlay.configure_inventory(inventory, null) + overlay.set_game_mode(GameMode.CREATIVE) + overlay.open_panel() + await process_frame + _expect(overlay._catalog.is_visible_in_tree(), "creative catalog is visible") + for control in overlay._creative_controls: + _expect(control.is_visible_in_tree(), "creative exposes time and weather controls") + for definition in BlockRegistry.BLOCK_DEFS: + var id := int(definition[0]) + var water := id == BlockRegistry.BLOCK_WATER or (id >= BlockRegistry.BLOCK_WATER_FLOW_7 and id <= BlockRegistry.BLOCK_WATER_FLOW_1) + var excluded := id in [BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_LAVA, BlockRegistry.BLOCK_FIRE] or water or (int(definition[5]) & BlockRegistry.FLAG_UNBREAKABLE) != 0 + _expect(overlay._catalog_buttons.has(id) == (not excluded and ItemRegistry.is_valid(id)), "catalog filters block %d" % id) + for offset in ItemRegistry.ITEM_NAMES.size(): + _expect(overlay._catalog_buttons.has(ItemRegistry.ITEM_FLINT_AND_STEEL + offset), "catalog contains every registered item") + _expect(overlay.find_children("*", "OptionButton", true, false).size() == 1 and overlay._recipe_category.item_count == 7, "only selector is the recipe category, not game mode") + for button in overlay.find_children("*", "Button", true, false): + _expect(not "survival" in button.text.to_lower() and not "creative" in button.text.to_lower(), "no mode switching button") + overlay._catalog_buttons[1].pressed.emit() + _expect(inventory.count_item(1) == 64, "catalog click grants exactly one block stack") + overlay._catalog_buttons[ItemRegistry.ITEM_WOOD_PICKAXE].pressed.emit() + _expect(inventory.count_item(ItemRegistry.ITEM_WOOD_PICKAXE) == 1 and inventory.slots[1].durability == ItemRegistry.max_durability(ItemRegistry.ITEM_WOOD_PICKAXE), "catalog tools have full durability and do not stack") + var before := inventory.persistent_state() + overlay._catalog_buttons[2].pressed.emit() + overlay._catalog_buttons[1].pressed.emit() + _expect(inventory.persistent_state() == before, "full inventory grants neither duplicate nor replace existing items") + _expect(overlay._status.text.contains("No room"), "full inventory reports feedback") + inventory.remove_at(0, 3) + overlay._catalog_buttons[1].pressed.emit() + _expect(inventory.persistent_state() == before, "partial capacity grants only the three available spaces") + _expect(overlay._status.text.contains("x3") and overlay._status.text.contains("No room"), "partial grant reports actual count and overflow") + overlay.close_panel() + overlay.open_panel() + _expect(overlay._game_mode == GameMode.CREATIVE, "opening and closing preserves fixed session mode") + overlay.queue_free() + await process_frame + + +func _recipe_index(id: String) -> int: + for index in CraftingRecipes.RECIPES.size(): + if str(CraftingRecipes.RECIPES[index]["id"]) == id: + return index + return -1 + + +func _verify_recipe_book() -> void: + var overlay: Node = load("res://ui/inventory_overlay.tscn").instantiate() + root.add_child(overlay) + var inventory := ItemInventory.new() + overlay.configure_inventory(inventory, null) + overlay.open_panel() + var planks := _recipe_index("planks_oak") + var pick := _recipe_index("wood_pickaxe") + _expect(overlay._recipe_buttons.size() == CraftingRecipes.RECIPES.size(), "recipe buttons preserve model order") + for index in CraftingRecipes.RECIPES.size(): + _expect(overlay._recipe_cards[index].visible, "unavailable recipes remain discoverable by default") + _expect(not overlay._recipe_requirements[index].text.is_empty(), "requirements are readable outside disabled buttons") + _expect(overlay._recipe_requirements[pick].text.contains("Requires crafting table"), "table requirement explains opening a placed table") + overlay._recipe_search.text = "spruce" + overlay._recipe_search.text_changed.emit("spruce") + _expect(overlay._recipe_cards[_recipe_index("planks_spruce")].visible and not overlay._recipe_cards[planks].visible, "search matches recipe identity and ingredients") + overlay._recipe_search.text = "zz-no-such-recipe" + overlay._recipe_search.text_changed.emit("zz-no-such-recipe") + _expect(overlay._recipe_empty.visible, "empty search has recovery guidance") + overlay._recipe_search.text = "" + overlay._recipe_search.text_changed.emit("") + overlay._recipe_category.select(2) + overlay._recipe_category.item_selected.emit(2) + for index in CraftingRecipes.RECIPES.size(): + _expect(overlay._recipe_cards[index].visible == (str(CraftingRecipes.RECIPES[index]["category"]).to_lower() == "tools"), "category filter shows only its group") + overlay._recipe_category.select(0) + overlay._recipe_category.item_selected.emit(0) + overlay._craftable_only.button_pressed = true + _expect(overlay._recipe_empty.visible, "empty inventory has no craftable recipes") + inventory.add_item(5, 3) + _expect(overlay._recipe_cards[planks].visible and not overlay._recipe_cards[pick].visible, "craftable filter updates with live inventory") + _expect(overlay._recipe_requirements[planks].text.contains("3/1"), "ingredients display owned and needed counts") + _expect(overlay._recipe_max_buttons[planks].text.contains("3 batches") and overlay._recipe_max_buttons[planks].focus_mode == Control.FOCUS_ALL, "max control reports batch count and supports keyboard focus") + var cached: Dictionary = overlay._recipe_availability[planks] + overlay._recipe_search.text_changed.emit("") + overlay._refresh() + _expect(is_same(cached, overlay._recipe_availability[planks]), "filter and selection refresh reuse capacity trials") + var stale: Dictionary = overlay.drag_data(overlay._slots[0]) + overlay._recipe_max_buttons[planks].pressed.emit() + _expect(inventory.count_item(5) == 0 and inventory.count_item(74) == 12, "Craft Max consumes exactly available batches") + _expect(not overlay.can_drop(overlay._slots[10], stale), "crafting invalidates stale source stack drag") + _expect(overlay._status.text.contains("x12") and not overlay._recipe_cards[planks].visible, "max output feedback and craftability refresh") + _expect(not is_same(cached, overlay._recipe_availability[planks]), "craft mutation invalidates cached capacity") + overlay._craftable_only.button_pressed = false + var full := ItemInventory.new(1) + full.add_item(5, 64) + overlay.configure_inventory(full, null) + var reason := CraftingRecipes.blocking_reason(full, "planks_oak", false) + _expect(not reason.is_empty() and overlay._recipe_requirements[planks].text.contains(reason), "capacity reason is visible, not just a tooltip") + _expect(overlay._recipe_buttons[planks].disabled and overlay._recipe_max_buttons[planks].disabled, "capacity disables both crafting controls") + var before := full.persistent_state() + overlay._craft_max("planks_oak") + _expect(full.persistent_state() == before, "blocked max leaves inventory unchanged") + var compact := ItemInventory.new(1) + compact.add_item(74, 4) + overlay.configure_inventory(compact, null) + var sticks := _recipe_index("sticks") + _expect(overlay._recipe_buttons[sticks].disabled and not overlay._recipe_max_buttons[sticks].disabled, "max can free ingredient space even when a single batch cannot fit") + overlay._recipe_max_buttons[sticks].grab_focus() + await process_frame + var accept := InputEventAction.new() + accept.action = "ui_accept" + accept.pressed = true + Input.parse_input_event(accept) + await process_frame + accept = InputEventAction.new() + accept.action = "ui_accept" + accept.pressed = false + Input.parse_input_event(accept) + await process_frame + _expect(compact.count_item(74) == 0 and compact.count_item(1010) == 8, "max accounts for slots freed by consuming ingredients") + overlay.configure_inventory(inventory, null) + inventory.add_item(1010, 2) + _expect(overlay._recipe_buttons[pick].disabled, "materials alone do not bypass table access") + var containers := BlockContainers.new() + overlay.open_station(Vector3i.ZERO, BlockRegistry.BLOCK_CRAFTING_TABLE, containers) + _expect(not overlay._recipe_buttons[pick].disabled, "opening table updates recipe availability") + overlay.close_panel() + cached = overlay._recipe_availability[planks] + inventory.add_item(5, 1) + _expect(overlay._recipes_dirty and is_same(cached, overlay._recipe_availability[planks]), "closed inventory defers capacity trials") + overlay.open_panel() + _expect(not overlay._recipes_dirty and not is_same(cached, overlay._recipe_availability[planks]), "reopening refreshes deferred inventory changes") + _expect(overlay._recipe_buttons[pick].disabled, "closing table removes table access") + var previous_scale := root.content_scale_factor + root.content_scale_factor = previous_scale * root.get_visible_rect().size.x / 640.0 + await process_frame + await process_frame + _expect(overlay._grid.columns == 5, "narrow layout reduces backpack columns") + _expect(overlay._panel.size.x <= root.get_visible_rect().size.x and overlay._panel.size.y <= root.get_visible_rect().size.y, "recipe book stays within narrow viewport") + _expect(overlay._scroll.follow_focus, "keyboard navigation follows focused recipes through scroll panel") + root.content_scale_factor = previous_scale + overlay.close_panel() + overlay.queue_free() + await process_frame + + +func _expect(condition: bool, message: String) -> void: + if not condition: + _failures += 1 + push_error(message) diff --git a/tools/inventory_ui_verify.gd.uid b/tools/inventory_ui_verify.gd.uid new file mode 100644 index 0000000..ae23df4 --- /dev/null +++ b/tools/inventory_ui_verify.gd.uid @@ -0,0 +1 @@ +uid://cntx2bg0hxcsg diff --git a/tools/item_drops_verify.gd b/tools/item_drops_verify.gd new file mode 100644 index 0000000..d055f0c --- /dev/null +++ b/tools/item_drops_verify.gd @@ -0,0 +1,159 @@ +## redot --headless --path . --script res://tools/item_drops_verify.gd +extends SceneTree + +class TestWorld extends VoxelWorld: + var resident := true + var wall := false + func _ready() -> void: + _blocks = BlockRegistry.new() + func _process(_delta: float) -> void: + pass + func is_collision_ready_at(at: Vector3) -> bool: + return resident and at.x < 16.0 and at.x >= 0.0 and at.z >= 0.0 and at.z < 16.0 + func get_block_world(at: Vector3i) -> int: + return BlockRegistry.BLOCK_STONE if at.y == 0 or (wall and at.x == 8) else BlockRegistry.BLOCK_AIR + func make_block_mesh(_id: int) -> ArrayMesh: + return ArrayMesh.new() + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var world := TestWorld.new() + root.add_child(world) + # Runtime loading resolves Player's autoload dependencies in --script mode. + var player = load("res://player/player.gd").new() + var head := Node3D.new() + head.name = "Head" + head.position.y = 1.65 + var camera := Camera3D.new() + camera.name = "Camera3D" + head.add_child(camera) + player.add_child(head) + root.add_child(player) + player.set_physics_process(false) + player.position = Vector3(4, 1.2, 4) + var inventory := ItemInventory.new(1) + var drops = load("res://game/item_drops.gd").new() + root.add_child(drops) + drops.setup(world, player, inventory) + drops.set_physics_process(false) + var stone := BlockRegistry.BLOCK_STONE + inventory.add_item(stone, 63) + drops.spawn_drop(Vector3(4, 2, 4), {"id": stone, "count": 5}) + drops._drops[0].velocity = Vector3.ZERO + var on_credit := func() -> void: + _expect(inventory.count_item(stone) + _count(drops) == 68, "save during inventory notification has consistent ownership") + inventory.changed.connect(on_credit) + _tick(drops, 3) + _expect(inventory.count_item(stone) == 63 and _count(drops) == 5, "throw delay prevents instant pickup credit") + _tick(drops, 40) + inventory.changed.disconnect(on_credit) + _expect(inventory.count_item(stone) == 64 and _count(drops) == 4, "partial pickup retains exact overflow") + _tick(drops, 100) + _expect(inventory.count_item(stone) == 64 and _count(drops) == 4, "full inventory never loses or duplicates drops") + inventory.remove_at(0, 64) + _tick(drops, 40) + _expect(inventory.count_item(stone) == 4 and drops.persistent_state().is_empty(), "overflow picked up after capacity opens") + + inventory.restore([]) + var tool := ItemRegistry.ITEM_IRON_PICKAXE + drops.spawn_drop(Vector3(4, 2, 4), {"id": tool, "count": 2, "durability": 17}) + drops._drops[0].velocity = Vector3.ZERO + _tick(drops, 40) + _expect(inventory.slots[0].get("durability") == 17 and inventory.count_item(tool) == 1 \ + and _count(drops) == 1, "tools remain unstacked and preserve wear") + var state: Array = drops.persistent_state() + var serialized: Array = JSON.parse_string(JSON.stringify(state)) + drops.restore(serialized) + var restored: Array = drops.persistent_state() + var saved_position: Array = serialized[0].position + _expect(restored[0].stack.id == int(serialized[0].stack.id) \ + and restored[0].stack.count == int(serialized[0].stack.count) \ + and restored[0].stack.durability == int(serialized[0].stack.durability) \ + and Vector3(drops._drops[0].position).is_equal_approx( \ + Vector3(saved_position[0], saved_position[1], saved_position[2])), "JSON persistence retains position, count and durability") + state[0].stack.count = 99 + _expect(_count(drops) == 1, "persistent snapshots do not alias live stacks") + drops.restore(serialized) + _expect(_count(drops) == 1, "restore replaces rather than duplicates") + inventory.restore([]) + player.dead = true + _tick(drops, 40) + _expect(inventory.count_item(tool) == 0 and _count(drops) == 1, "dead player cannot pick up") + player.dead = false + paused = true + var before: Array = drops.persistent_state() + _tick(drops, 20) + _expect(drops.persistent_state() == before and inventory.count_item(tool) == 0, "paused simulation cannot move or credit") + paused = false + world.resident = false + _tick(drops, 30) + _expect(drops.persistent_state() == before and inventory.count_item(tool) == 0, "unloaded drop freezes without pickup") + world.resident = true + _tick(drops, 40) + _expect(inventory.count_item(tool) == 1 and inventory.slots[0].durability == 17, "restored worn tool eventually collected") + + player.dead = true + drops.spawn_drop(Vector3(6, 8, 6), {"id": stone, "count": 3}) + _expect(drops._drops[0].velocity.y > 0.0, "spawn applies throwing impulse") + drops._drops[0].velocity = Vector3(1, -200, 0) + drops._physics_process(1.0) + _tick(drops, 100) + var at: Vector3 = drops._drops[0].position + _expect(at.y >= 1.0 + drops.RADIUS and at.y < 1.3 and at.x > 6.0, "substeps move laterally and cannot tunnel through floor") + _expect(Vector3(drops._drops[0].velocity).length() < 0.001, "ground friction settles throw") + before = drops.persistent_state() + player.position = Vector3(1000, 2, 1000) + _tick(drops, 100) + _expect(drops.persistent_state() == before, "far-away physics freezes without expiry") + player.position = Vector3(4, 1.2, 4) + drops._drops[0].position = Vector3(15.5, 4, 6) + drops._drops[0].velocity = Vector3(100, 0, 0) + drops._physics_process(0.1) + _expect(drops._drops[0].position.x + drops.RADIUS < 16.0, "sweep cannot enter an unloaded neighboring chunk") + world.wall = true + drops._drops[0].position = Vector3(7, 2, 4) + drops._drops[0].velocity = Vector3(100, 0, 0) + drops._physics_process(0.1) + _expect(drops._drops[0].position.x + drops.RADIUS < 8.0, "sweep cannot tunnel through voxel wall") + player.dead = false + player.position = Vector3(9, 1.2, 4) + drops._drops[0].position = Vector3(7.6, 2, 4) + drops._drops[0].age = 2.0 + inventory.restore([]) + _tick(drops, 40) + _expect(inventory.count_item(stone) == 0 and _count(drops) == 3, "magnet cannot collect through wall") + world.wall = false + _tick(drops, 40) + _expect(inventory.count_item(stone) == 3 and _count(drops) == 0, "magnet resumes when obstruction is removed") + + drops.restore([{}, {"position": [0, 0], "stack": {}}, {"position": [NAN, 2, 3], "stack": {"id": stone, "count": 1}}]) + _expect(drops.persistent_state().is_empty(), "malformed positions are rejected") + drops.free() + player.free() + world.free() + print("item_drops_verify: %d failures" % failures) + quit(1 if failures else 0) + + +func _tick(drops: Node, count: int) -> void: + for tick in count: + drops._physics_process(0.05) + + +func _count(drops: Node) -> int: + var count := 0 + for entry in drops.persistent_state(): + count += int(entry.stack.count) + return count + + +func _expect(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error(message) diff --git a/tools/item_drops_verify.gd.uid b/tools/item_drops_verify.gd.uid new file mode 100644 index 0000000..3be0141 --- /dev/null +++ b/tools/item_drops_verify.gd.uid @@ -0,0 +1 @@ +uid://5b64wyluchl6 diff --git a/tools/player_survival_verify.gd b/tools/player_survival_verify.gd new file mode 100644 index 0000000..d665f9f --- /dev/null +++ b/tools/player_survival_verify.gd @@ -0,0 +1,245 @@ +## redot --headless --path . --script res://tools/player_survival_verify.gd +## Runtime Player loading allows its autoload dependencies to resolve. +extends SceneTree + +class TestWorld extends VoxelWorld: + var sync_calls := 0 + func _ready() -> void: + _blocks = BlockRegistry.new() + func _process(_delta: float) -> void: + pass + func setup_player(_node: Node3D, sync_spawn_area := false) -> void: + if sync_spawn_area: + sync_calls += 1 + func find_safe_spawn(desired: Vector3) -> Vector3: + return desired + Vector3.UP + +var failures := 0 + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + var pick := InputEventMouseButton.new() + pick.button_index = MOUSE_BUTTON_MIDDLE + pick.pressed = true + _expect(pick.is_action_pressed("pick_block"), "middle click maps to pick action") + var world := TestWorld.new() + root.add_child(world) + var chunk := VoxelWorld.Chunk.new() + chunk.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + chunk.shape = CollisionShape3D.new() + chunk.shape.shape = BoxShape3D.new() + world.add_child(chunk.shape) + world._chunks[Vector2i.ZERO] = chunk + var player = load("res://player/player.gd").new() + player.game_mode = GameMode.SURVIVAL + var head := Node3D.new() + head.name = "Head" + head.position.y = 1.65 + var camera := Camera3D.new() + camera.name = "Camera3D" + head.add_child(camera) + player.add_child(head) + var capsule := CollisionShape3D.new() + capsule.shape = CapsuleShape3D.new() + capsule.shape.height = 1.8 + capsule.shape.radius = 0.3 + capsule.position.y = 0.9 + player.add_child(capsule) + root.add_child(player) + player.set_physics_process(false) + player.world = world + player._create_highlight() + player._create_held_block() + player.position = Vector3(4.5, 3.0, 4.5) + player.spawn_position = player.position + var deaths: Array[String] = [] + player.died.connect(func(cause: String) -> void: deaths.append(cause)) + player.take_damage(3.0, "test") + _expect(player.health == 17.0 and not player.dead, "damage") + player.take_damage(NAN) + _expect(player.health == 17.0, "nonfinite damage rejected") + player.hunger = 10.0 + _expect(player.eat(4.0) and player.hunger == 14.0, "food") + _expect(not player.eat(-1.0), "negative food rejected") + var state: Dictionary = player.persistent_state() + player.health = 1.0 + _expect(player.restore_persistent_state(state) and player.health == 17.0 and player.hunger == 14.0, "vitals roundtrip") + state["flying"] = true + state["game_mode"] = GameMode.CREATIVE + _expect(player.restore_persistent_state(state) and not player.flying and player.game_mode == GameMode.SURVIVAL, "survival ignores saved flight and mode") + player._handle_double_tap_jump() + player._handle_double_tap_jump() + _expect(not player.flying, "survival cannot enable flight") + player.take_damage(100.0, "test death") + player.take_damage(1.0) + _expect(player.dead and deaths == ["test death"] and not player.eat(4.0), "death fires once and blocks food") + state = player.persistent_state() + _expect(player.restore_persistent_state(state) and player.dead, "dead restore") + state["position"] = [4.5, -21.0, 4.5] + _expect(player.restore_persistent_state(state) and player.dead, "void death restore") + player.respawn() + _expect(not player.dead and player.health == 20.0 and player.hunger == 20.0 and player.air == 10.0, "respawn vitals") + _expect(world.sync_calls == 2 and player.position == player.spawn_position + Vector3.UP, "respawn streams and resolves safe spawn") + player.position = Vector3(4.5, 3.0, 4.5) + var eye := Vector3i(4, 4, 4) + _set_block(chunk.data, eye, BlockRegistry.BLOCK_WATER) + player.air = 0.1 + player._update_survival(1.0) + _expect(player.air == 0.0 and player.health == 18.0, "drowning") + _set_block(chunk.data, eye, BlockRegistry.BLOCK_AIR) + player._update_survival(1.0) + _expect(player.air == 4.0, "air refill") + player.hunger = 0.0 + player._update_survival(1.0) + _expect(player.health == 17.0, "starvation") + player.hunger = 20.0 + player._regen_clock = 3.0 + player._update_survival(1.0) + _expect(player.health == 18.0 and player.hunger < 20.0, "fed regeneration") + _set_block(chunk.data, Vector3i(4, 3, 4), BlockRegistry.BLOCK_LAVA) + player._update_survival(1.0) + _expect(player.health == 14.0, "lava") + _set_block(chunk.data, Vector3i(4, 3, 4), BlockRegistry.BLOCK_AIR) + _set_block(chunk.data, eye, BlockRegistry.BLOCK_STONE) + player._update_survival(1.0) + _expect(player.health == 13.0, "suffocation") + _set_block(chunk.data, eye, BlockRegistry.BLOCK_FIRE) + player._update_survival(1.0) + _expect(player.health == 11.0, "fire") + _set_block(chunk.data, eye, BlockRegistry.BLOCK_AIR) + var cell := Vector3i(4, 4, 2) + _set_block(chunk.data, cell, BlockRegistry.BLOCK_STONE) + player.has_target = true + player.target_block = cell + player.selected_block = 0 + var mined: Array = [] + var broken: Array = [] + player.mined_block.connect(func(pos: Vector3i, id: int, harvest: bool) -> void: mined.append([pos, id, harvest])) + player.block_broken.connect(func(id: int) -> void: broken.append(id)) + player._break_target() + player._tick_mining(0.01) + _expect(world.get_block_world(cell) == BlockRegistry.BLOCK_STONE and player.mining_progress > 0.0, "mining is timed") + _expect(player._effects.cracks.visible, "mining cracks") + player.selected_block = ItemRegistry.ITEM_WOOD_PICKAXE + player._tick_mining(0.1) + _expect(not player._mining and player.mining_progress == 0.0, "tool change cancels mining") + player._break_target() + player._tick_mining(BlockRegistry.break_seconds(BlockRegistry.BLOCK_STONE, player.selected_block) + 0.1) + _expect(world.get_block_world(cell) == 0 and mined.size() == 1 and broken.size() == 1 and mined[0][2], "completion signals and harvest") + _set_block(chunk.data, cell, BlockRegistry.BLOCK_STONE) + player.selected_block = 0 + player._break_target() + player._tick_mining(BlockRegistry.break_seconds(BlockRegistry.BLOCK_STONE, 0) + 0.1) + _expect(mined.size() == 2 and not mined[1][2], "wrong tool removes without harvesting") + _set_block(chunk.data, cell, BlockRegistry.BLOCK_BEDROCK) + player._break_target() + player._tick_mining(100.0) + _expect(world.get_block_world(cell) == BlockRegistry.BLOCK_BEDROCK and mined.size() == 2, "unbreakable mining") + _set_block(chunk.data, cell, BlockRegistry.BLOCK_STONE) + player._break_target() + player.target_block += Vector3i.RIGHT + player._tick_mining(100.0) + _expect(not player._mining and mined.size() == 2, "target change cancels mining") + player.target_block = cell + var interactions: Array = [] + player.interact_check = func(_pos: Vector3i) -> bool: return true + player.block_interacted.connect(func(pos: Vector3i) -> void: interactions.append(pos)) + player.can_place_check = func() -> bool: return false + player._place_target() + _expect(interactions == [cell], "interaction precedes placement validation") + player.third_person = true + player._effects.update_view(0.1) + _expect(camera.position.z > 0.0 and player._effects.body.visible and not player._held_block.visible, "third person") + player.third_person = false + player._effects.update_view(0.1) + _expect(camera.position == Vector3.ZERO and not player._effects.body.visible, "first person restored") + var picks: Array = [] + player.block_picked.connect(func(id: int) -> void: picks.append(id)) + # The headless display cannot capture a mouse; exercise the same action. + player._pick_target() + _expect(picks == [BlockRegistry.BLOCK_STONE], "middle pick") + player._break_target() + var release := InputEventMouseButton.new() + release.button_index = MOUSE_BUTTON_LEFT + player._unhandled_input(release) + _expect(not player._mining and not player._effects.cracks.visible, "release cancels mining") + var foods: Array = [] + player.item_used.connect(func(id: int, _pos: Vector3i) -> void: foods.append(id)) + player.selected_block = ItemRegistry.ITEM_COOKED_MEAT + player.has_target = false + player._place_target() + _expect(foods == [ItemRegistry.ITEM_COOKED_MEAT], "food works without target") + var floor_body := StaticBody3D.new() + var floor_shape := CollisionShape3D.new() + var floor_box := BoxShape3D.new() + floor_box.size = Vector3(2.0, 1.0, 2.0) + floor_shape.shape = floor_box + floor_body.add_child(floor_shape) + floor_body.position = Vector3(4.0, 2.5, 4.0) + root.add_child(floor_body) + await physics_frame + await process_frame + player.position = Vector3(4.5, 3.01, 4.5) + player.velocity = Vector3(10.0, 0.0, 0.0) + player._protect_edge(0.1) + _expect(player.velocity.x == 0.0, "crouch blocks unsupported step") + player.velocity = Vector3(-1.0, 0.0, 0.0) + player._protect_edge(0.1) + _expect(player.velocity.x == -1.0, "crouch permits supported step") + player.position = Vector3(4.0, 9.0, 4.0) + player.velocity = Vector3.ZERO + player.health = 20.0 + player.hunger = 10.0 + for tick in range(160): + player._physics_process(1.0 / 60.0) + _expect(player.is_on_floor() and player.health < 20.0 and player.health > 0.0, "landing fall damage") + # Simulate a separate Creative startup with hostile legacy session values. + player.game_mode = GameMode.CREATIVE + state = {"position": [4.5, 3.0, 4.5], "health": 0.0, "hunger": 1.0, + "air": 0.0, "dead": true, "flying": true, "game_mode": GameMode.SURVIVAL} + _expect(player.restore_persistent_state(state) and not player.dead and player.flying + and player.health == 20.0 and player.hunger == 20.0 and player.air == 10.0 + and player.game_mode == GameMode.CREATIVE, "creative ignores saved death, vitals and mode") + _set_block(chunk.data, eye, BlockRegistry.BLOCK_LAVA) + player._update_survival(100.0) + player.take_damage(100.0, "test") + _expect(player.health == 20.0 and player.hunger == 20.0 and player.air == 10.0 and not player.dead, "creative has no survival drain or damage") + player._handle_double_tap_jump() + player._handle_double_tap_jump() + _expect(not player.flying, "creative double tap toggles flight") + _set_block(chunk.data, cell, BlockRegistry.BLOCK_STONE) + player.has_target = true + player.target_block = cell + player.selected_block = 0 + player._break_target() + _expect(world.get_block_world(cell) == 0 and not player._mining, "creative mines instantly without a tool") + _set_block(chunk.data, cell, BlockRegistry.BLOCK_BEDROCK) + player._break_target() + _expect(world.get_block_world(cell) == BlockRegistry.BLOCK_BEDROCK, "creative respects unbreakable blocks") + player.position.y = -21.0 + player._physics_process(0.1) + _expect(player.position.y >= 0.0 and not player.dead and player.velocity == Vector3.ZERO, "creative void recovery does not stall below world") + state["position"] = [4.5, -21.0, 4.5] + _expect(not player.restore_persistent_state(state), "creative rejects saved void death position for startup spawn recovery") + floor_body.free() + player.free() + world.free() + # Let the headless audio mixer finish the two real mining cues before exit. + await create_timer(1.0).timeout + if failures == 0: + print("PLAYER SURVIVAL VERIFY: PASS") + quit(0 if failures == 0 else 1) + + +func _set_block(data: PackedByteArray, pos: Vector3i, id: int) -> void: + data[pos.x + pos.z * VoxelDefs.DATA_STRIDE_Z + pos.y * VoxelDefs.DATA_STRIDE_Y] = id + + +func _expect(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error("player_survival_verify: " + message) diff --git a/tools/player_survival_verify.gd.uid b/tools/player_survival_verify.gd.uid new file mode 100644 index 0000000..e6cbd60 --- /dev/null +++ b/tools/player_survival_verify.gd.uid @@ -0,0 +1 @@ +uid://15ddpu87ddbr diff --git a/tools/player_target_verify.gd b/tools/player_target_verify.gd index aefaafa..69eef3e 100644 --- a/tools/player_target_verify.gd +++ b/tools/player_target_verify.gd @@ -77,6 +77,7 @@ func _ready() -> void: push_error("player_target_verify: stream boundary blocked motion through loaded chunks") failed = true var flight_player := Player.new() + flight_player.game_mode = GameMode.CREATIVE var flight_shape := CollisionShape3D.new() flight_shape.shape = CapsuleShape3D.new() flight_player.add_child(flight_shape) diff --git a/tools/survival_progression_verify.gd b/tools/survival_progression_verify.gd new file mode 100644 index 0000000..e67a58e --- /dev/null +++ b/tools/survival_progression_verify.gd @@ -0,0 +1,401 @@ +## redot --headless --path . --script res://tools/survival_progression_verify.gd +## No starter supplies or injected recipe outputs: each species starts empty. +extends SceneTree + +const B = preload("res://world/block_registry.gd") +const I = preload("res://game/item_registry.gd") +const TABLE := Vector3i(7, 1, 4) +const FURNACE := Vector3i(7, 1, 6) +const MINE := Vector3i(4, 2, 4) +const SPECIES := { + "oak": B.BLOCK_LOG, "spruce": B.BLOCK_SPRUCE_LOG, + "birch": B.BLOCK_BIRCH_LOG, "acacia": B.BLOCK_ACACIA_LOG, + "jungle": B.BLOCK_JUNGLE_LOG, "mangrove": B.BLOCK_MANGROVE_LOG, +} + +class FixtureWorld extends VoxelWorld: + func _ready() -> void: + _blocks = BlockRegistry.new() + func _process(_delta: float) -> void: + pass + func make_block_mesh(_id: int) -> ArrayMesh: + return ArrayMesh.new() + +var failures := 0 +var _species := "" +var _main: Node +var _world: FixtureWorld +var _ui: Node +var _chunk: VoxelWorld.Chunk + + +func _initialize() -> void: + call_deferred("_run") + + +func _run() -> void: + # Main/Player reference autoloads, so resolve them after SceneTree startup. + for species: String in SPECIES: + _species = species + var previous_failures := failures + _setup() + _chain(int(SPECIES[species])) + _teardown() + print(" %s progression: %s" % [species, "PASS" if failures == previous_failures else "FAIL"]) + await process_frame + await create_timer(0.6).timeout + print("SURVIVAL PROGRESSION VERIFY: %s" % ("PASS" if failures == 0 else "FAIL (%d)" % failures)) + quit(0 if failures == 0 else 1) + + +func _setup() -> void: + _world = FixtureWorld.new() + root.add_child(_world) + _chunk = VoxelWorld.Chunk.new() + _chunk.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + _chunk.shape = CollisionShape3D.new() + _chunk.shape.shape = BoxShape3D.new() + _world.add_child(_chunk.shape) + _world._chunks[Vector2i.ZERO] = _chunk + for x in 16: + for z in 16: + _set_block(Vector3i(x, 0, z), B.BLOCK_STONE) + _main = load("res://game/main.gd").new() + _main._game_mode = GameMode.SURVIVAL + _main.world = _world + _main.player = load("res://player/player.gd").new() + _main.player.game_mode = GameMode.SURVIVAL + var head := Node3D.new() + head.name = "Head" + head.position.y = 1.65 + var camera := Camera3D.new() + camera.name = "Camera3D" + head.add_child(camera) + _main.player.add_child(head) + root.add_child(_main.player) + _main.player.set_physics_process(false) + _main.player.position = Vector3(4.5, 1.2, 4.5) + _main.player.world = _world + _main._status_label = Label.new() + root.add_child(_main._status_label) + _main._restore_session_state({}) + _main._drops = load("res://game/item_drops.gd").new() + root.add_child(_main._drops) + _main._drops.setup(_world, _main.player, _main.inventory) + _main._drops.set_physics_process(false) + _ui = load("res://ui/inventory_overlay.tscn").instantiate() + root.add_child(_ui) + _ui.configure_inventory(_main.inventory, null) + _ui.set_game_mode(GameMode.SURVIVAL) + _main._inventory_overlay = _ui + _main._connect_player() + _expect(_main.inventory.slots.all(func(slot: Dictionary) -> bool: return slot.is_empty()), "empty Survival startup") + + +func _chain(log_id: int) -> void: + # Six logs: four -> 16 planks (table 4, sticks 4, pick 3, fuel 2, + # bowls 3); two -> charcoal (one torch batch, one furnace fuel). + # Eleven ordinary stone -> three cobble for pick plus eight for furnace. + _mine(log_id, 6) + _ui.open_panel() + _craft("planks_" + _species, 4) + _craft("sticks", 2) + _craft("crafting_table") + var before: Array = _main.inventory.persistent_state() + _expect(CraftingRecipes.max_craftable(_main.inventory, "wood_pickaxe", false) == 0, "table guard maximum") + _expect(CraftingRecipes.blocking_reason(_main.inventory, "wood_pickaxe", false).contains("table"), "table guard explanation") + _ui._craft("wood_pickaxe") + _expect(_main.inventory.persistent_state() == before, "inventory UI cannot craft table-only pick despite owning materials") + _place_station(B.BLOCK_CRAFTING_TABLE, TABLE) + _open(TABLE) + _expect(_ui._at_table, "placed table opened via Player/Main") + _craft("wood_pickaxe") + _select(I.ITEM_WOOD_PICKAXE) + _mine(B.BLOCK_IRON_ORE, 1, false) + _expect(_main.inventory.count_item(B.BLOCK_IRON_ORE) == 0, "wood pick cannot harvest iron") + _mine(B.BLOCK_STONE, 11) + _expect(_main.inventory.count_item(B.BLOCK_STONE) == 0 and _main.inventory.count_item(B.BLOCK_COBBLESTONE) == 11, "ordinary stone actually yields cobble") + _expect(_durability(I.ITEM_WOOD_PICKAXE) == I.max_durability(I.ITEM_WOOD_PICKAXE) - 12, "Main wears wood pick for successful and failed harvests") + _ui.open_panel() + before = _main.inventory.persistent_state() + _ui._craft("stone_pickaxe") + _ui._craft_max("furnace") + _expect(not _ui._at_table and _main.inventory.persistent_state() == before, "closing table revokes access for single and max crafting") + _open(TABLE) + _craft("stone_pickaxe") + _craft("furnace") + _place_station(B.BLOCK_FURNACE, FURNACE) + _open(FURNACE) + _expect(not _ui._at_table, "furnace is not a crafting table") + var furnace: ItemInventory = _main.containers.get_inventory(FURNACE) + _move(_main.inventory, _find(_main.inventory, log_id), furnace, BlockContainers.INPUT, 2) + _move(_main.inventory, _find(_main.inventory, B.BLOCK_PLANKS), furnace, BlockContainers.FUEL, 2) + _ui.close_panel() + _main.containers.tick(5.0) + _expect(furnace.slots[2].is_empty() and _main.containers.get_furnace(FURNACE).progress == 5.0, "charcoal needs actual smelting time") + _reload_midway() + furnace = _main.containers.get_inventory(FURNACE) + _main.containers.tick(15.0) + _expect(furnace.count_item(I.ITEM_CHARCOAL) == 2 and furnace.slots[0].is_empty() and furnace.slots[1].is_empty(), "reload resumes both log smelts without granting fuel") + _take_output(I.ITEM_CHARCOAL, 2) + _ui.open_panel() + _craft("charcoal_torches") + _expect(_main.inventory.count_item(B.BLOCK_TORCH) == 4, "renewable charcoal torches") + _select(I.ITEM_STONE_PICKAXE) + _mine(B.BLOCK_GOLD_ORE, 1, false) + _mine(B.BLOCK_IRON_ORE, 3) + _expect(_durability(I.ITEM_STONE_PICKAXE) == I.max_durability(I.ITEM_STONE_PICKAXE) - 4, "stone pick wear and iron gate") + _smelt(B.BLOCK_IRON_ORE, I.ITEM_IRON_INGOT, 3, I.ITEM_CHARCOAL) + _open(TABLE) + _craft("iron_pickaxe") + _select(I.ITEM_IRON_PICKAXE) + _mine(B.BLOCK_GOLD_ORE) + _smelt(B.BLOCK_GOLD_ORE, I.ITEM_GOLD_INGOT) + _expect(_durability(I.ITEM_IRON_PICKAXE) == I.max_durability(I.ITEM_IRON_PICKAXE) - 1, "iron pick harvests gold with real wear") + _expect(_main.inventory.count_item(I.ITEM_STICK) == 1 and _main.inventory.count_item(B.BLOCK_COBBLESTONE) == 0, "derived chain resource totals") + _food() + _legacy_smoke() + + +func _mine(id: int, count: int = 1, harvest: bool = true) -> void: + _ui.close_panel() + for index in count: + # Only natural raw blocks enter the fixture, never inventory or drops. + _set_block(MINE, id) + var before: Array = _main.inventory.persistent_state() + var tool: int = _main.player.selected_block + _expect(B.can_harvest(id, tool) == harvest, "harvest gate for %d using %d" % [id, tool]) + _main.player.has_target = true + _main.player.target_block = MINE + _main.player._break_target() + var seconds: float = B.break_seconds(id, tool) + _main.player._tick_mining(seconds * 0.25) + _expect(_world.get_block_world(MINE) == id, "mining cannot complete before its duration") + _main.player._tick_mining(seconds + 0.1) + _expect(_world.get_block_world(MINE) == B.BLOCK_AIR, "timed Player mining removed fixture block") + var drops: Array = _main._drops.persistent_state() + _expect(drops.size() == (1 if harvest else 0), "Main spawns only harvestable drops, never failed-harvest tool drops") + if harvest and drops.size() == 1: + var expected_id := B.BLOCK_COBBLESTONE if id == B.BLOCK_STONE else (I.ITEM_COAL if id == B.BLOCK_COAL_ORE else id) + _expect(drops[0].stack.id == expected_id and drops[0].stack.count == 1, "physical drop mapping") + var old_count: int = _main.inventory.count_item(expected_id) + var previous_count := 0 + for stack: Dictionary in before: + if stack.get("id", 0) == expected_id: + previous_count += int(stack.count) + _expect(old_count == previous_count, "Main never directly credits mining inventory") + _pickup() + _expect(_main.inventory.count_item(expected_id) == old_count + 1, "physical pickup credits exactly one harvested item") + else: + for slot in before.size(): + if slot != _main.selected_slot: + _expect(_main.inventory.slots[slot] == before[slot], "failed harvest cannot grant inventory items") + + +func _pickup() -> void: + # Keep deterministic motion, but retain the real delay, collision, magnet, + # capacity and ownership-transfer path instead of crediting inventory. + for drop in _main._drops._drops: + drop.velocity = Vector3.ZERO + for tick in 50: + _main._drops._physics_process(0.05) + _expect(_main._drops.persistent_state().is_empty(), "all physical drops collected") + + +func _craft(id: String, batches: int = 1) -> void: + var recipe := CraftingRecipes.get_recipe(id) + var before: int = _main.inventory.count_item(recipe.output) + var ingredients := {} + for ingredient in recipe.ingredients: + ingredients[ingredient] = _main.inventory.count_item(ingredient) + _expect(CraftingRecipes.max_craftable(_main.inventory, id, _ui._at_table) >= batches, "affordable recipe " + id) + _ui._craft(id, batches) + _expect(_main.inventory.count_item(recipe.output) == before + int(recipe.count) * batches, "UI craft output " + id) + for ingredient in recipe.ingredients: + _expect(_main.inventory.count_item(ingredient) == int(ingredients[ingredient]) - int(recipe.ingredients[ingredient]) * batches, "UI consumes exact ingredients for " + id) + + +func _select(id: int) -> void: + var index := _find(_main.inventory, id) + _expect(index >= 0, "select earned item %d" % id) + if index < 0: + return + if index >= ItemInventory.HOTBAR_SIZE: + _main.inventory.move_stack(index, 0) + index = 0 + _main.select_slot(index) + + +func _place_station(id: int, position: Vector3i) -> void: + _ui.close_panel() + _select(id) + var before: int = _main.inventory.count_item(id) + _main.player.has_target = true + _main.player.target_block = position + Vector3i.DOWN + _main.player.target_normal = Vector3i.UP + _main.player._place_target() + _expect(_world.get_block_world(position) == id and _main.inventory.count_item(id) == before - 1, "Player places earned station and Main consumes it") + + +func _open(position: Vector3i) -> void: + _main.player.has_target = true + _main.player.target_block = position + _main.player._place_target() + _expect(_ui.visible and _ui._station_kind == _world.get_block_world(position), "Player/Main station interaction") + + +func _move(source: ItemInventory, from: int, target: ItemInventory, to: int, count: int) -> void: + _expect(from >= 0 and to >= 0, "transfer source and destination exist") + if from < 0 or to < 0: + return + var stack: Dictionary = source.slots[from].duplicate() + _expect(int(stack.get("count", 0)) >= count and target.slots[to].is_empty(), "transfer owns enough items and destination is empty") + if int(stack.get("count", 0)) < count or not target.slots[to].is_empty(): + return + if target == _main.containers.get_inventory(FURNACE): + _expect(_main.containers.can_insert(FURNACE, to, int(stack.id)), "furnace insertion gate") + if not source.remove_at(from, count): + return + stack.count = count + target.slots[to] = stack + target.changed.emit() + + +func _take_output(id: int, count: int) -> void: + _open(FURNACE) + var furnace: ItemInventory = _main.containers.get_inventory(FURNACE) + _expect(furnace.count_item(id) == count, "furnace output quantity %d" % id) + var empty := _find(_main.inventory, 0) + if empty >= 0: + var drag: Dictionary = _ui.drag_data(_ui._slots[42]) + _expect(_ui.drop_stack(_ui._slots[empty], drag), "UI extracts actual furnace output") + _expect(furnace.slots[2].is_empty(), "output transferred, not copied") + _ui.close_panel() + + +func _smelt(input: int, output: int, count: int = 1, fuel: int = 0) -> void: + var furnace: ItemInventory = _main.containers.get_inventory(FURNACE) + _move(_main.inventory, _find(_main.inventory, input), furnace, 0, count) + if fuel != 0: + _move(_main.inventory, _find(_main.inventory, fuel), furnace, 1, 1) + _main.containers.tick(CraftingRecipes.SMELT_SECONDS * count) + _take_output(output, count) + + +func _reload_midway() -> void: + _ui.close_panel() + var state := {"state_version": 2, "inventory": _main.inventory.persistent_state(), + "containers": _main.containers.persistent_state(), "selected_slot": _main.selected_slot, + "day_night": null, "weather": null} + var serialized: Dictionary = JSON.parse_string(JSON.stringify(state)) + _main.inventory = ItemInventory.new() + _main.containers = BlockContainers.new() + _main._restore_session_state(_main._migrate_session_state(serialized)) + _main._drops.setup(_world, _main.player, _main.inventory) + _ui.configure_inventory(_main.inventory, null) + _expect(_main.inventory.persistent_state() == state.inventory, "Main reload preserves earned items and worn tools") + _expect(_main.containers.persistent_state() == state.containers, "Main reload preserves furnace slots, progress and fuel") + _main.select_slot(_main.selected_slot) + + +func _food() -> void: + # Select a non-tool earned stack so gathering food does not hide pick wear. + _select(I.ITEM_STICK) + _mine(B.BLOCK_MELON) + _select(B.BLOCK_MELON) + _main.player.hunger = 10.0 + _main._on_item_used(B.BLOCK_MELON, Vector3i.ZERO) + _expect(_main.player.hunger == 10.0 and _main.inventory.count_item(B.BLOCK_MELON) == 1, "whole melon is not food") + _ui.open_panel() + _craft("melon_slices") + _select(I.ITEM_MELON_SLICE) + _main.player.hunger = 20.0 + _main.player.has_target = false + _main.player._place_target() + _expect(_main.inventory.count_item(I.ITEM_MELON_SLICE) == 4, "full hunger does not consume earned food") + _eat() + _expect(_main.player.hunger == 12.0 and _main.inventory.count_item(I.ITEM_MELON_SLICE) == 3, "crafted melon slices feed player") + _mine(B.BLOCK_KELP) + _smelt(B.BLOCK_KELP, I.ITEM_DRIED_KELP) + _select(I.ITEM_DRIED_KELP) + _eat() + _expect(_main.player.hunger == 11.0 and _main.inventory.count_item(I.ITEM_DRIED_KELP) == 0, "harvested kelp smelts into edible food") + _select(I.ITEM_STICK) + _mine(B.BLOCK_BROWN_MUSHROOM, 2) + _mine(B.BLOCK_RED_MUSHROOM, 2) + _ui.open_panel() + _craft("bowls") + _craft("mushroom_stew", 2) + _expect(_main.inventory.count_item(B.BLOCK_PLANKS) == 0, "six-log budget fully accounted for") + _ui.close_panel() + _mine(B.BLOCK_DIRT, 40) + # Stash only earned goods, then spread harvested dirt across the backpack. + # This creates a genuinely full inventory without injecting filler stacks. + var stash := ItemInventory.new() + for index in _main.inventory.slots.size(): + var stack: Dictionary = _main.inventory.slots[index] + if not stack.is_empty() and stack.id not in [I.ITEM_MUSHROOM_STEW, B.BLOCK_DIRT]: + _move(_main.inventory, index, stash, _find(stash, 0), int(stack.count)) + _select(I.ITEM_MUSHROOM_STEW) + var dirt_slot := _find(_main.inventory, B.BLOCK_DIRT) + for index in _main.inventory.slots.size(): + if _main.inventory.slots[index].is_empty(): + _move(_main.inventory, dirt_slot, _main.inventory, index, 1) + _expect(_find(_main.inventory, 0) == -1, "food test inventory is full") + _eat() + _expect(_main.player.hunger == 16.0 and _main.inventory.count_item(I.ITEM_MUSHROOM_STEW) == 1, "stacked stew consumes one serving") + var drops: Array = _main._drops.persistent_state() + _expect(drops.size() == 1 and drops[0].stack.id == I.ITEM_BOWL and drops[0].stack.count == 1, "full inventory returns overflow bowl as physical drop") + _eat() + _expect(_main.player.hunger == 16.0 and _main.inventory.slots[_main.selected_slot].get("id") == I.ITEM_BOWL, "last stew returns bowl in newly freed slot") + _pickup() + _expect(_main.inventory.count_item(I.ITEM_BOWL) == 2 and stash.count_item(I.ITEM_BOWL) == 2, "all four crafted bowls conserved after eating and pickup") + + +func _eat() -> void: + _main.player.hunger = 10.0 + _main.player.has_target = false + _main.player._place_target() + + +func _legacy_smoke() -> void: + # Separate migration smoke, never a source of goods for the survival chain. + var legacy: Node = load("res://game/main.gd").new() + legacy._game_mode = GameMode.SURVIVAL + legacy._restore_session_state(legacy._migrate_session_state({"state_version": 1, + "inventory": [[B.BLOCK_LOG, 2], [I.ITEM_WOOD_PICKAXE, 1]], + "day_night": null, "weather": null})) + _expect(legacy.inventory.count_item(B.BLOCK_LOG) == 2 and legacy.inventory.count_item(I.ITEM_WOOD_PICKAXE) == 1, "legacy count save still migrates") + _expect(CraftingRecipes.craft(legacy.inventory, "planks_oak", false), "legacy three-argument crafting still works") + legacy.free() + + +func _find(inventory: ItemInventory, id: int) -> int: + for index in inventory.slots.size(): + if int(inventory.slots[index].get("id", 0)) == id: + return index + return -1 + + +func _durability(id: int) -> int: + var index := _find(_main.inventory, id) + return int(_main.inventory.slots[index].get("durability", 0)) if index >= 0 else 0 + + +func _set_block(position: Vector3i, id: int) -> void: + _chunk.data[position.x + position.z * VoxelDefs.DATA_STRIDE_Z + position.y * VoxelDefs.DATA_STRIDE_Y] = id + + +func _teardown() -> void: + _ui.free() + _main._drops.free() + _main._status_label.free() + _main.player.free() + _world.free() + _main.free() + + +func _expect(condition: bool, message: String) -> void: + if not condition: + failures += 1 + push_error("survival_progression_verify [%s]: %s" % [_species, message]) diff --git a/tools/survival_progression_verify.gd.uid b/tools/survival_progression_verify.gd.uid new file mode 100644 index 0000000..09b051c --- /dev/null +++ b/tools/survival_progression_verify.gd.uid @@ -0,0 +1 @@ +uid://bf7uoi4hghqvj diff --git a/tools/ui_flow_verify.gd b/tools/ui_flow_verify.gd index 437d6bc..9ed7d36 100644 --- a/tools/ui_flow_verify.gd +++ b/tools/ui_flow_verify.gd @@ -18,6 +18,19 @@ func _initialize() -> void: func _run() -> void: var game_config: Node = root.get_node("GameConfig") + _expect(game_config.get_game_mode() == GameMode.SURVIVAL, "new session must default to Survival") + var legacy := {"id": "mode-test", "worldgen": {}} + _expect(game_config.activate_world(legacy), "legacy activation rejected") + _expect(game_config.get_game_mode() == GameMode.CREATIVE, "legacy activation must be Creative") + for invalid in [true, "1", 0.5, 2, null]: + _expect(not game_config.activate_world({"id": "bad", "worldgen": {}, "game_mode": invalid}), "invalid activation accepted") + _expect(game_config.active_world_id == "mode-test", "invalid activation changed active world") + _expect(game_config.activate_world({"id": "survival", "worldgen": {}, "game_mode": 1.0}), "JSON Survival activation rejected") + _expect(game_config.get_game_mode() == GameMode.SURVIVAL, "active Survival mode lost") + game_config.pending_game_mode = GameMode.CREATIVE + game_config.clear_active_world() + _expect(game_config.get_game_mode() == GameMode.CREATIVE, "clear reset pending mode") + game_config.pending_game_mode = GameMode.SURVIVAL var menu: Node = load("res://ui/main_menu.tscn").instantiate() root.add_child(menu) await process_frame @@ -32,12 +45,18 @@ func _run() -> void: menu._on_play() await process_frame _expect(play.visible, "Play did not open the worlds hub") + _expect(not play._mode_row.visible and not play._mode_hint.visible, "landing shows creation mode controls") _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/LandingBox/NewWorldButton"), "worlds hub did not focus New World for an empty library") play.get_node("Center/Panel/Box/LandingBox/NewWorldButton").pressed.emit() await process_frame _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/SeedRow/SeedField"), "world setup did not focus the seed field") _expect(play.get_node("Center/Panel/Box/TypeRow/TypeOption").item_count == 3, "world type list is incomplete") + _expect(play.get_selected_game_mode() == GameMode.SURVIVAL, "creation selector must default to Survival") + _expect(play._mode_option.item_count == 2 and play._mode_row.visible, "creation mode selector missing") + _expect("locked after creation" in play._mode_hint.text, "creation mode lock explanation missing") + play._mode_option.select(0) + _expect(play.get_selected_game_mode() == GameMode.CREATIVE, "Creative selection not exposed") # Advanced -> back restores the play screen. play.advanced_requested.emit() @@ -46,6 +65,7 @@ func _run() -> void: worldgen.close_panel() await process_frame _expect(play.visible and not worldgen.visible, "closing advanced should return to the play screen") + _expect(play.get_selected_game_mode() == GameMode.CREATIVE, "advanced reset selected mode") _expect(root.gui_get_focus_owner() == play.get_node("Center/Panel/Box/Footer/AdvancedButton"), "advanced did not restore focus") # Config merge payload keeps every tunable the old combined panel created. @@ -67,6 +87,19 @@ func _run() -> void: play.get_node("Center/Panel/Box/LandingBox/LoadWorldButton").pressed.emit() await process_frame _expect(play._cards.size() == 3, "load view did not list every saved world") + _expect(not play._mode_row.visible and not play._mode_hint.visible, "load view exposes mode editing") + var mode_labels := (play._cards["alpha"] as Node).find_children("GameModeLabel", "Label", true, false) + _expect(mode_labels.size() == 1 and mode_labels[0].text == "Creative", "saved card mode label missing") + var mode_card: Button = play._cards["alpha"] + var card_content: MarginContainer = mode_card.get_node("Content") + await process_frame + _expect(mode_card.size.y >= card_content.get_combined_minimum_size().y, + "world card clips mode label or bottom padding") + mode_labels[0].add_theme_font_size_override("font_size", 30) + for frame in 4: + await process_frame + _expect(mode_card.size.y >= card_content.get_combined_minimum_size().y, + "world card does not grow with mode label text size") var future_card: Button = play._cards.get("future") _expect(future_card != null and not future_card.disabled, "incompatible world cannot be selected for deletion") diff --git a/tools/world_storage_verify.gd b/tools/world_storage_verify.gd index 074da69..a174442 100644 --- a/tools/world_storage_verify.gd +++ b/tools/world_storage_verify.gd @@ -5,6 +5,7 @@ var _root := "user://world_storage_verify_%d" % Time.get_ticks_usec() func _initialize() -> void: + _verify_game_modes() _verify_v1_compatibility_fixture() _verify_round_trip_and_regions() _verify_future_metadata_rejection() @@ -22,6 +23,49 @@ func _initialize() -> void: quit(1) +func _verify_game_modes() -> void: + for value in [0, 1, 0.0, 1.0]: + _expect(GameMode.is_valid(value), "valid mode rejected: %s" % value) + for value in [true, false, "0", "1", null, -1, 2, 0.5, 1.5, INF, NAN, [], {}]: + _expect(not GameMode.is_valid(value), "invalid mode accepted: %s" % str(value)) + var storage := WorldStorage.new(_root) + _expect(storage.create_world({}, {}, "bad-mode", false, 2).is_empty(), "invalid creation mode accepted") + for mode in [GameMode.CREATIVE, GameMode.SURVIVAL]: + var id := "mode-%d" % mode + var created := storage.create_world({}, {}, id, false, mode) + _expect(created.get("game_mode", -1) == mode, "creation lost mode") + _expect(storage.flush({"test": true}) == OK, "mode fixture flush failed") + _expect(storage.open_world(id).get("game_mode", -1) == mode, "reopen lost mode") + _expect(WorldStorage.duplicate_world(id, _root).get("game_mode", -1) == mode, "duplicate lost mode") + var backup := WorldStorage.backup_world(id, _root, _root + "-backups") + var saved: Variant = JSON.parse_string(FileAccess.get_file_as_string(backup + "/metadata.json")) + _expect(saved is Dictionary and saved.get("game_mode", -1) == mode, "backup lost mode") + var path := _root + "/" + id + "/metadata.json" + var file := FileAccess.open(path, FileAccess.WRITE) + file.store_string("{}") + file = null + _expect(storage.open_world(id).get("game_mode", -1) == mode, "recovery backup lost mode") + var legacy := storage.create_world({}, {}, "legacy-mode", false) + _expect(legacy.get("game_mode", -1) == GameMode.CREATIVE, "old caller default changed") + legacy.erase("game_mode") + var legacy_path := _root + "/legacy-mode/metadata.json" + var fixture := FileAccess.open(legacy_path, FileAccess.WRITE) + fixture.store_string(JSON.stringify(legacy)) + fixture = null + _expect(storage.open_world("legacy-mode").get("game_mode", -1) == GameMode.CREATIVE, "legacy absent mode not creative") + for invalid in [true, "1", 0.5, 2, null]: + legacy["game_mode"] = invalid + fixture = FileAccess.open(legacy_path, FileAccess.WRITE) + fixture.store_string(JSON.stringify(legacy)) + fixture = null + _expect(storage.open_world("legacy-mode").is_empty(), "invalid metadata mode loaded") + for summary in WorldStorage.list_world_summaries(_root): + if summary["id"] == "legacy-mode": + _expect(not summary["compatible"], "invalid mode summary is loadable") + elif String(summary["id"]).begins_with("mode-"): + _expect(summary["game_mode"] == int(String(summary["id"]).trim_prefix("mode-")), "summary lost mode") + + func _verify_v1_compatibility_fixture() -> void: var storage := WorldStorage.new(_root) var config := WorldGenConfig.new({"seed": -10101}).to_dictionary() diff --git a/tools/worldgen_ore_verify.gd b/tools/worldgen_ore_verify.gd index a616ca9..562d193 100644 --- a/tools/worldgen_ore_verify.gd +++ b/tools/worldgen_ore_verify.gd @@ -26,6 +26,7 @@ func _initialize() -> void: _verify_catalog(populator) _verify_stage_fingerprints(populator) _verify_replacement_semantics(populator) + _verify_generator_versions() if _failures.is_empty(): print("WORLDGEN ORE VERIFY: PASS") quit(0) @@ -80,6 +81,37 @@ func _verify_replacement_semantics(populator: VoxelPopulator) -> void: _expect(data == before, "ore generation replaced a non-stone block") +func _verify_generator_versions() -> void: + var generator := TerrainGenerator.new() + _expect(WorldGenConfig.new().worldgen_version == 14, "new worlds must default to v14") + for version in [13, 14]: + generator.configure({"seed": 123456789, "worldgen_version": version}) + var data: PackedByteArray = generator.generate_data(Vector2i.ZERO, {}).data + _expect(_sha256(data) == "8a589915d1d2e84b4f8f38a312f1176fa894ec62cce323b6c0136fed756715e7", + "v%d default cave fixture changed" % version) + for world_type in [WorldGenConfig.WORLD_TYPE_NORMAL, WorldGenConfig.WORLD_TYPE_AMPLIFIED, + WorldGenConfig.WORLD_TYPE_FLAT]: + generator.configure({"seed": 123456789, "worldgen_version": version, + "cave_density": 0.0, "world_type": world_type}) + data = generator.generate_data(Vector2i.ZERO, {}).data + var total := 0 + for count in _ore_counts(data).values(): + total += int(count) + var needs_ores: bool = version >= 14 and world_type != WorldGenConfig.WORLD_TYPE_FLAT + _expect(total > 0 if needs_ores else total == 0, + "v%d type %d cave-free ore count: %d" % [version, world_type, total]) + print("CAVE-FREE v", version, " type=", world_type, " ores=", total) + # Flat layout remains byte-identical regardless of the ore version or caves. + generator.configure({"seed": 123456789, "worldgen_version": 13, + "world_type": WorldGenConfig.WORLD_TYPE_FLAT}) + var flat_before: PackedByteArray = generator.generate_data(Vector2i.ZERO, {}).data + for density in [0.0, 1.0]: + generator.configure({"seed": 123456789, "worldgen_version": 14, + "world_type": WorldGenConfig.WORLD_TYPE_FLAT, "cave_density": density}) + _expect(generator.generate_data(Vector2i.ZERO, {}).data == flat_before, + "v14 changed the Flat layout at cave density %s" % density) + + func _stone_volume() -> PackedByteArray: var data := PackedByteArray() data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) diff --git a/tools/worldgen_water_verify.gd b/tools/worldgen_water_verify.gd index 04d36ea..faff4a0 100644 --- a/tools/worldgen_water_verify.gd +++ b/tools/worldgen_water_verify.gd @@ -19,6 +19,7 @@ var _failures := PackedStringArray() func _initialize() -> void: + _verify_flat_world_is_dry() _verify_determinism() _verify_legacy_compatibility() _verify_adjacent_chunk_borders() @@ -33,6 +34,18 @@ func _initialize() -> void: quit(1) +func _verify_flat_world_is_dry() -> void: + var config := TEST_CONFIG.duplicate() + config["world_type"] = WorldGenConfig.WORLD_TYPE_FLAT + var generator := TerrainGenerator.new() + generator.configure(config) + var full := generator.generate_data(Vector2i.ZERO, {}, false) + var lod := generator.generate_data(Vector2i.ZERO, {}, true) + _expect(not full.data.has(BlockRegistry.BLOCK_WATER), "flat world flooded below sea level") + for water_y in lod.lod_water_y: + _expect(water_y == -1, "flat LOD world contains water") + + func _verify_determinism() -> void: var samples := 0 for seed in SEEDS: diff --git a/ui/inventory_overlay.gd b/ui/inventory_overlay.gd index 2c40367..1c89640 100644 --- a/ui/inventory_overlay.gd +++ b/ui/inventory_overlay.gd @@ -22,7 +22,41 @@ const TIME_BUTTONS := [ @onready var _hint: Label = $Center/Panel/Box/Hint @onready var _dim: ColorRect = $Dim -var _slot_width := 180.0 +var _inventory: ItemInventory +var _world: VoxelWorld +var _station_inventory: ItemInventory +var _at_table := false +var _scroll: ScrollContainer +var _station_box: VBoxContainer +var _station_grid: GridContainer +var _recipes: VBoxContainer +var _status: Label +var _slots: Array[InventorySlot] = [] +var _selected: Dictionary = {} +var _session := 0 +var _previous_focus: Control +var _containers: BlockContainers +var _station_position := Vector3i.ZERO +var _station_kind := 0 +var _furnace_status: Label +var _recipe_buttons: Array[Button] = [] +var _legacy_inventory := false +var _game_mode: int = GameMode.SURVIVAL +var _creative_controls: Array[Control] = [] +var _catalog: VBoxContainer +var _catalog_grid: GridContainer +var _catalog_buttons: Dictionary = {} +const RECIPE_CATEGORIES := ["Basics", "Tools", "Storage", "Food", "Building", "Lighting"] +var _recipe_search: LineEdit +var _recipe_category: OptionButton +var _craftable_only: CheckBox +var _recipe_cards: Array[VBoxContainer] = [] +var _recipe_requirements: Array[Label] = [] +var _recipe_max_buttons: Array[Button] = [] +var _recipe_groups: Dictionary = {} +var _recipe_empty: Label +var _recipe_availability: Array[Dictionary] = [] +var _recipes_dirty := true func _ready() -> void: @@ -32,6 +66,10 @@ func _ready() -> void: var button := _time_row.get_node(entry["node"]) as Button button.pressed.connect(_on_time_button.bind(entry["hours"])) _weather_button.pressed.connect(_on_weather_button) + _build_contents() + set_game_mode(_game_mode) + get_viewport().size_changed.connect(_apply_responsive_layout) + GameConfig.interface_scale_changed.connect(_apply_responsive_layout) func _style_static() -> void: @@ -66,11 +104,12 @@ func _style_static() -> void: var weather_eyebrow := UITheme.eyebrow("Weather") box.add_child(weather_eyebrow) box.move_child(weather_eyebrow, _weather_row.get_index()) + _creative_controls.assign([time_eyebrow, strip, weather_eyebrow, _weather_row]) _weather_row.alignment = BoxContainer.ALIGNMENT_BEGIN UITheme.style_button_ghost(_weather_button) _weather_button.custom_minimum_size = Vector2(240.0, 38.0) - _grid.columns = 4 + _grid.columns = 10 _grid.add_theme_constant_override("h_separation", 8) _grid.add_theme_constant_override("v_separation", 8) _hint.add_theme_color_override("font_color", UITheme.MUTED) @@ -80,7 +119,8 @@ func _style_static() -> void: ## The close hint follows the live inventory binding. func _refresh_hint() -> void: - _hint.text = "Press %s or ESC to close" % GameConfig.input_key("inventory") + _hint.text = "Drag or click two slots to move. Shift: half stack.\nPress %s or ESC to close" % GameConfig.input_key("inventory") + _hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART func _unhandled_input(event: InputEvent) -> void: @@ -94,102 +134,474 @@ func _unhandled_input(event: InputEvent) -> void: func open_panel() -> void: + if visible: + return + _previous_focus = get_viewport().gui_get_focus_owner() + _session += 1 _apply_responsive_layout() _refresh_hint() + _refresh() visible = true Motion.dim_in(_dim) Motion.pop_in(_panel) opened.emit() - (_time_row.get_node("MorningButton") as Button).grab_focus() + if not visible: + return + if not _slots.is_empty(): + _slots[0].grab_focus() + else: + (_time_row.get_node("MorningButton") as Button).grab_focus() func _apply_responsive_layout() -> void: - var viewport_width := get_viewport().get_visible_rect().size.x - if viewport_width < 680.0: - _grid.columns = 2 - _slot_width = 160.0 - elif viewport_width < 940.0: - _grid.columns = 3 - _slot_width = 180.0 - else: - _grid.columns = 4 - _slot_width = 180.0 - _panel.custom_minimum_size.x = minf(780.0, viewport_width - 48.0) + if not is_node_ready(): + return + var viewport_size := get_viewport().get_visible_rect().size + _grid.columns = 10 if viewport_size.x >= 820.0 else 5 + if _station_grid != null: + _station_grid.columns = _grid.columns + if _catalog_grid != null: + _catalog_grid.columns = 4 if viewport_size.x >= 820.0 else 2 + _panel.custom_minimum_size.x = minf(820.0, maxf(320.0, viewport_size.x - 32.0)) + if _scroll != null: + _scroll.custom_minimum_size = Vector2(0, maxf(160.0, minf(760.0, viewport_size.y - 72.0))) func close_panel() -> void: + if not visible: + return visible = false + _session += 1 + _selected.clear() + _set_station_inventory(null) + _at_table = false + _recipes_dirty = true + _containers = null + _station_kind = 0 + _heading.text = "INVENTORY" + if is_instance_valid(_previous_focus) and _previous_focus.is_visible_in_tree(): + _previous_focus.grab_focus() closed.emit() func show_inventory(inventory_data: Dictionary, world_ref: VoxelWorld) -> void: - for child in _grid.get_children(): - _grid.remove_child(child) - child.queue_free() - var ids: Array = inventory_data.keys() - ids.sort() - if ids.is_empty(): - _grid.add_child(UITheme.muted_label("Nothing collected yet — mine some blocks.", 14)) - return - var registry: BlockRegistry = world_ref.get_registry() if world_ref != null and world_ref.has_method("get_registry") else null - for id in ids: - _grid.add_child(_make_slot(int(id), int(inventory_data[id]), world_ref, registry)) + # Legacy callers can still present counts; configured live models stay authoritative. + if _inventory == null or _legacy_inventory: + var model := ItemInventory.new() + model.migrate_counts(inventory_data) + configure_inventory(model, world_ref) + _legacy_inventory = true func set_weather_state(raining: bool) -> void: _weather_button.text = "Weather · Rain" if raining else "Weather · Sunny" -func _make_slot(item_id: int, count: int, world_ref: VoxelWorld, registry: BlockRegistry) -> PanelContainer: - var slot := PanelContainer.new() - slot.custom_minimum_size = Vector2(_slot_width, 68.0) - var style := UITheme.panel_style( - Color(UITheme.SURFACE_HI.r, UITheme.SURFACE_HI.g, UITheme.SURFACE_HI.b, 0.42), - UITheme.LINE, 1, 8) - style.content_margin_left = 10.0 - style.content_margin_right = 10.0 - style.content_margin_top = 8.0 - style.content_margin_bottom = 8.0 - slot.add_theme_stylebox_override("panel", style) - - var row := HBoxContainer.new() - row.add_theme_constant_override("separation", 10) - slot.add_child(row) - - var icon := TextureRect.new() - icon.custom_minimum_size = Vector2(50.0, 50.0) - icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE - icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED - icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST +## Session initialization only; the inventory exposes no mode selector. +func set_game_mode(mode: int) -> void: + _game_mode = GameMode.CREATIVE if mode == GameMode.CREATIVE else GameMode.SURVIVAL + if not is_node_ready(): + return + var creative := _game_mode == GameMode.CREATIVE + for control in _creative_controls: + control.visible = creative + _catalog.visible = creative + if creative and _catalog_buttons.is_empty(): + _build_catalog() + + +func _build_catalog() -> void: + var ids: Array[int] = [] + for definition in BlockRegistry.BLOCK_DEFS: + var id := int(definition[0]) + if id in [BlockRegistry.BLOCK_AIR, BlockRegistry.BLOCK_WATER, BlockRegistry.BLOCK_LAVA, BlockRegistry.BLOCK_FIRE]: + continue + if id >= BlockRegistry.BLOCK_WATER_FLOW_7 and id <= BlockRegistry.BLOCK_WATER_FLOW_1: + continue + if (int(definition[5]) & BlockRegistry.FLAG_UNBREAKABLE) != 0 or not ItemRegistry.is_valid(id): + continue + ids.append(id) + for offset in ItemRegistry.ITEM_NAMES.size(): + ids.append(ItemRegistry.ITEM_FLINT_AND_STEEL + offset) + for id in ids: + var button := Button.new() + button.text = item_name(id) + button.icon = item_icon(id) + button.expand_icon = true + button.add_theme_constant_override("icon_max_width", 40) + button.custom_minimum_size = Vector2(144, 64) + button.size_flags_horizontal = Control.SIZE_EXPAND_FILL + button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + button.tooltip_text = "Grant %s x%d" % [item_name(id), ItemRegistry.stack_limit(id)] + UITheme.style_button_flat(button) + UITheme.apply_font_size(button, 12) + button.pressed.connect(_grant_catalog_item.bind(id)) + _catalog_grid.add_child(button) + _catalog_buttons[id] = button + + +func _grant_catalog_item(id: int) -> void: + if _game_mode != GameMode.CREATIVE or _inventory == null or not _catalog_buttons.has(id): + return + var requested := ItemRegistry.stack_limit(id) + var remaining := _inventory.add_item(id, requested) + if remaining == requested: + _status.text = "No room in backpack. No items were changed." + else: + _status.text = "Added %s x%d.%s" % [item_name(id), requested - remaining, + " No room for the rest." if remaining > 0 else ""] + + +func _build_contents() -> void: + var box := _heading.get_parent() as VBoxContainer + _scroll = ScrollContainer.new() + _scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_AUTO + _scroll.follow_focus = true + _panel.add_child(_scroll) + box.reparent(_scroll) + box.size_flags_horizontal = Control.SIZE_EXPAND_FILL + _station_box = VBoxContainer.new() + box.add_child(_station_box) + box.move_child(_station_box, _grid.get_index()) + _station_grid = GridContainer.new() + _station_grid.columns = 10 + _station_box.add_child(UITheme.eyebrow("Station storage")) + _station_box.add_child(_station_grid) + _furnace_status = UITheme.muted_label("", 12) + _furnace_status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _station_box.add_child(_furnace_status) + _station_box.hide() + var inventory_title := UITheme.eyebrow("Backpack / first 10 slots are the hotbar") + box.add_child(inventory_title) + box.move_child(inventory_title, _grid.get_index()) + _catalog = VBoxContainer.new() + _catalog.name = "CreativeCatalog" + box.add_child(_catalog) + box.move_child(_catalog, _hint.get_index()) + _catalog.add_child(UITheme.eyebrow("Creative catalog / select to add a stack")) + _catalog_grid = GridContainer.new() + _catalog_grid.columns = 4 + _catalog_grid.add_theme_constant_override("h_separation", 8) + _catalog_grid.add_theme_constant_override("v_separation", 8) + _catalog.add_child(_catalog_grid) + _recipes = VBoxContainer.new() + box.add_child(_recipes) + box.move_child(_recipes, _hint.get_index()) + _recipes.add_child(UITheme.eyebrow("Recipe book / ingredients shown as owned / needed")) + var guide := UITheme.muted_label("Getting started: Any logs -> planks (in inventory) -> sticks + crafting table. Place the table and right-click it -> wooden pickaxe -> mine stone for cobblestone -> stone pickaxe + furnace -> smelt iron ore. Table recipes require opening the placed table.", 13) + guide.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _recipes.add_child(guide) + _recipe_search = LineEdit.new() + _recipe_search.placeholder_text = "Search recipes or ingredients" + _recipe_search.tooltip_text = "Search by recipe name, wood species, or ingredient" + _recipe_search.text_changed.connect(func(_text: String) -> void: _refresh_recipes()) + _recipes.add_child(_recipe_search) + _recipe_category = OptionButton.new() + _recipe_category.add_item("All categories") + for category in RECIPE_CATEGORIES: + _recipe_category.add_item(category) + _recipe_category.item_selected.connect(func(_index: int) -> void: _refresh_recipes()) + _recipes.add_child(_recipe_category) + _craftable_only = CheckBox.new() + _craftable_only.text = "Craftable only" + _craftable_only.toggled.connect(func(_enabled: bool) -> void: _refresh_recipes()) + _recipes.add_child(_craftable_only) + for category in RECIPE_CATEGORIES: + var group := VBoxContainer.new() + group.add_child(UITheme.eyebrow(category)) + _recipes.add_child(group) + _recipe_groups[category] = group + for recipe in CraftingRecipes.RECIPES: + var card := VBoxContainer.new() + card.add_theme_constant_override("separation", 6) + _recipe_groups[str(recipe["category"]).capitalize()].add_child(card) + _recipe_cards.append(card) + var title := Label.new() + title.text = "%s x%d" % [item_name(int(recipe["output"])), int(recipe["count"])] + title.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + UITheme.apply_font_size(title, 16) + card.add_child(title) + var requirements := UITheme.muted_label("", 13) + requirements.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + card.add_child(requirements) + _recipe_requirements.append(requirements) + var button := Button.new() + button.alignment = HORIZONTAL_ALIGNMENT_LEFT + button.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + button.expand_icon = true + button.add_theme_constant_override("icon_max_width", 40) + UITheme.style_button_ghost(button) + UITheme.apply_font_size(button, 13) + button.pressed.connect(_craft.bind(str(recipe["id"]))) + card.add_child(button) + _recipe_buttons.append(button) + var maximum := Button.new() + maximum.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + UITheme.style_button_ghost(maximum) + UITheme.apply_font_size(maximum, 13) + maximum.pressed.connect(_craft_max.bind(str(recipe["id"]))) + card.add_child(maximum) + _recipe_max_buttons.append(maximum) + _recipe_empty = UITheme.muted_label("No matching recipes. Clear search or turn off Craftable only to see requirements.", 13) + _recipe_empty.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _recipes.add_child(_recipe_empty) + _status = UITheme.muted_label("", 13) + _status.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + box.add_child(_status) + box.move_child(_status, _hint.get_index()) + var close_button := Button.new() + close_button.text = "Close" + UITheme.style_button_ghost(close_button) + close_button.pressed.connect(close_panel) + box.add_child(close_button) + _rebuild_slots() + _apply_responsive_layout() + + +func configure_inventory(model: ItemInventory, world_ref: VoxelWorld) -> void: + _legacy_inventory = false + if _inventory != null and _inventory.changed.is_connected(_on_inventory_changed): + _inventory.changed.disconnect(_on_inventory_changed) + _inventory = model + _recipes_dirty = true + _world = world_ref + _session += 1 + _selected.clear() + if _inventory != null: + _inventory.changed.connect(_on_inventory_changed) + if is_node_ready(): + _rebuild_slots() + for id in _catalog_buttons: + _catalog_buttons[id].icon = item_icon(int(id)) + + +func _set_station_inventory(model: ItemInventory) -> void: + if _station_inventory != null and _station_inventory.changed.is_connected(_refresh): + _station_inventory.changed.disconnect(_refresh) + _station_inventory = model + if model != null: + model.changed.connect(_refresh) + _session += 1 + _selected.clear() + _rebuild_slots() + + +func _rebuild_slots() -> void: + _slots.clear() + for grid in [_grid, _station_grid]: + for child in grid.get_children(): + grid.remove_child(child) + child.queue_free() + for model in [_inventory, _station_inventory]: + if model == null: + continue + var grid: GridContainer = _grid if model == _inventory else _station_grid + for index in model.slots.size(): + var slot := InventorySlot.new() + slot.inventory = model + slot.slot_index = index + slot.overlay = self + if model == _inventory and index < ItemInventory.HOTBAR_SIZE: + slot.caption = str((index + 1) % ItemInventory.HOTBAR_SIZE) + elif model == _station_inventory and model.slots.size() == 3: + slot.caption = ["Input", "Fuel", "Output"][index] + grid.add_child(slot) + _slots.append(slot) + _station_box.visible = _station_inventory != null + _refresh() + + +func _refresh() -> void: + for slot in _slots: + slot.refresh() + slot.selected = not _selected.is_empty() and _selected.get("inventory") == slot.inventory and _selected.get("index") == slot.slot_index + _refresh_recipes() + if _furnace_status != null: + _furnace_status.visible = _station_kind == BlockRegistry.BLOCK_FURNACE + if _containers != null and _furnace_status.visible: + var state := _containers.get_furnace(_station_position) + _furnace_status.text = "Smelting: %d%% | Fuel: %.1fs remaining\nPut a recipe ingredient in Input and fuel in Fuel. Close inventory to resume smelting: inventory pauses the game. Reopen the furnace to collect Output (extraction only).\nOne input -> one output every %.0f seconds.\n" % [ + roundi(float(state.get("progress", 0.0)) / CraftingRecipes.SMELT_SECONDS * 100.0), float(state.get("burn_remaining", 0.0)), CraftingRecipes.SMELT_SECONDS] + var smelting: PackedStringArray = [] + for id in CraftingRecipes.SMELTING: + smelting.append("%s -> %s%s" % [item_name(int(id)), item_name(int(CraftingRecipes.SMELTING[id])), " (raw meat: Creative only; survival source not yet available)" if int(id) == ItemRegistry.ITEM_RAW_MEAT else ""]) + var fuels: PackedStringArray = [] + var ids: Array[int] = [] + for definition in BlockRegistry.BLOCK_DEFS: + ids.append(int(definition[0])) + for offset in ItemRegistry.ITEM_NAMES.size(): + ids.append(ItemRegistry.ITEM_FLINT_AND_STEEL + offset) + for id in ids: + var seconds := ItemRegistry.fuel_seconds(id) + if seconds > 0.0: + fuels.append("%s: %.0fs" % [item_name(id), seconds]) + _furnace_status.text += "Recipes:\n%s\nFuel per item:\n%s" % ["\n".join(smelting), ", ".join(fuels)] + + +func _on_inventory_changed() -> void: + _recipes_dirty = true + if visible: + _refresh() + + +func item_name(item_id: int) -> String: if ItemRegistry.is_item(item_id): - icon.texture = ItemRegistry.make_icon(item_id, 50) - elif registry != null: - icon.texture = BlockIcon.make_icon(registry, item_id, 50) - row.add_child(icon) - - var text := VBoxContainer.new() - text.add_theme_constant_override("separation", 2) - text.size_flags_horizontal = Control.SIZE_EXPAND_FILL - row.add_child(text) - var name_label := Label.new() - name_label.text = (ItemRegistry.get_item_name(item_id) if ItemRegistry.is_item(item_id) else world_ref.get_block_name(item_id)).to_lower() - name_label.add_theme_font_override("font", UITheme.font_semi()) - name_label.add_theme_color_override("font_color", UITheme.INK) - name_label.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART - UITheme.apply_font_size(name_label, 13) - text.add_child(name_label) - var count_label := Label.new() - count_label.text = "×%d" % count - count_label.add_theme_font_override("font", UITheme.font_semi()) - count_label.add_theme_color_override("font_color", UITheme.CYAN) - UITheme.apply_font_size(count_label, 14) - text.add_child(count_label) - return slot + return ItemRegistry.get_item_name(item_id).capitalize() + if _world != null: + return _world.get_block_name(item_id).capitalize() + for definition in BlockRegistry.BLOCK_DEFS: + if int(definition[0]) == item_id: + return str(definition[1]).capitalize() + return "Item %d" % item_id + + +func item_icon(item_id: int) -> Texture2D: + if ItemRegistry.is_item(item_id): + return ItemRegistry.make_icon(item_id, 48) + if _world != null and _world.get_registry() != null: + return BlockIcon.make_icon(_world.get_registry(), item_id, 48) + return null + + +func drag_data(slot: InventorySlot, split: bool = false) -> Dictionary: + if not visible or slot.inventory.slots[slot.slot_index].is_empty(): + return {} + var stack: Dictionary = slot.inventory.slots[slot.slot_index].duplicate(true) + return {"overlay": self, "session": _session, "inventory": slot.inventory, + "index": slot.slot_index, "stack": stack, "split": split, + "amount": ceili(float(stack["count"]) / 2.0) if split else int(stack["count"])} + + +func activate_slot(slot: InventorySlot) -> void: + if _selected.is_empty(): + _selected = drag_data(slot, Input.is_key_pressed(KEY_SHIFT)) + if not _selected.is_empty(): + _status.text = "Selected %s. Choose a destination slot." % item_name(int(_selected["stack"]["id"])) + else: + drop_stack(slot, _selected) + _selected = {} + _refresh() + + +func open_station(position: Vector3i, kind: int, containers: BlockContainers) -> void: + if _inventory == null or containers == null: + return + if kind not in [BlockRegistry.BLOCK_CRAFTING_TABLE, BlockRegistry.BLOCK_CHEST, BlockRegistry.BLOCK_FURNACE]: + return + var model: ItemInventory + if kind == BlockRegistry.BLOCK_CHEST: + model = containers.ensure_chest(position) + elif kind == BlockRegistry.BLOCK_FURNACE: + model = containers.ensure_furnace(position) + if kind != BlockRegistry.BLOCK_CRAFTING_TABLE and model == null: + return + _containers = containers + _station_position = position + _station_kind = kind + _at_table = kind == BlockRegistry.BLOCK_CRAFTING_TABLE + _recipes_dirty = true + _set_station_inventory(model) + _heading.text = item_name(kind).to_upper() + open_panel() + if visible and not _slots.is_empty(): + _slots[0].grab_focus() + + +func _accepts(model: ItemInventory, index: int, item_id: int) -> bool: + if model == _inventory: + return true + return model == _station_inventory and _containers != null and _containers.can_insert(_station_position, index, item_id) + + +func can_drop(slot: InventorySlot, data: Variant) -> bool: + if not visible or not data is Dictionary or data.get("overlay") != self or data.get("session", -1) != _session: + return false + var source: ItemInventory = data.get("inventory") as ItemInventory + var index := int(data.get("index", -1)) + if source == null or source not in [_inventory, _station_inventory] or slot.inventory not in [_inventory, _station_inventory]: + return false + if index < 0 or index >= source.slots.size() or source.slots[index] != data.get("stack"): + return false + return not InventoryTransfer.proposal(source, index, slot.inventory, slot.slot_index, bool(data.get("split", false)), _accepts).is_empty() + + +func drop_stack(slot: InventorySlot, data: Variant) -> bool: + if not can_drop(slot, data): + _status.text = "That move is not allowed. No items were moved." + return false + var moved := InventoryTransfer.commit(data["inventory"], int(data["index"]), slot.inventory, slot.slot_index, bool(data["split"]), _accepts) + _status.text = "Stack moved." if moved else "No items were moved." + _selected = {} + _refresh() + return moved + + +func _refresh_recipes() -> void: + # Capacity checks copy inventories. Filters and slot selection reuse the result; + # only backpack mutations or a station change invalidate recipe availability. + if _recipes_dirty: + _recipe_availability.clear() + for recipe in CraftingRecipes.RECIPES: + var recipe_id := str(recipe["id"]) + _recipe_availability.append({ + "maximum": CraftingRecipes.max_craftable(_inventory, recipe_id, _at_table) if _inventory != null else 0, + "reason": CraftingRecipes.blocking_reason(_inventory, recipe_id, _at_table) if _inventory != null else "No inventory available.", + }) + _recipes_dirty = false + var query := _recipe_search.text.strip_edges().to_lower() + var shown := 0 + for group in _recipe_groups.values(): + group.hide() + for index in _recipe_buttons.size(): + var recipe: Dictionary = CraftingRecipes.RECIPES[index] + var button := _recipe_buttons[index] + var requirements: PackedStringArray = [] + for id in recipe["ingredients"]: + var needed := int(recipe["ingredients"][id]) + var owned := _inventory.count_item(int(id)) if _inventory != null else 0 + requirements.append("%s %d/%d" % [item_name(int(id)), owned, needed]) + var recipe_id := str(recipe["id"]) + var category := str(recipe["category"]).capitalize() + var maximum := int(_recipe_availability[index]["maximum"]) + var reason := str(_recipe_availability[index]["reason"]) + var station := "Crafting table: ready" if _at_table else "Requires crafting table: place it and right-click to open" + _recipe_requirements[index].text = "%s\n%s%s" % [", ".join(requirements), station if recipe["table"] else "Inventory crafting / no table needed", "\n1 batch: " + reason if not reason.is_empty() else ""] + button.text = "Craft 1 batch / %s x%d" % [item_name(int(recipe["output"])), int(recipe["count"])] + button.icon = item_icon(int(recipe["output"])) + button.disabled = not reason.is_empty() + button.tooltip_text = reason + _recipe_max_buttons[index].text = "Craft Max / %d batches / %s x%d" % [maximum, item_name(int(recipe["output"])), maximum * int(recipe["count"])] + _recipe_max_buttons[index].disabled = maximum == 0 + _recipe_max_buttons[index].tooltip_text = reason if maximum == 0 else "Craft the largest batch that fits, up to 64 batches" + var searchable := (recipe_id + " " + item_name(int(recipe["output"])) + " " + " ".join(requirements)).to_lower() + var matches := (query.is_empty() or searchable.contains(query)) and (_recipe_category.selected == 0 or _recipe_category.get_item_text(_recipe_category.selected) == category) and (not _craftable_only.button_pressed or maximum > 0) + _recipe_cards[index].visible = matches + if matches: + _recipe_groups[category].show() + shown += 1 + _recipe_empty.visible = shown == 0 + + +func _craft_max(recipe_id: String) -> void: + if _inventory != null: + _craft(recipe_id, CraftingRecipes.max_craftable(_inventory, recipe_id, _at_table)) + + +func _craft(recipe_id: String, batches: int = 1) -> void: + _selected = {} + if _inventory != null and batches > 0 and CraftingRecipes.craft(_inventory, recipe_id, _at_table, batches): + var recipe := CraftingRecipes.get_recipe(recipe_id) + _status.text = "Crafted %s x%d (%d batches)." % [item_name(int(recipe["output"])), int(recipe["count"]) * batches, batches] + else: + _status.text = CraftingRecipes.blocking_reason(_inventory, recipe_id, _at_table) if _inventory != null else "No inventory available." + _refresh() func _on_time_button(hours: float) -> void: - time_selected.emit(hours) + if _game_mode == GameMode.CREATIVE: + time_selected.emit(hours) func _on_weather_button() -> void: - weather_toggled.emit() + if _game_mode == GameMode.CREATIVE: + weather_toggled.emit() diff --git a/ui/inventory_slot.gd b/ui/inventory_slot.gd new file mode 100644 index 0000000..fbb8d98 --- /dev/null +++ b/ui/inventory_slot.gd @@ -0,0 +1,91 @@ +class_name InventorySlot +extends Button + +## Dragging never takes ownership of items. Only a validated drop mutates data. +var inventory: ItemInventory +var slot_index := 0 +var overlay: InventoryOverlay +var caption := "" +var _icon: TextureRect +var _count: Label +var _caption: Label +var _item_id := -1 +var selected := false: + set(value): + selected = value + queue_redraw() + + +func _ready() -> void: + custom_minimum_size = Vector2(64, 72) + size_flags_horizontal = Control.SIZE_EXPAND_FILL + UITheme.style_button_flat(self) + add_theme_stylebox_override("normal", UITheme.panel_style(UITheme.SURFACE_LOW, UITheme.LINE, 1, 6)) + _icon = TextureRect.new() + _icon.set_anchors_and_offsets_preset(Control.PRESET_FULL_RECT) + _icon.offset_left = 10 + _icon.offset_right = -10 + _icon.offset_top = 14 + _icon.offset_bottom = -14 + _icon.expand_mode = TextureRect.EXPAND_IGNORE_SIZE + _icon.stretch_mode = TextureRect.STRETCH_KEEP_ASPECT_CENTERED + _icon.texture_filter = CanvasItem.TEXTURE_FILTER_NEAREST + _icon.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_icon) + _count = UITheme.value_label() + add_child(_count) + _count.set_anchors_and_offsets_preset(Control.PRESET_BOTTOM_RIGHT) + _count.grow_horizontal = Control.GROW_DIRECTION_BEGIN + _count.grow_vertical = Control.GROW_DIRECTION_BEGIN + _count.offset_left = -40 + _count.offset_right = -6 + _count.offset_top = -24 + _count.offset_bottom = -4 + _count.mouse_filter = Control.MOUSE_FILTER_IGNORE + _caption = UITheme.muted_label(caption, 11) + _caption.position = Vector2(5, 2) + _caption.mouse_filter = Control.MOUSE_FILTER_IGNORE + add_child(_caption) + pressed.connect(func() -> void: overlay.activate_slot(self)) + refresh() + + +func refresh() -> void: + if not is_node_ready(): + return + var stack: Dictionary = inventory.slots[slot_index] + var item_id := int(stack.get("id", -1)) + if item_id != _item_id: + _item_id = item_id + _icon.texture = overlay.item_icon(item_id) if item_id >= 0 else null + _count.text = str(stack.get("count", 0)) if not stack.is_empty() else "" + tooltip_text = caption + (" - " if not caption.is_empty() else "") + tooltip_text += "%s x%d" % [overlay.item_name(item_id), int(stack.get("count", 0))] if item_id >= 0 else "Empty" + if item_id >= 0 and ItemRegistry.max_durability(item_id) > 0: + tooltip_text += "\nDurability: %d" % int(stack["durability"]) + button_pressed = false + queue_redraw() + + +func _draw() -> void: + if selected: + draw_style_box(get_theme_stylebox("focus"), Rect2(Vector2.ZERO, size)) + + +func _get_drag_data(_at_position: Vector2) -> Variant: + var data := overlay.drag_data(self, Input.is_key_pressed(KEY_SHIFT)) + if data.is_empty(): + return null + var preview := Label.new() + preview.text = "%s x%d" % [overlay.item_name(int(data["stack"]["id"])), int(data["amount"])] + UITheme.apply(preview) + set_drag_preview(preview) + return data + + +func _can_drop_data(_at_position: Vector2, data: Variant) -> bool: + return overlay.can_drop(self, data) + + +func _drop_data(_at_position: Vector2, data: Variant) -> void: + overlay.drop_stack(self, data) diff --git a/ui/inventory_slot.gd.uid b/ui/inventory_slot.gd.uid new file mode 100644 index 0000000..21ac38c --- /dev/null +++ b/ui/inventory_slot.gd.uid @@ -0,0 +1 @@ +uid://xvrep346rqr6 diff --git a/ui/inventory_transfer.gd b/ui/inventory_transfer.gd new file mode 100644 index 0000000..07a68af --- /dev/null +++ b/ui/inventory_transfer.gd @@ -0,0 +1,44 @@ +class_name InventoryTransfer +extends RefCounted + +## Return the two proposed stacks without mutating either inventory. Checking +## the reverse side of a swap prevents inserting items into furnace outputs. +static func proposal(source: ItemInventory, from: int, target: ItemInventory, to: int, split: bool, accepts: Callable) -> Array: + if source == null or target == null or from < 0 or to < 0 or from >= source.slots.size() or to >= target.slots.size(): + return [] + if source == target and from == to: + return [] + var first: Dictionary = source.slots[from].duplicate(true) + var second: Dictionary = target.slots[to].duplicate(true) + if first.is_empty() or not accepts.call(target, to, int(first["id"])): + return [] + var amount := ceili(float(first["count"]) / 2.0) if split else int(first["count"]) + if second.is_empty(): + second = first.duplicate(true) + second["count"] = amount + elif second["id"] == first["id"] and second.get("durability", 0) == first.get("durability", 0): + amount = mini(amount, ItemRegistry.stack_limit(int(first["id"])) - int(second["count"])) + if amount <= 0: + return [] + second["count"] += amount + else: + if split or not accepts.call(source, from, int(second["id"])): + return [] + return [second, first] + first["count"] -= amount + if int(first["count"]) == 0: + first = {} + return [first, second] + + +static func commit(source: ItemInventory, from: int, target: ItemInventory, to: int, split: bool, accepts: Callable) -> bool: + var stacks := proposal(source, from, target, to, split, accepts) + if stacks.is_empty(): + return false + # Both sides are committed before any observer sees a changed signal. + source.slots[from] = stacks[0] + target.slots[to] = stacks[1] + source.changed.emit() + if target != source: + target.changed.emit() + return true diff --git a/ui/inventory_transfer.gd.uid b/ui/inventory_transfer.gd.uid new file mode 100644 index 0000000..ba57a87 --- /dev/null +++ b/ui/inventory_transfer.gd.uid @@ -0,0 +1 @@ +uid://bmqy3tl1lf846 diff --git a/ui/main_menu.gd b/ui/main_menu.gd index 5fec122..db6976c 100644 --- a/ui/main_menu.gd +++ b/ui/main_menu.gd @@ -255,6 +255,7 @@ func _on_create_world(seed: int, world_type: int) -> void: config["seed"] = seed config["world_type"] = world_type GameConfig.apply_world(config) + GameConfig.pending_game_mode = _play_panel.get_selected_game_mode() # Main creates the durable world only after the gameplay scene loads, so a # failed scene change cannot leave an empty world in the library. GameConfig.clear_active_world() diff --git a/ui/pause_menu.gd b/ui/pause_menu.gd index 5d6ef85..77ac5b6 100644 --- a/ui/pause_menu.gd +++ b/ui/pause_menu.gd @@ -68,7 +68,7 @@ func _refresh_hint() -> void: if sprint_key == descend_key else "%s sprint %s descend" % [sprint_key, descend_key] ) - _hint.text = "%s move %s jump double-tap %s to fly\n%s %s fast fly LMB mine RMB place\n1-9 / 0 or wheel select block %s inventory %s map %s minimap %s map mode\n%s hide HUD %s screenshot %s photo camera %s worldgen map ESC pause" % [ + _hint.text = "%s move %s jump double-tap %s to fly (Creative only)\n%s %s fast fly LMB mine RMB place\n1-9 / 0 or wheel select block %s inventory %s map %s minimap %s map mode\n%s hide HUD %s screenshot %s photo camera %s worldgen map ESC pause" % [ GameConfig.input_move_hint(), jump_key, jump_key, diff --git a/ui/play_panel.gd b/ui/play_panel.gd index f0cf3c5..5cb536f 100644 --- a/ui/play_panel.gd +++ b/ui/play_panel.gd @@ -84,15 +84,22 @@ var _rename_cancel_button: Button var _rename_confirm_button: Button var _operation_status: Label var _editing_rename := false +var _mode_row: HBoxContainer +var _mode_option: OptionButton +var _mode_hint: Label func _ready() -> void: UITheme.apply(self) + _build_mode_controls() _build_management_controls() _style_static() _load_scroll.horizontal_scroll_mode = ScrollContainer.SCROLL_MODE_DISABLED for type_name in WORLD_TYPES: _type_option.add_item(type_name) + _type_option.item_selected.connect(_update_creation_description) + _mode_option.item_selected.connect(_update_creation_description) + _update_creation_description() _random_button.pressed.connect(func() -> void: _seed_field.text = str(randi() % 1000000000) ) @@ -189,6 +196,8 @@ func _show_view(view: int) -> void: _view = view _landing_box.visible = view == View.LANDING _type_row.visible = view == View.CREATE + _mode_row.visible = view == View.CREATE + _mode_hint.visible = view == View.CREATE _seed_row.visible = view == View.CREATE _footer.visible = view == View.CREATE _load_box.visible = view == View.LOAD @@ -206,6 +215,7 @@ func _show_view(view: int) -> void: _heading.text = "New World" _seed_field.text = str(GameConfig.get_world_seed()) _type_option.selected = clampi(GameConfig.get_world_type(), 0, WORLD_TYPES.size() - 1) + _update_creation_description() _seed_field.grab_focus() View.LOAD: _heading.text = "Load World" @@ -308,7 +318,7 @@ func _build_card(world: Dictionary) -> Button: card.toggle_mode = false card.focus_mode = Control.FOCUS_ALL card.size_flags_horizontal = Control.SIZE_EXPAND_FILL - card.custom_minimum_size = Vector2(0.0, 62.0) + card.custom_minimum_size = Vector2(0.0, 82.0) card.set_meta("world_id", id) card.set_meta("compatible", compatible) if compatible: @@ -360,6 +370,13 @@ func _build_card(world: Dictionary) -> Button: UITheme.apply_font_size(meta, 13) meta.mouse_filter = Control.MOUSE_FILTER_IGNORE details.add_child(meta) + var mode_label := Label.new() + mode_label.name = "GameModeLabel" + mode_label.text = GameMode.display_name(int(world.get("game_mode", GameMode.CREATIVE))) + mode_label.add_theme_color_override("font_color", UITheme.MUTED) + UITheme.apply_font_size(mode_label, 13) + mode_label.mouse_filter = Control.MOUSE_FILTER_IGNORE + details.add_child(mode_label) var stats := VBoxContainer.new() stats.mouse_filter = Control.MOUSE_FILTER_IGNORE @@ -392,6 +409,10 @@ func _build_card(world: Dictionary) -> Button: stat_value.text = "INCOMPATIBLE" _style_card(card, false) + # Button children do not contribute to its minimum size automatically. + margin.minimum_size_changed.connect(func() -> void: + card.custom_minimum_size.y = maxf(82.0, margin.get_combined_minimum_size().y)) + card.custom_minimum_size.y = maxf(82.0, margin.get_combined_minimum_size().y) card.pressed.connect(_on_card_activated.bind(id)) card.gui_input.connect(_on_card_gui_input.bind(id)) return card @@ -610,6 +631,44 @@ func _backup_selected() -> void: # ------------------------------------------------------------- create view -- +func get_selected_game_mode() -> int: + return _mode_option.get_selected_id() + + +func _build_mode_controls() -> void: + var box := _type_row.get_parent() + _mode_row = HBoxContainer.new() + _mode_row.name = "GameModeRow" + _mode_row.visible = false + var label := Label.new() + _style_row_label(label, "Game Mode") + _mode_row.add_child(label) + _mode_option = OptionButton.new() + _mode_option.name = "GameModeOption" + _mode_option.size_flags_horizontal = Control.SIZE_EXPAND_FILL + for mode in [GameMode.CREATIVE, GameMode.SURVIVAL]: + _mode_option.add_item(GameMode.display_name(mode), mode) + _mode_option.select(_mode_option.get_item_index(GameMode.SURVIVAL)) + _mode_row.add_child(_mode_option) + box.add_child(_mode_row) + box.move_child(_mode_row, _type_row.get_index()) + _mode_hint = Label.new() + _mode_hint.name = "GameModeHint" + _mode_hint.visible = false + _mode_hint.autowrap_mode = TextServer.AUTOWRAP_WORD_SMART + _mode_hint.add_theme_color_override("font_color", UITheme.MUTED) + UITheme.apply_font_size(_mode_hint, 13) + box.add_child(_mode_hint) + box.move_child(_mode_hint, _mode_row.get_index() + 1) + + +func _update_creation_description(_index: int = -1) -> void: + _mode_hint.text = "Creative: build freely. Survival: gather and survive.\nGame mode is locked after creation." + if _type_option.selected == WorldGenConfig.WORLD_TYPE_FLAT \ + and get_selected_game_mode() == GameMode.SURVIVAL: + _mode_hint.text += "\nWarning: Flat Survival is resource-limited. Flat terrain generates no trees or ores." + + func _on_import() -> void: var text := DisplayServer.clipboard_get().strip_edges() if not text.is_empty(): diff --git a/world/block_registry.gd b/world/block_registry.gd index 6ed3527..0dffb4e 100644 --- a/world/block_registry.gd +++ b/world/block_registry.gd @@ -72,6 +72,10 @@ const BLOCK_GEODE_SHELL := 67 const BLOCK_AMETHYST := 68 const BLOCK_CRYSTAL_BUD := 69 const BLOCK_FIRE := 70 +const BLOCK_CRAFTING_TABLE := 71 +const BLOCK_CHEST := 72 +const BLOCK_FURNACE := 73 +const BLOCK_PLANKS := 74 const FLAG_OPAQUE := 1 const FLAG_CUTOUT := 2 @@ -85,78 +89,83 @@ const FLAG_TINTED := 64 ## both its trunk and its canopy. const FLAG_FLAMMABLE := 128 +## Rows: id, name, top, side, bottom, flags, hardness, preferred tool, required tier. const BLOCK_DEFS := [ - [0, "AIR", "", "", "", 0], - [1, "GRASS", "grass_top.png", "grass_side.png", "dirt.png", FLAG_OPAQUE | FLAG_TINTED], - [2, "DIRT", "dirt.png", "dirt.png", "dirt.png", FLAG_OPAQUE], - [3, "STONE", "stone.png", "stone.png", "stone.png", FLAG_OPAQUE], - [4, "COBBLESTONE", "cobblestone.png", "cobblestone.png", "cobblestone.png", FLAG_OPAQUE], - [5, "OAK LOG", "wood_top.png", "wood_side.png", "wood_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [6, "OAK LEAVES", "leaves.png", "leaves.png", "leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], - [7, "SAND", "sand.png", "sand.png", "sand.png", FLAG_OPAQUE], - [8, "GLASS", "glass.png", "glass.png", "glass.png", FLAG_CUTOUT], - [9, "GLOWSTONE", "glowstone.png", "glowstone.png", "glowstone.png", FLAG_OPAQUE | FLAG_EMISSIVE], - [10, "SNOW", "snow_block.png", "snow_block.png", "snow_block.png", FLAG_OPAQUE], - [11, "BEDROCK", "bedrock.png", "bedrock.png", "bedrock.png", FLAG_OPAQUE | FLAG_UNBREAKABLE], - [12, "GRAVEL", "gravel.png", "gravel.png", "gravel.png", FLAG_OPAQUE], - [13, "COAL ORE", "coal_ore.png", "coal_ore.png", "coal_ore.png", FLAG_OPAQUE], - [14, "IRON ORE", "iron_ore.png", "iron_ore.png", "iron_ore.png", FLAG_OPAQUE], - [15, "GOLD ORE", "gold_ore.png", "gold_ore.png", "gold_ore.png", FLAG_OPAQUE], - [16, "WATER", "water.png", "water.png", "water.png", 0], - [17, "CLAY", "clay.png", "clay.png", "clay.png", FLAG_OPAQUE], - [18, "MUD", "mud.png", "mud.png", "mud.png", FLAG_OPAQUE], - [19, "RED SAND", "red_sand.png", "red_sand.png", "red_sand.png", FLAG_OPAQUE], - [20, "CACTUS", "cactus_top.png", "cactus_side.png", "cactus_top.png", FLAG_OPAQUE], - [21, "SPRUCE LOG", "spruce_log_top.png", "spruce_log_side.png", "spruce_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [22, "SPRUCE LEAVES", "spruce_leaves.png", "spruce_leaves.png", "spruce_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], - [23, "BIRCH LOG", "birch_log_top.png", "birch_log_side.png", "birch_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [24, "BIRCH LEAVES", "birch_leaves.png", "birch_leaves.png", "birch_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], - [25, "TERRACOTTA", "terracotta.png", "terracotta.png", "terracotta.png", FLAG_OPAQUE], - [26, "MYCELIUM", "mycelium_top.png", "mycelium_side.png", "dirt.png", FLAG_OPAQUE], - [27, "TORCH", "torch.png", "torch.png", "torch.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE], - [28, "WATER FLOW 7", "water.png", "water.png", "water.png", 0], - [29, "WATER FLOW 6", "water.png", "water.png", "water.png", 0], - [30, "WATER FLOW 5", "water.png", "water.png", "water.png", 0], - [31, "WATER FLOW 4", "water.png", "water.png", "water.png", 0], - [32, "WATER FLOW 3", "water.png", "water.png", "water.png", 0], - [33, "WATER FLOW 2", "water.png", "water.png", "water.png", 0], - [34, "WATER FLOW 1", "water.png", "water.png", "water.png", 0], - [35, "TALL GRASS", "tall_grass.png", "tall_grass.png", "tall_grass.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE], - [36, "YELLOW FLOWER", "flower_yellow.png", "flower_yellow.png", "flower_yellow.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], - [37, "RED FLOWER", "flower_red.png", "flower_red.png", "flower_red.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], - [38, "DEAD BUSH", "dead_bush.png", "dead_bush.png", "dead_bush.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], - [39, "BAMBOO", "bamboo.png", "bamboo.png", "bamboo.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE], - [40, "VINE", "vine.png", "vine.png", "vine.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE], - [41, "ACACIA LOG", "acacia_log_top.png", "acacia_log_side.png", "acacia_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [42, "ACACIA LEAVES", "acacia_leaves.png", "acacia_leaves.png", "acacia_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], - [43, "JUNGLE LOG", "jungle_log_top.png", "jungle_log_side.png", "jungle_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [44, "JUNGLE LEAVES", "jungle_leaves.png", "jungle_leaves.png", "jungle_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], - [45, "MANGROVE LOG", "mangrove_log_top.png", "mangrove_log_side.png", "mangrove_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [46, "MANGROVE LEAVES", "mangrove_leaves.png", "mangrove_leaves.png", "mangrove_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], - [47, "MANGROVE ROOTS", "mangrove_roots.png", "mangrove_roots.png", "mangrove_roots.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [48, "LAVA", "lava.png", "lava.png", "lava.png", FLAG_OPAQUE | FLAG_EMISSIVE], - [49, "BROWN MUSHROOM", "brown_mushroom_block.png", "brown_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], - [50, "RED MUSHROOM", "red_mushroom_block.png", "red_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], - [51, "MELON", "melon_top.png", "melon_side.png", "melon_side.png", FLAG_OPAQUE | FLAG_FLAMMABLE], - [52, "CORAL SUBSTRATE", "coral_substrate.png", "coral_substrate.png", "coral_substrate.png", FLAG_OPAQUE], - [53, "SEAGRASS", "seagrass.png", "seagrass.png", "seagrass.png", FLAG_CUTOUT | FLAG_CROSS], - [54, "KELP", "kelp.png", "kelp.png", "kelp.png", FLAG_CUTOUT | FLAG_CROSS], - [55, "CORAL FAN", "coral_fan.png", "coral_fan.png", "coral_fan.png", FLAG_CUTOUT | FLAG_CROSS], - [56, "CORAL BRANCH", "coral_branch.png", "coral_branch.png", "coral_branch.png", FLAG_CUTOUT | FLAG_CROSS], - [57, "SPONGE", "sponge.png", "sponge.png", "sponge.png", FLAG_OPAQUE], - [58, "ANEMONE", "anemone.png", "anemone.png", "anemone.png", FLAG_CUTOUT | FLAG_CROSS], - [59, "TNT", "tnt_top.png", "tnt_side.png", "tnt_bottom.png", FLAG_OPAQUE], - [60, "NUKE", "nuke_top.png", "nuke_side.png", "nuke_bottom.png", FLAG_OPAQUE], - [61, "DRIPSTONE", "dripstone.png", "dripstone.png", "dripstone.png", FLAG_OPAQUE], - [62, "MOSS", "moss.png", "moss.png", "moss.png", FLAG_OPAQUE], - [63, "CAVE MOSS", "cave_moss.png", "cave_moss.png", "cave_moss.png", FLAG_CUTOUT | FLAG_CROSS], - [64, "DEEPSTONE", "deepstone.png", "deepstone.png", "deepstone.png", FLAG_OPAQUE], - [65, "SCULK", "sculk.png", "sculk.png", "sculk.png", FLAG_OPAQUE | FLAG_EMISSIVE], - [66, "CALCITE", "calcite.png", "calcite.png", "calcite.png", FLAG_OPAQUE], - [67, "GEODE SHELL", "geode_shell.png", "geode_shell.png", "geode_shell.png", FLAG_OPAQUE], - [68, "AMETHYST", "amethyst.png", "amethyst.png", "amethyst.png", FLAG_OPAQUE | FLAG_EMISSIVE], - [69, "CRYSTAL BUD", "crystal_bud.png", "crystal_bud.png", "crystal_bud.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE], - [70, "FIRE", "fire.png", "fire.png", "fire.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE], + [0, "AIR", "", "", "", 0, -1.0, "", 0], + [1, "GRASS", "grass_top.png", "grass_side.png", "dirt.png", FLAG_OPAQUE | FLAG_TINTED, 0.6, "shovel", 0], + [2, "DIRT", "dirt.png", "dirt.png", "dirt.png", FLAG_OPAQUE, 0.5, "shovel", 0], + [3, "STONE", "stone.png", "stone.png", "stone.png", FLAG_OPAQUE, 1.5, "pickaxe", 1], + [4, "COBBLESTONE", "cobblestone.png", "cobblestone.png", "cobblestone.png", FLAG_OPAQUE, 2.0, "pickaxe", 1], + [5, "OAK LOG", "wood_top.png", "wood_side.png", "wood_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], + [6, "OAK LEAVES", "leaves.png", "leaves.png", "leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE, 0.2, "axe", 0], + [7, "SAND", "sand.png", "sand.png", "sand.png", FLAG_OPAQUE, 0.5, "shovel", 0], + [8, "GLASS", "glass.png", "glass.png", "glass.png", FLAG_CUTOUT, 0.3, "", 0], + [9, "GLOWSTONE", "glowstone.png", "glowstone.png", "glowstone.png", FLAG_OPAQUE | FLAG_EMISSIVE, 0.3, "pickaxe", 0], + [10, "SNOW", "snow_block.png", "snow_block.png", "snow_block.png", FLAG_OPAQUE, 0.2, "shovel", 0], + [11, "BEDROCK", "bedrock.png", "bedrock.png", "bedrock.png", FLAG_OPAQUE | FLAG_UNBREAKABLE, -1.0, "", 0], + [12, "GRAVEL", "gravel.png", "gravel.png", "gravel.png", FLAG_OPAQUE, 0.6, "shovel", 0], + [13, "COAL ORE", "coal_ore.png", "coal_ore.png", "coal_ore.png", FLAG_OPAQUE, 3.0, "pickaxe", 1], + [14, "IRON ORE", "iron_ore.png", "iron_ore.png", "iron_ore.png", FLAG_OPAQUE, 3.0, "pickaxe", 2], + [15, "GOLD ORE", "gold_ore.png", "gold_ore.png", "gold_ore.png", FLAG_OPAQUE, 3.0, "pickaxe", 3], + [16, "WATER", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [17, "CLAY", "clay.png", "clay.png", "clay.png", FLAG_OPAQUE, 0.6, "shovel", 0], + [18, "MUD", "mud.png", "mud.png", "mud.png", FLAG_OPAQUE, 0.5, "shovel", 0], + [19, "RED SAND", "red_sand.png", "red_sand.png", "red_sand.png", FLAG_OPAQUE, 0.5, "shovel", 0], + [20, "CACTUS", "cactus_top.png", "cactus_side.png", "cactus_top.png", FLAG_OPAQUE, 0.4, "axe", 0], + [21, "SPRUCE LOG", "spruce_log_top.png", "spruce_log_side.png", "spruce_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], + [22, "SPRUCE LEAVES", "spruce_leaves.png", "spruce_leaves.png", "spruce_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE, 0.2, "axe", 0], + [23, "BIRCH LOG", "birch_log_top.png", "birch_log_side.png", "birch_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], + [24, "BIRCH LEAVES", "birch_leaves.png", "birch_leaves.png", "birch_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE, 0.2, "axe", 0], + [25, "TERRACOTTA", "terracotta.png", "terracotta.png", "terracotta.png", FLAG_OPAQUE, 1.25, "pickaxe", 1], + [26, "MYCELIUM", "mycelium_top.png", "mycelium_side.png", "dirt.png", FLAG_OPAQUE, 0.6, "shovel", 0], + [27, "TORCH", "torch.png", "torch.png", "torch.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE, 0.0, "", 0], + [28, "WATER FLOW 7", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [29, "WATER FLOW 6", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [30, "WATER FLOW 5", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [31, "WATER FLOW 4", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [32, "WATER FLOW 3", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [33, "WATER FLOW 2", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [34, "WATER FLOW 1", "water.png", "water.png", "water.png", 0, -1.0, "", 0], + [35, "TALL GRASS", "tall_grass.png", "tall_grass.png", "tall_grass.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE, 0.0, "", 0], + [36, "YELLOW FLOWER", "flower_yellow.png", "flower_yellow.png", "flower_yellow.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE, 0.0, "", 0], + [37, "RED FLOWER", "flower_red.png", "flower_red.png", "flower_red.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE, 0.0, "", 0], + [38, "DEAD BUSH", "dead_bush.png", "dead_bush.png", "dead_bush.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE, 0.0, "", 0], + [39, "BAMBOO", "bamboo.png", "bamboo.png", "bamboo.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE, 1.0, "axe", 0], + [40, "VINE", "vine.png", "vine.png", "vine.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE, 0.2, "axe", 0], + [41, "ACACIA LOG", "acacia_log_top.png", "acacia_log_side.png", "acacia_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], + [42, "ACACIA LEAVES", "acacia_leaves.png", "acacia_leaves.png", "acacia_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE, 0.2, "axe", 0], + [43, "JUNGLE LOG", "jungle_log_top.png", "jungle_log_side.png", "jungle_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], + [44, "JUNGLE LEAVES", "jungle_leaves.png", "jungle_leaves.png", "jungle_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE, 0.2, "axe", 0], + [45, "MANGROVE LOG", "mangrove_log_top.png", "mangrove_log_side.png", "mangrove_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], + [46, "MANGROVE LEAVES", "mangrove_leaves.png", "mangrove_leaves.png", "mangrove_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE, 0.2, "axe", 0], + [47, "MANGROVE ROOTS", "mangrove_roots.png", "mangrove_roots.png", "mangrove_roots.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 0.7, "axe", 0], + [48, "LAVA", "lava.png", "lava.png", "lava.png", FLAG_OPAQUE | FLAG_EMISSIVE, 1.0, "pickaxe", 0], + [49, "BROWN MUSHROOM", "brown_mushroom_block.png", "brown_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE, 0.0, "", 0], + [50, "RED MUSHROOM", "red_mushroom_block.png", "red_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE, 0.0, "", 0], + [51, "MELON", "melon_top.png", "melon_side.png", "melon_side.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 1.0, "axe", 0], + [52, "CORAL SUBSTRATE", "coral_substrate.png", "coral_substrate.png", "coral_substrate.png", FLAG_OPAQUE, 1.5, "pickaxe", 1], + [53, "SEAGRASS", "seagrass.png", "seagrass.png", "seagrass.png", FLAG_CUTOUT | FLAG_CROSS, 0.0, "", 0], + [54, "KELP", "kelp.png", "kelp.png", "kelp.png", FLAG_CUTOUT | FLAG_CROSS, 0.0, "", 0], + [55, "CORAL FAN", "coral_fan.png", "coral_fan.png", "coral_fan.png", FLAG_CUTOUT | FLAG_CROSS, 0.0, "", 0], + [56, "CORAL BRANCH", "coral_branch.png", "coral_branch.png", "coral_branch.png", FLAG_CUTOUT | FLAG_CROSS, 0.0, "", 0], + [57, "SPONGE", "sponge.png", "sponge.png", "sponge.png", FLAG_OPAQUE, 0.6, "", 0], + [58, "ANEMONE", "anemone.png", "anemone.png", "anemone.png", FLAG_CUTOUT | FLAG_CROSS, 0.0, "", 0], + [59, "TNT", "tnt_top.png", "tnt_side.png", "tnt_bottom.png", FLAG_OPAQUE, 0.0, "", 0], + [60, "NUKE", "nuke_top.png", "nuke_side.png", "nuke_bottom.png", FLAG_OPAQUE, 0.0, "", 0], + [61, "DRIPSTONE", "dripstone.png", "dripstone.png", "dripstone.png", FLAG_OPAQUE, 1.5, "pickaxe", 1], + [62, "MOSS", "moss.png", "moss.png", "moss.png", FLAG_OPAQUE, 0.1, "shovel", 0], + [63, "CAVE MOSS", "cave_moss.png", "cave_moss.png", "cave_moss.png", FLAG_CUTOUT | FLAG_CROSS, 0.0, "", 0], + [64, "DEEPSTONE", "deepstone.png", "deepstone.png", "deepstone.png", FLAG_OPAQUE, 3.0, "pickaxe", 1], + [65, "SCULK", "sculk.png", "sculk.png", "sculk.png", FLAG_OPAQUE | FLAG_EMISSIVE, 0.2, "", 0], + [66, "CALCITE", "calcite.png", "calcite.png", "calcite.png", FLAG_OPAQUE, 0.75, "pickaxe", 1], + [67, "GEODE SHELL", "geode_shell.png", "geode_shell.png", "geode_shell.png", FLAG_OPAQUE, 1.5, "pickaxe", 1], + [68, "AMETHYST", "amethyst.png", "amethyst.png", "amethyst.png", FLAG_OPAQUE | FLAG_EMISSIVE, 1.5, "pickaxe", 1], + [69, "CRYSTAL BUD", "crystal_bud.png", "crystal_bud.png", "crystal_bud.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE, 1.5, "pickaxe", 1], + [70, "FIRE", "fire.png", "fire.png", "fire.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE, 0.0, "", 0], + [71, "CRAFTING TABLE", "wood_top.png", "wood_side.png", "wood_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.5, "axe", 0], + [72, "CHEST", "wood_top.png", "wood_side.png", "wood_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.5, "axe", 0], + [73, "FURNACE", "stone.png", "cobblestone.png", "stone.png", FLAG_OPAQUE, 3.5, "pickaxe", 1], + [74, "PLANKS", "planks.png", "planks.png", "planks.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], ] const TEXTURE_ROOT := "res://assets/placeholders/zigcraft/default/" @@ -251,6 +260,28 @@ func _init() -> void: _load_water_material() +## Wrong or under-tier tools can destroy a block, but cannot harvest its drops. +static func break_seconds(block_id: int, tool_id: int = 0) -> float: + if block_id <= BLOCK_AIR or block_id >= BLOCK_DEFS.size(): + return -1.0 + var def: Array = BLOCK_DEFS[block_id] + if float(def[6]) < 0.0 or (int(def[5]) & FLAG_UNBREAKABLE) != 0: + return -1.0 + var speed := 1.0 + if not String(def[7]).is_empty() and ItemRegistry.tool_kind(tool_id) == def[7]: + speed = float(ItemRegistry.tool_tier(tool_id) * 2) + return float(def[6]) * (1.5 if can_harvest(block_id, tool_id) else 5.0) / speed + + +static func can_harvest(block_id: int, tool_id: int = 0) -> bool: + if block_id <= BLOCK_AIR or block_id >= BLOCK_DEFS.size(): + return false + var def: Array = BLOCK_DEFS[block_id] + if float(def[6]) < 0.0 or (int(def[5]) & FLAG_UNBREAKABLE) != 0: + return false + return int(def[8]) == 0 or (ItemRegistry.tool_kind(tool_id) == def[7] and ItemRegistry.tool_tier(tool_id) >= int(def[8])) + + func is_valid_id(block_id: int) -> bool: return block_id > BLOCK_AIR and block_id < _names.size() @@ -392,6 +423,14 @@ func _build_material(array_texture: Texture2DArray, fallback_image: Image) -> vo func _load_image(path: String) -> Image: + if path == TEXTURE_ROOT + "planks.png": + var planks := Image.create(TILE_PX, TILE_PX, false, Image.FORMAT_RGBA8) + for y in TILE_PX: + for x in TILE_PX: + var seam := y % 16 == 0 or (x + (y / 16) * 24) % 48 == 0 + var grain := float((x * 13 + (y / 3) * 7) % 11) / 100.0 + planks.set_pixel(x, y, Color("715033") if seam else Color("b98c54").darkened(grain)) + return planks var texture := load(path) as Texture2D if texture == null: push_warning("Block texture missing, using placeholder: %s" % path) diff --git a/world/world_storage.gd b/world/world_storage.gd index 96cb7fe..a029c9c 100644 --- a/world/world_storage.gd +++ b/world/world_storage.gd @@ -38,9 +38,11 @@ func _init(p_root_path: String = DEFAULT_ROOT) -> void: func create_world(world_config: Dictionary, initial_state: Dictionary = {}, requested_id: String = "", - activate: bool = true) -> Dictionary: + activate: bool = true, game_mode: int = GameMode.CREATIVE) -> Dictionary: _reset_cache() metadata = {} + if not GameMode.is_valid(game_mode): + return {} var normalized_config := WorldGenConfig.new(world_config).to_dictionary() world_id = _safe_id(requested_id) if not requested_id.is_empty() and world_id != requested_id: @@ -62,6 +64,7 @@ func create_world(world_config: Dictionary, initial_state: Dictionary = {}, requ "created_unix": now, "updated_unix": now, "worldgen": normalized_config, + "game_mode": game_mode, "layout": { "chunk_size": VoxelDefs.CHUNK_SIZE, "world_height": VoxelDefs.WORLD_HEIGHT, @@ -227,7 +230,8 @@ static func duplicate_world(p_world_id: String, p_root_path: String = DEFAULT_RO return {} var copy := WorldStorage.new(p_root_path) var created := copy.create_world( - source_metadata.get("worldgen", {}), source_metadata.get("state", {}), "", false) + source_metadata.get("worldgen", {}), source_metadata.get("state", {}), "", false, + int(source_metadata.get("game_mode", GameMode.CREATIVE))) if created.is_empty(): return {} var source_name := String(source_metadata.get("name", "World")) @@ -293,6 +297,7 @@ static func _summary_from_metadata(value: Dictionary, id: String) -> Dictionary: return {} var worldgen: Dictionary = value["worldgen"] var worldgen_version := int(worldgen.get("worldgen_version", -1)) + var mode: Variant = value.get("game_mode", GameMode.CREATIVE) return { "id": id, "name": String(value.get("name", "")), @@ -301,8 +306,10 @@ static func _summary_from_metadata(value: Dictionary, id: String) -> Dictionary: "seed": int(worldgen.get("seed", 0)), "world_type": clampi(int(worldgen.get("world_type", 0)), 0, 2), "worldgen_version": worldgen_version, + "game_mode": int(mode) if GameMode.is_valid(mode) else -1, "compatible": int(value.get("format_version", -1)) == METADATA_VERSION \ - and worldgen_version >= 1 and worldgen_version <= WorldGenConfig.CURRENT_VERSION, + and worldgen_version >= 1 and worldgen_version <= WorldGenConfig.CURRENT_VERSION \ + and GameMode.is_valid(mode), } @@ -594,17 +601,21 @@ func _read_metadata_file(path: String) -> Dictionary: if FileAccess.file_exists(path): var primary: Variant = JSON.parse_string(FileAccess.get_file_as_string(path)) if typeof(primary) == TYPE_DICTIONARY and _validate_metadata(primary): + primary["game_mode"] = int(primary.get("game_mode", GameMode.CREATIVE)) return primary var backup := path + ".bak" if FileAccess.file_exists(backup): var recovered: Variant = JSON.parse_string(FileAccess.get_file_as_string(backup)) if typeof(recovered) == TYPE_DICTIONARY and _validate_metadata(recovered): _metadata_loaded_from_backup = true + recovered["game_mode"] = int(recovered.get("game_mode", GameMode.CREATIVE)) return recovered return {} func _validate_metadata(value: Dictionary) -> bool: + if not GameMode.is_valid(value.get("game_mode", GameMode.CREATIVE)): + return false if value.get("magic", "") != METADATA_MAGIC or int(value.get("format_version", -1)) != METADATA_VERSION: return false if _safe_id(String(value.get("id", ""))) != String(value.get("id", "")): diff --git a/world/worldgen/voxel_populator.gd b/world/worldgen/voxel_populator.gd index 106efd8..6dc0879 100644 --- a/world/worldgen/voxel_populator.gd +++ b/world/worldgen/voxel_populator.gd @@ -124,7 +124,8 @@ func _init(config_value: WorldGenConfig, biomes_value: BiomeCatalog, sampler_val biomes = biomes_value if biomes_value != null else BiomeCatalogScript.new() terrain_sampler = sampler_value decorations = DecorationCatalogScript.new(config.worldgen_version) - _ore_catalog = OreCatalogScript.new(config.worldgen_version) + # V14 changes stage eligibility, not the legacy ore distribution. + _ore_catalog = OreCatalogScript.new(mini(config.worldgen_version, OreCatalogScript.LEGACY_VERSION_MAX)) _cave_spaghetti_a = _make_cave_noise(1701, 0.018, 2) _cave_spaghetti_b = _make_cave_noise(1877, 0.015, 2) _cave_cheese = _make_cave_noise(1999, 0.009, 3) @@ -149,6 +150,10 @@ func populate(chunk_pos: Vector2i, field: ChunkTerrainData, edits: Dictionary, f _place_ore_veins(data, origin_x, origin_z) _place_geodes(data, field, origin_x, origin_z) _decorate_caves(data, field, origin_x, origin_z) + elif full_detail and config.world_type != WorldGenConfigScript.WORLD_TYPE_FLAT \ + and config.worldgen_version >= WorldGenConfigScript.CAVE_INDEPENDENT_ORES_VERSION: + # Preserve the cave-enabled stage order and pre-v14 cave-free worlds. + _place_ore_veins(data, origin_x, origin_z) if full_detail and config.decoration_density > 0.0: var decoration_scratch := DecorationGroundScratch.new() max_y = _decorate(data, field, origin_x, origin_z, max_y, decoration_scratch) @@ -436,6 +441,9 @@ func _fill_base_and_surface(data: PackedByteArray, field: ChunkTerrainData) -> i func _column_water_y(field: ChunkTerrainData, field_index: int, surface_y: int) -> int: + # Flat voxel terrain sits below sea level but must remain dry. + if config.world_type == WorldGenConfigScript.WORLD_TYPE_FLAT: + return -1 var inland_water: int = field.inland_water_y[field_index] if inland_water > surface_y: return inland_water diff --git a/world/worldgen/world_gen_config.gd b/world/worldgen/world_gen_config.gd index 11f0865..351cabf 100644 --- a/world/worldgen/world_gen_config.gd +++ b/world/worldgen/world_gen_config.gd @@ -3,9 +3,10 @@ class_name WorldGenConfig extends RefCounted -const CURRENT_VERSION: int = 13 +const CURRENT_VERSION: int = 14 const SPLINE_TERRAIN_VERSION: int = 10 const VARIANT_WORLDGEN_VERSION: int = 11 +const CAVE_INDEPENDENT_ORES_VERSION: int = 14 const WORLD_TYPE_NORMAL: int = 0 const WORLD_TYPE_FLAT: int = 1 From 73c97e9bdc78790bda0c509e81ef70121cac3f36 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 18 Sep 2026 08:32:19 +0100 Subject: [PATCH 2/2] Fix surface ore LOD parity and gameplay review findings --- game/item_registry.gd | 2 ++ game/main.gd | 11 ++++++----- player/player.gd | 28 ++++++++++++++------------- tools/fire_verify.gd | 9 +++++---- tools/gameplay_integration_verify.gd | 10 ++++++++++ tools/player_survival_verify.gd | 23 ++++++++++++++++++++++ tools/worldgen_lod_verify.gd | 8 ++++++++ tools/worldgen_ore_verify.gd | 29 ++++++++++++++++++++++++++++ world/voxel_world.gd | 9 +++++++++ world/worldgen/README.md | 6 ++++-- world/worldgen/voxel_populator.gd | 27 ++++++++++++++++++++++---- 11 files changed, 134 insertions(+), 28 deletions(-) diff --git a/game/item_registry.gd b/game/item_registry.gd index 501a0ed..6741953 100644 --- a/game/item_registry.gd +++ b/game/item_registry.gd @@ -100,6 +100,8 @@ static func fuel_seconds(item_id: int) -> float: static func harvest_drop(block_id: int) -> Dictionary: match block_id: + BlockRegistry.BLOCK_FIRE: + return {} BlockRegistry.BLOCK_STONE: return {"id": BlockRegistry.BLOCK_COBBLESTONE, "count": 1} BlockRegistry.BLOCK_COAL_ORE: diff --git a/game/main.gd b/game/main.gd index 3400d32..e592996 100644 --- a/game/main.gd +++ b/game/main.gd @@ -758,7 +758,7 @@ func _on_inventory_opened() -> void: if player.dead or _pause_menu.visible or (_photo_mode != null and _photo_mode.is_camera_active()): _inventory_overlay.close_panel() return - player._cancel_mining() + player.cancel_mining() if _map_overlay != null and _map_overlay.visible: _map_overlay.close() _inventory_overlay.set_weather_state(_weather.is_raining()) @@ -890,7 +890,9 @@ func _on_mined_block(position: Vector3i, block_id: int, harvest: bool) -> void: inventory.wear_tool(selected_slot) _drop_container_contents(position) if harvest and _game_mode == GameMode.SURVIVAL: - _drops.spawn_drop(Vector3(position) + Vector3.ONE * 0.5, ItemRegistry.harvest_drop(block_id)) + var drop := ItemRegistry.harvest_drop(block_id) + if not drop.is_empty(): + _drops.spawn_drop(Vector3(position) + Vector3.ONE * 0.5, drop) set_status("Mined %s" % world.get_block_name(block_id)) @@ -940,8 +942,7 @@ func _check_removed_containers() -> void: return for position: Vector3i in containers.containers.keys(): # get_block_world returns air for unloaded/LOD chunks, not actual absence. - var chunk := world._loaded_chunk_for(position) - if chunk == null or chunk.lod: + if not world.is_full_chunk_resident_at(position): continue if world.get_block_world(position) != int(containers.containers[position].block_id): _drop_container_contents(position) @@ -1004,7 +1005,7 @@ func _on_item_used(item_id: int, block_position: Vector3i) -> void: func select_slot(index: int) -> void: if selected_slot != clampi(index, 0, ItemInventory.HOTBAR_SIZE - 1) and player != null: - player._cancel_mining() + player.cancel_mining() selected_slot = clampi(index, 0, ItemInventory.HOTBAR_SIZE - 1) _update_slot_styles() if selected_slot < slot_panels.size(): diff --git a/player/player.gd b/player/player.gd index a330f85..5905cd7 100644 --- a/player/player.gd +++ b/player/player.gd @@ -102,7 +102,7 @@ func setup_world(world_node: VoxelWorld) -> void: func set_selected_block(block_id: int) -> void: if selected_block != block_id: - _cancel_mining() + cancel_mining() selected_block = block_id _update_held_block() @@ -223,14 +223,14 @@ func _reset_survival_motion() -> void: _survival_clock = 0.0 _regen_clock = 0.0 _last_jump_time = -10.0 - _cancel_mining() + cancel_mining() ## Detached photo-mode camera: freeze player simulation and hide the targeting ## highlight and the first-person held block so the composition cannot be ## disturbed or the hand model caught in the shot. func set_photo_mode(enabled: bool) -> void: - _cancel_mining() + cancel_mining() process_mode = Node.PROCESS_MODE_DISABLED if enabled else Node.PROCESS_MODE_INHERIT if enabled and _highlight != null: _highlight.visible = false @@ -246,7 +246,7 @@ func _unhandled_input(event: InputEvent) -> void: get_viewport().set_input_as_handled() return if event is InputEventMouseButton and event.button_index == MOUSE_BUTTON_LEFT and not event.pressed: - _cancel_mining() + cancel_mining() return if event is InputEventMouseMotion and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: var sensitivity := GameConfig.get_mouse_sensitivity() @@ -287,7 +287,7 @@ func _unhandled_input(event: InputEvent) -> void: _handle_double_tap_jump() elif event.is_action_pressed("third_person"): third_person = not third_person - _cancel_mining() + cancel_mining() func _physics_process(delta: float) -> void: @@ -309,7 +309,7 @@ func _physics_process(delta: float) -> void: if world != null and not world.is_collision_ready_at(global_position): velocity = Vector3.ZERO _fall_start = NAN - _cancel_mining() + cancel_mining() has_target = false if _highlight != null: _highlight.hide() @@ -319,7 +319,7 @@ func _physics_process(delta: float) -> void: if dead: return if _mining and (not Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) or Input.mouse_mode != Input.MOUSE_MODE_CAPTURED): - _cancel_mining() + cancel_mining() if not _mining and has_target and Input.is_mouse_button_pressed(MOUSE_BUTTON_LEFT) and Input.mouse_mode == Input.MOUSE_MODE_CAPTURED: _break_target() _tick_mining(delta) @@ -499,7 +499,7 @@ func _break_target() -> void: _tick_mining(0.0) -func _cancel_mining() -> void: +func cancel_mining() -> void: _mining = false mining_progress = 0.0 if _effects != null: @@ -511,11 +511,11 @@ func _tick_mining(delta: float) -> void: return if not has_target or target_block != _mining_position or selected_block != _mining_tool \ or world.get_block_world(target_block) != _mining_id: - _cancel_mining() + cancel_mining() return var seconds := BlockRegistry.break_seconds(_mining_id, _mining_tool) if seconds < 0.0 or not is_finite(seconds): - _cancel_mining() + cancel_mining() return mining_progress = 1.0 if game_mode == GameMode.CREATIVE else minf(1.0, mining_progress + delta / maxf(0.05, seconds)) if _effects != null: @@ -524,7 +524,7 @@ func _tick_mining(delta: float) -> void: return var position_mined := _mining_position var harvest := BlockRegistry.can_harvest(_mining_id, _mining_tool) - _cancel_mining() + cancel_mining() var removed := world.break_block(position_mined) if removed == BlockRegistry.BLOCK_AIR: return @@ -538,10 +538,12 @@ func _tick_mining(delta: float) -> void: func _place_target() -> void: if dead or world == null: return - _cancel_mining() + cancel_mining() if has_target and interact_check.is_valid() and interact_check.call(target_block): block_interacted.emit(target_block) return + if selected_block == BlockRegistry.BLOCK_AIR: + return if ItemRegistry.food_value(selected_block) > 0.0: item_used.emit(selected_block, target_block if has_target else Vector3i.ZERO) return @@ -664,7 +666,7 @@ func _update_survival(delta: float) -> void: if feet_id == BlockRegistry.BLOCK_LAVA or head_id == BlockRegistry.BLOCK_LAVA or ground_id == BlockRegistry.BLOCK_LAVA: take_damage(4.0, "lava") elif feet_id == BlockRegistry.BLOCK_FIRE or head_id == BlockRegistry.BLOCK_FIRE \ - or world._burning.has(ground_cell): + or world.is_burning_at(ground_cell): take_damage(2.0, "fire") if submerged and air <= 0.0: take_damage(2.0, "drowning") diff --git a/tools/fire_verify.gd b/tools/fire_verify.gd index e078fa2..0ea89b0 100644 --- a/tools/fire_verify.gd +++ b/tools/fire_verify.gd @@ -52,7 +52,7 @@ func _check_ignition_and_spread() -> void: for step in 12: world._fire_tick() # The log catches fire but is never replaced by a fire block. - _expect(world._burning.has(fuel_position), "fire did not catch the adjacent log") + _expect(world.is_burning_at(fuel_position), "fire did not catch the adjacent log") _expect(world.get_block_world(fuel_position) == BlockRegistry.BLOCK_LOG, "burning log was replaced instead of burning in place") # Burn-out destroys the block and persists the settled air edit. @@ -60,6 +60,7 @@ func _check_ignition_and_spread() -> void: world._fire_tick() _expect(world.get_block_world(fuel_position) == BlockRegistry.BLOCK_AIR, "burning log never crumbled away") + _expect(not world.is_burning_at(fuel_position), "burnt-out log still reports burning") _expect(world._edited_blocks.get(fuel_position, -1) == BlockRegistry.BLOCK_AIR, "burnt log did not persist its settled air edit") world.free() @@ -126,7 +127,7 @@ func _check_flint_and_steel() -> void: _set_block(world._chunks[Vector2i.ZERO].data, log_position, BlockRegistry.BLOCK_LOG) var lit_log := world.use_flint_and_steel(log_position, Vector3i.UP) _expect(int(lit_log.get("ignited", 0)) == 1, "flint and steel did not ignite a log") - _expect(world._burning.has(log_position), "clicked log did not start burning") + _expect(world.is_burning_at(log_position), "clicked log did not start burning") _expect(world.get_block_world(log_position) == BlockRegistry.BLOCK_LOG, "clicked log was replaced by a fire block instead of burning in place") @@ -144,7 +145,7 @@ func _check_submerged_and_support() -> void: _set_block(world._chunks[Vector2i.ZERO].data, wet_log + Vector3i.UP, BlockRegistry.BLOCK_WATER) var wet_lit := world.use_flint_and_steel(wet_log, Vector3i.UP) _expect(wet_lit.get("blocked", "") == "wet", "flint and steel lit a submerged log") - _expect(not world._burning.has(wet_log), "submerged log started burning") + _expect(not world.is_burning_at(wet_log), "submerged log started burning") # Water on a lateral face (a log at the waterline) also counts as flooded. var shoreline_log := Vector3i(13, 9, 13) @@ -152,7 +153,7 @@ func _check_submerged_and_support() -> void: _set_block(world._chunks[Vector2i.ZERO].data, shoreline_log + Vector3i(0, 0, 1), BlockRegistry.BLOCK_WATER) _expect(world.use_flint_and_steel(shoreline_log, Vector3i.UP).get("blocked", "") == "wet", "flint and steel lit a log touching water on the side") - _expect(not world._burning.has(shoreline_log), "shoreline log started burning") + _expect(not world.is_burning_at(shoreline_log), "shoreline log started burning") # Re-clicking a burning block reports already-burning rather than a face hint. var log_position := Vector3i(4, 9, 4) diff --git a/tools/gameplay_integration_verify.gd b/tools/gameplay_integration_verify.gd index 39957b1..186f6f1 100644 --- a/tools/gameplay_integration_verify.gd +++ b/tools/gameplay_integration_verify.gd @@ -74,6 +74,9 @@ func _run() -> void: _expect(main._drops.persistent_state().size() == 1 and main.inventory.slots[1].durability == 57, "failed harvest wears tool but yields no drop") main._on_mined_block(Vector3i.ZERO, BlockRegistry.BLOCK_COAL_ORE, true) _expect(main._drops.persistent_state()[1].stack.id == ItemRegistry.ITEM_COAL, "coal ore yields physical coal") + var before_fire: Array = main._drops.persistent_state() + main._on_mined_block(Vector3i.ZERO, BlockRegistry.BLOCK_FIRE, true) + _expect(main._drops.persistent_state() == before_fire, "extinguishing fire creates no physical drop") var food_before: Array = main.inventory.persistent_state() for index in main.inventory.slots.size(): main.inventory.slots[index] = {"id": BlockRegistry.BLOCK_DIRT, "count": 64, "durability": 0} @@ -112,6 +115,13 @@ func _run() -> void: main.containers.ensure_chest(chest_position).add_item(2, 80) main._check_removed_containers() _expect(main.containers.get_inventory(chest_position) != null, "unloaded chunks do not destroy containers") + var chest_chunk := VoxelWorld.Chunk.new() + chest_chunk.lod = true + main.world._chunks[Vector2i(10, 10)] = chest_chunk + _expect(not main.world.is_full_chunk_resident_at(chest_position), "LOD chunk is not full resident") + main._check_removed_containers() + _expect(main.containers.get_inventory(chest_position) != null, "LOD chunks do not destroy containers") + main.world._chunks.clear() main._drop_container_contents(chest_position) var drop_count := 0 for drop in main._drops.persistent_state(): diff --git a/tools/player_survival_verify.gd b/tools/player_survival_verify.gd index d665f9f..5671392 100644 --- a/tools/player_survival_verify.gd +++ b/tools/player_survival_verify.gd @@ -34,6 +34,10 @@ func _run() -> void: chunk.shape.shape = BoxShape3D.new() world.add_child(chunk.shape) world._chunks[Vector2i.ZERO] = chunk + _expect(world.is_full_chunk_resident_at(Vector3i(4, 4, 4)), "full chunk residency") + _expect(not world.is_full_chunk_resident_at(Vector3i(-1, 4, 4)), "unloaded negative coordinate is not resident") + _expect(not world.is_full_chunk_resident_at(Vector3i(4, -1, 4)) + and not world.is_full_chunk_resident_at(Vector3i(4, VoxelDefs.WORLD_HEIGHT, 4)), "out of height bounds is not resident") var player = load("res://player/player.gd").new() player.game_mode = GameMode.SURVIVAL var head := Node3D.new() @@ -151,6 +155,25 @@ func _run() -> void: player.can_place_check = func() -> bool: return false player._place_target() _expect(interactions == [cell], "interaction precedes placement validation") + var statuses: Array[String] = [] + player.status_requested.connect(func(message: String) -> void: statuses.append(message)) + player.interact_check = func(_pos: Vector3i) -> bool: return false + player.can_place_check = func() -> bool: + statuses.append("placement checked") + return false + player._place_target() + _expect(statuses.is_empty(), "empty hand neither checks placement nor emits a toast") + player._break_target() + player._tick_mining(0.01) + player.cancel_mining() + _expect(not player._mining and player.mining_progress == 0.0 and not player._effects.cracks.visible, "public cancellation clears mining and cracks") + _set_block(chunk.data, cell, BlockRegistry.BLOCK_FIRE) + player._break_target() + player._tick_mining(1.0) + _expect(world.get_block_world(cell) == BlockRegistry.BLOCK_AIR and mined.back()[1] == BlockRegistry.BLOCK_FIRE, + "bare-hand mining extinguishes breakable fire") + _expect(ItemRegistry.harvest_drop(BlockRegistry.BLOCK_FIRE).is_empty(), "fire has no collectible harvest") + _set_block(chunk.data, cell, BlockRegistry.BLOCK_STONE) player.third_person = true player._effects.update_view(0.1) _expect(camera.position.z > 0.0 and player._effects.body.visible and not player._held_block.visible, "third person") diff --git a/tools/worldgen_lod_verify.gd b/tools/worldgen_lod_verify.gd index 2eaa660..5e137d8 100644 --- a/tools/worldgen_lod_verify.gd +++ b/tools/worldgen_lod_verify.gd @@ -174,9 +174,16 @@ func _verify_lod_shading(mesher: ChunkMesher) -> void: var flat_config := BASE_CONFIG.duplicate() flat_config["world_type"] = WorldGenConfig.WORLD_TYPE_FLAT flat_config["decoration_density"] = 0.0 + flat_config["tree_density"] = 0.0 flat_config["cave_density"] = 0.0 flat.configure(flat_config) var flat_lod := flat.generate_data(Vector2i.ZERO, {}, true) + for column in VoxelDefs.CHUNK_AREA: + if flat_lod.lod_solid_y[column] != VoxelPopulator.FLAT_VOXEL_SURFACE_Y or flat_lod.lod_water_y[column] != -1: + _fail("flat shading fixture is not level dry ground at column %d" % column) + # Vertex colors include biome grass tint as well as occlusion. Neutralize + # tint in this lighting-only fixture, retaining the real terrain and patches. + flat_lod.foliage_tints.fill(Color.WHITE) var flat_mesh := mesher.build_lod(flat_lod.lod_solid_y, flat_lod.lod_solid_id, flat_lod.lod_sub_id, flat_lod.lod_water_y, flat_lod.lod_water_level, flat_lod.max_y, flat_lod.foliage_tints, flat_lod.water_tints, ChunkMesher.LodNeighbors.new()) var flat_spread := _top_luminance_spread(flat_mesh) if flat_spread > 0.002: @@ -184,6 +191,7 @@ func _verify_lod_shading(mesher: ChunkMesher) -> void: var hilly := TerrainGenerator.new() hilly.configure(BASE_CONFIG) var hilly_lod := hilly.generate_data(Vector2i(20, -12), {}, true) + hilly_lod.foliage_tints.fill(Color.WHITE) var hilly_mesh := mesher.build_lod(hilly_lod.lod_solid_y, hilly_lod.lod_solid_id, hilly_lod.lod_sub_id, hilly_lod.lod_water_y, hilly_lod.lod_water_level, hilly_lod.max_y, hilly_lod.foliage_tints, hilly_lod.water_tints, ChunkMesher.LodNeighbors.new()) var hilly_spread := _top_luminance_spread(hilly_mesh) if hilly_spread < 0.03: diff --git a/tools/worldgen_ore_verify.gd b/tools/worldgen_ore_verify.gd index 562d193..f29ed37 100644 --- a/tools/worldgen_ore_verify.gd +++ b/tools/worldgen_ore_verify.gd @@ -26,6 +26,7 @@ func _initialize() -> void: _verify_catalog(populator) _verify_stage_fingerprints(populator) _verify_replacement_semantics(populator) + _verify_compact_stamps(populator) _verify_generator_versions() if _failures.is_empty(): print("WORLDGEN ORE VERIFY: PASS") @@ -81,6 +82,34 @@ func _verify_replacement_semantics(populator: VoxelPopulator) -> void: _expect(data == before, "ore generation replaced a non-stone block") +func _verify_compact_stamps(populator: VoxelPopulator) -> void: + # Compare every possible exposed height to the frozen full-volume stage, + # including negative origins, ore-band boundaries and non-stone surfaces. + for origin in ORIGINS: + var full := _stone_volume() + populator._place_ore_veins(full, origin.x, origin.y) + var solid_y := PackedInt32Array() + var solid_id := PackedByteArray() + var sub_id := PackedByteArray() + solid_y.resize(VoxelDefs.CHUNK_AREA) + solid_id.resize(VoxelDefs.CHUNK_AREA) + sub_id.resize(VoxelDefs.CHUNK_AREA) + for y in range(1, VoxelDefs.WORLD_HEIGHT): + solid_y.fill(y) + solid_id.fill(BlockRegistry.BLOCK_STONE) + sub_id.fill(BlockRegistry.BLOCK_STONE) + for column in range(0, VoxelDefs.CHUNK_AREA, 3): + solid_id[column] = BlockRegistry.BLOCK_GRASS + for column in range(0, VoxelDefs.CHUNK_AREA, 5): + sub_id[column] = BlockRegistry.BLOCK_DIRT + populator._place_ore_veins(PackedByteArray(), origin.x, origin.y, solid_y, solid_id, sub_id) + for column in VoxelDefs.CHUNK_AREA: + var expected_top: int = BlockRegistry.BLOCK_GRASS if column % 3 == 0 else full[column + y * VoxelDefs.DATA_STRIDE_Y] + var expected_sub: int = BlockRegistry.BLOCK_DIRT if column % 5 == 0 else full[column + (y - 1) * VoxelDefs.DATA_STRIDE_Y] + _expect(solid_id[column] == expected_top and sub_id[column] == expected_sub, + "compact ore stamp differs at %s column=%d y=%d" % [origin, column, y]) + + func _verify_generator_versions() -> void: var generator := TerrainGenerator.new() _expect(WorldGenConfig.new().worldgen_version == 14, "new worlds must default to v14") diff --git a/world/voxel_world.gd b/world/voxel_world.gd index c8d5c22..a0c1c4e 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -1216,6 +1216,15 @@ func _data_index(block_position: Vector3i) -> int: return local_x + local_z * VoxelDefs.DATA_STRIDE_Z + block_position.y * VoxelDefs.DATA_STRIDE_Y +func is_full_chunk_resident_at(cell: Vector3i) -> bool: + var chunk := _loaded_chunk_for(cell) + return chunk != null and not chunk.lod + + +func is_burning_at(cell: Vector3i) -> bool: + return _burning.has(cell) + + func get_block_world(block_position: Vector3i) -> int: var chunk := _loaded_chunk_for(block_position) if chunk == null or chunk.lod: diff --git a/world/worldgen/README.md b/world/worldgen/README.md index 07e054b..c4cff73 100644 --- a/world/worldgen/README.md +++ b/world/worldgen/README.md @@ -26,8 +26,10 @@ catalogs/samplers used by worker jobs. A chunk then runs these ordered stages: decorations and v11 region POIs, then applies player edits last. 6. `VoxelPopulator.populate_lod()` handles distance chunks: it writes compact per-column top/sub/water arrays instead of a full voxel volume and skips - caves, ores, and detailed flora. It retains floor patches, low scrub, and real - tree crowns using in-field anchors and shared stamp functions; site-validity probes are + caves, buried ores, and detailed flora. V14 samples the shared ore stamps only + at the compact top/sub cells so exposed stone matches full-detail ore. It retains + floor patches, low scrub, and real tree crowns using in-field anchors and shared + stamp functions; site-validity probes are skipped because they leave the padded field and cost nearly full population. `ChunkMesher.build_lod()` consumes those arrays directly, and full chunks expand compact neighbors on the worker thread. Keep the compact diff --git a/world/worldgen/voxel_populator.gd b/world/worldgen/voxel_populator.gd index 6dc0879..bc91160 100644 --- a/world/worldgen/voxel_populator.gd +++ b/world/worldgen/voxel_populator.gd @@ -165,7 +165,7 @@ func populate(chunk_pos: Vector2i, field: ChunkTerrainData, edits: Dictionary, f ## Compact LOD population: the distance mesh only needs each column's top/sub ## block and water surface, so no 192-block data array is allocated; caves, -## ores, and ground flora are skipped. Real tree crowns from in-field anchors +## buried ores, and ground flora are skipped. Real tree crowns from in-field anchors ## are baked into the compact columns so forests do not vanish past the ## full-detail ring. Far chunks cost kilobytes instead of ~50 KB and stream ## several times faster. Player edits are intentionally ignored here: LOD @@ -208,6 +208,9 @@ func populate_lod(chunk_pos: Vector2i, field: ChunkTerrainData) -> Dictionary: else: max_y = maxi(max_y, surface_y) _apply_lod_floor_patches(solid_y, solid_id, field, origin_x, origin_z) + if config.worldgen_version >= WorldGenConfigScript.CAVE_INDEPENDENT_ORES_VERSION \ + and config.world_type != WorldGenConfigScript.WORLD_TYPE_FLAT: + _place_ore_veins(PackedByteArray(), origin_x, origin_z, solid_y, solid_id, sub_id) var terrain_solid_y: PackedInt32Array = solid_y.duplicate() var terrain_solid_id: PackedByteArray = solid_id.duplicate() var terrain_sub_id: PackedByteArray = sub_id.duplicate() @@ -859,7 +862,9 @@ func _fill_lava_lake(data: PackedByteArray, field: ChunkTerrainData, center: Vec data[_index(local_x, y, local_z)] = BlockRegistryScript.BLOCK_LAVA -func _place_ore_veins(data: PackedByteArray, origin_x: int, origin_z: int) -> void: +func _place_ore_veins(data: PackedByteArray, origin_x: int, origin_z: int, + solid_y: PackedInt32Array = PackedInt32Array(), solid_id: PackedByteArray = PackedByteArray(), + sub_id: PackedByteArray = PackedByteArray()) -> void: var first_x: int = WorldGenHashScript.floor_div(origin_x - 8, ORE_CELL_SIZE) var last_x: int = WorldGenHashScript.floor_div(origin_x + VoxelDefsScript.CHUNK_SIZE + 8, ORE_CELL_SIZE) var first_z: int = WorldGenHashScript.floor_div(origin_z - 8, ORE_CELL_SIZE) @@ -873,7 +878,7 @@ func _place_ore_veins(data: PackedByteArray, origin_x: int, origin_z: int) -> vo continue var start := Vector3i(cell_x * ORE_CELL_SIZE + 2 + hash_value % 16, cell_y * ORE_CELL_SIZE + 2 + (hash_value / 17) % 16, cell_z * ORE_CELL_SIZE + 2 + (hash_value / 73) % 16) var finish := start + Vector3i((hash_value / 7) % 7 - 3, (hash_value / 131) % 5 - 2, (hash_value / 251) % 7 - 3) - _stamp_ore_segment(data, origin_x, origin_z, start, finish, ore) + _stamp_ore_segment(data, origin_x, origin_z, start, finish, ore, solid_y, solid_id, sub_id) ## Sparse deterministic cave details. A global four-block lattice samples open @@ -1069,7 +1074,9 @@ func _ore_for_anchor(hash_value: int, y: int) -> int: return _ore_catalog.select(hash_value, y) -func _stamp_ore_segment(data: PackedByteArray, origin_x: int, origin_z: int, start: Vector3i, finish: Vector3i, ore: int) -> void: +func _stamp_ore_segment(data: PackedByteArray, origin_x: int, origin_z: int, start: Vector3i, finish: Vector3i, ore: int, + solid_y: PackedInt32Array = PackedInt32Array(), solid_id: PackedByteArray = PackedByteArray(), + sub_id: PackedByteArray = PackedByteArray()) -> void: var distance: int = maxi(maxi(absi(finish.x - start.x), absi(finish.y - start.y)), absi(finish.z - start.z)) for step in range(distance + 1): var center := Vector3i(start.x + (finish.x - start.x) * step / maxi(1, distance), start.y + (finish.y - start.y) * step / maxi(1, distance), start.z + (finish.z - start.z) * step / maxi(1, distance)) @@ -1081,6 +1088,18 @@ func _stamp_ore_segment(data: PackedByteArray, origin_x: int, origin_z: int, sta var local_x: int = x - origin_x if local_x < 0 or local_x >= VoxelDefsScript.CHUNK_SIZE: continue + if not solid_y.is_empty(): + # Same ordered stamps and stone-only replacement as full detail, + # but intersect just the two exposed cells, never a voxel volume. + var column: int = local_x + local_z * VoxelDefsScript.DATA_STRIDE_Z + var top_y: int = solid_y[column] + if top_y >= 1 and top_y < VoxelDefsScript.WORLD_HEIGHT \ + and absi(top_y - center.y) <= 1 and solid_id[column] == BlockRegistryScript.BLOCK_STONE: + solid_id[column] = ore + if top_y > 1 and top_y <= VoxelDefsScript.WORLD_HEIGHT \ + and absi(top_y - 1 - center.y) <= 1 and sub_id[column] == BlockRegistryScript.BLOCK_STONE: + sub_id[column] = ore + continue for y in range(center.y - 1, center.y + 2): if y < 1 or y >= VoxelDefsScript.WORLD_HEIGHT: continue