diff --git a/ROADMAP.md b/ROADMAP.md index 78381d1..77623d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -86,8 +86,8 @@ Property names and file references are included so each item is easy to find. - [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 +- [x] Functional blocks — appended byte-safe voxel state IDs preserve four-way rotation without changing the region format; stairs, top/bottom slabs, paired two-block doors, wall ladders, freestanding signs, and low beds use shape-specific render/collision meshes. Placement derives rotation from player facing (and attachment face for ladders/slabs), doors toggle both persisted halves, ladders are pass-through and climbable, state variants drop/pick their canonical inventory item, and all six families have Survival recipes while hidden states stay out of the Creative catalog. Crafting tables, chests, furnaces, doors, ladders, signs, and beds now use a dedicated generated 64 px texture set under `assets/placeholders/functional/` instead of generic wood/stone faces. `tools/functional_blocks_verify.gd` pins state mapping, texture wiring, geometry/collision, recipes, and canonical drops +- [x] Sleeping and spawn points — interacting with a bed at night skips to `DayNightCycle.SUNRISE_HOUR`, stores a validated nearby safe respawn in the existing persisted player state, and flushes immediately. Daytime, rain/snow storms, and the world threat-query hook block sleep with HUD feedback; the hook defaults to no threats until the planned mob/entity system installs its nearby-hostile query. `tools/functional_blocks_verify.gd` covers clock and threat contracts - [ ] Mobs and combat — passive and hostile creatures with light/biome/time spawning rules, pathfinding, and melee combat; no entity system exists beyond the player - [x] Fly boost — holding Ctrl while flying multiplies fly speed and acceleration 5x (`player/player.gd` `FLY_BOOST_MULTIPLIER`); pause menu controls updated - [x] Terrain-carving explosives — TNT (5-block radius) and nuke (18-block radius) are placeable hotbar blocks with generated placeholder textures; flint and steel is a tool-only item that right-click activates either block immediately. `VoxelWorld.carve_sphere()` persists every removed voxel while batching each affected chunk remesh once, preserves bedrock, and skips unloaded/LOD terrain. `tools/explosives_verify.tscn` covers activation, radii, persistence, sphere bounds, unbreakable blocks, and item signaling diff --git a/assets/placeholders/functional/README.md b/assets/placeholders/functional/README.md new file mode 100644 index 0000000..226a69f --- /dev/null +++ b/assets/placeholders/functional/README.md @@ -0,0 +1,12 @@ +# Functional block textures + +These 64 px placeholder textures are generated specifically for RedotCraft by +`tools/gen_functional_textures.gd`. They distinguish crafting, storage, +furnaces, doors, ladders, signs, and beds from generic wood and stone while +remaining visually compatible with the existing voxel placeholder pack. + +Regenerate from the repository root: + +```sh +redot --headless --path . --script res://tools/gen_functional_textures.gd +``` diff --git a/assets/placeholders/functional/bed_bottom.png b/assets/placeholders/functional/bed_bottom.png new file mode 100644 index 0000000..1ccb56a Binary files /dev/null and b/assets/placeholders/functional/bed_bottom.png differ diff --git a/assets/placeholders/functional/bed_bottom.png.import b/assets/placeholders/functional/bed_bottom.png.import new file mode 100644 index 0000000..1a9d6de --- /dev/null +++ b/assets/placeholders/functional/bed_bottom.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://blr7mncu5uphm" +path="res://.godot/imported/bed_bottom.png-e636a2b00e916fa823fb1e838b6662d6.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/bed_bottom.png" +dest_files=["res://.godot/imported/bed_bottom.png-e636a2b00e916fa823fb1e838b6662d6.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/bed_side.png b/assets/placeholders/functional/bed_side.png new file mode 100644 index 0000000..71be562 Binary files /dev/null and b/assets/placeholders/functional/bed_side.png differ diff --git a/assets/placeholders/functional/bed_side.png.import b/assets/placeholders/functional/bed_side.png.import new file mode 100644 index 0000000..5861675 --- /dev/null +++ b/assets/placeholders/functional/bed_side.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://03qi4s456osd" +path="res://.godot/imported/bed_side.png-2f9605a279acbf0e04ffa1494b008619.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/bed_side.png" +dest_files=["res://.godot/imported/bed_side.png-2f9605a279acbf0e04ffa1494b008619.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/bed_top.png b/assets/placeholders/functional/bed_top.png new file mode 100644 index 0000000..87c9fa9 Binary files /dev/null and b/assets/placeholders/functional/bed_top.png differ diff --git a/assets/placeholders/functional/bed_top.png.import b/assets/placeholders/functional/bed_top.png.import new file mode 100644 index 0000000..9bc175e --- /dev/null +++ b/assets/placeholders/functional/bed_top.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dlf2evqecsdb8" +path="res://.godot/imported/bed_top.png-0d5b24202ed13a9c041e5a7a6008f815.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/bed_top.png" +dest_files=["res://.godot/imported/bed_top.png-0d5b24202ed13a9c041e5a7a6008f815.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/chest_bottom.png b/assets/placeholders/functional/chest_bottom.png new file mode 100644 index 0000000..342d76d Binary files /dev/null and b/assets/placeholders/functional/chest_bottom.png differ diff --git a/assets/placeholders/functional/chest_bottom.png.import b/assets/placeholders/functional/chest_bottom.png.import new file mode 100644 index 0000000..bddad1b --- /dev/null +++ b/assets/placeholders/functional/chest_bottom.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bf2enltr4qfgy" +path="res://.godot/imported/chest_bottom.png-d0805795eed322ea8cf1ee565c157c49.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/chest_bottom.png" +dest_files=["res://.godot/imported/chest_bottom.png-d0805795eed322ea8cf1ee565c157c49.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/chest_side.png b/assets/placeholders/functional/chest_side.png new file mode 100644 index 0000000..821955a Binary files /dev/null and b/assets/placeholders/functional/chest_side.png differ diff --git a/assets/placeholders/functional/chest_side.png.import b/assets/placeholders/functional/chest_side.png.import new file mode 100644 index 0000000..46cd364 --- /dev/null +++ b/assets/placeholders/functional/chest_side.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://sk0tixpjhm5h" +path="res://.godot/imported/chest_side.png-7fe6681533c6f38bba230e4e448a6c03.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/chest_side.png" +dest_files=["res://.godot/imported/chest_side.png-7fe6681533c6f38bba230e4e448a6c03.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/chest_top.png b/assets/placeholders/functional/chest_top.png new file mode 100644 index 0000000..075ce49 Binary files /dev/null and b/assets/placeholders/functional/chest_top.png differ diff --git a/assets/placeholders/functional/chest_top.png.import b/assets/placeholders/functional/chest_top.png.import new file mode 100644 index 0000000..7600e36 --- /dev/null +++ b/assets/placeholders/functional/chest_top.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://t8vbry1ndhbh" +path="res://.godot/imported/chest_top.png-72000fbea43cce52fc3c68d744d9e5fa.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/chest_top.png" +dest_files=["res://.godot/imported/chest_top.png-72000fbea43cce52fc3c68d744d9e5fa.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/crafting_bottom.png b/assets/placeholders/functional/crafting_bottom.png new file mode 100644 index 0000000..213483c Binary files /dev/null and b/assets/placeholders/functional/crafting_bottom.png differ diff --git a/assets/placeholders/functional/crafting_bottom.png.import b/assets/placeholders/functional/crafting_bottom.png.import new file mode 100644 index 0000000..a556ad6 --- /dev/null +++ b/assets/placeholders/functional/crafting_bottom.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dfvlwrv8lb8f6" +path="res://.godot/imported/crafting_bottom.png-5214b5ee3c97a34bcb116a1b4acc4f5e.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/crafting_bottom.png" +dest_files=["res://.godot/imported/crafting_bottom.png-5214b5ee3c97a34bcb116a1b4acc4f5e.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/crafting_side.png b/assets/placeholders/functional/crafting_side.png new file mode 100644 index 0000000..b9bebae Binary files /dev/null and b/assets/placeholders/functional/crafting_side.png differ diff --git a/assets/placeholders/functional/crafting_side.png.import b/assets/placeholders/functional/crafting_side.png.import new file mode 100644 index 0000000..0507e0c --- /dev/null +++ b/assets/placeholders/functional/crafting_side.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cmd8xfugav8i0" +path="res://.godot/imported/crafting_side.png-5e7831cf53a40a3ed12f9e913b40235f.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/crafting_side.png" +dest_files=["res://.godot/imported/crafting_side.png-5e7831cf53a40a3ed12f9e913b40235f.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/crafting_top.png b/assets/placeholders/functional/crafting_top.png new file mode 100644 index 0000000..7f7b200 Binary files /dev/null and b/assets/placeholders/functional/crafting_top.png differ diff --git a/assets/placeholders/functional/crafting_top.png.import b/assets/placeholders/functional/crafting_top.png.import new file mode 100644 index 0000000..ccf2ccf --- /dev/null +++ b/assets/placeholders/functional/crafting_top.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://dj1784od2144x" +path="res://.godot/imported/crafting_top.png-378c242c540f52a75a70e9ff21ff6107.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/crafting_top.png" +dest_files=["res://.godot/imported/crafting_top.png-378c242c540f52a75a70e9ff21ff6107.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/door_lower.png b/assets/placeholders/functional/door_lower.png new file mode 100644 index 0000000..991d7e6 Binary files /dev/null and b/assets/placeholders/functional/door_lower.png differ diff --git a/assets/placeholders/functional/door_lower.png.import b/assets/placeholders/functional/door_lower.png.import new file mode 100644 index 0000000..71ac5b3 --- /dev/null +++ b/assets/placeholders/functional/door_lower.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cnqvxii1eui6o" +path="res://.godot/imported/door_lower.png-f4e0ae10de068aad291ed8dee8abd2f8.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/door_lower.png" +dest_files=["res://.godot/imported/door_lower.png-f4e0ae10de068aad291ed8dee8abd2f8.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/door_upper.png b/assets/placeholders/functional/door_upper.png new file mode 100644 index 0000000..223e71e Binary files /dev/null and b/assets/placeholders/functional/door_upper.png differ diff --git a/assets/placeholders/functional/door_upper.png.import b/assets/placeholders/functional/door_upper.png.import new file mode 100644 index 0000000..c727730 --- /dev/null +++ b/assets/placeholders/functional/door_upper.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://wfe2ys18ic5m" +path="res://.godot/imported/door_upper.png-c6b2de1199a16b6dc75963bd7d5ab26b.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/door_upper.png" +dest_files=["res://.godot/imported/door_upper.png-c6b2de1199a16b6dc75963bd7d5ab26b.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/furnace_bottom.png b/assets/placeholders/functional/furnace_bottom.png new file mode 100644 index 0000000..e4170a8 Binary files /dev/null and b/assets/placeholders/functional/furnace_bottom.png differ diff --git a/assets/placeholders/functional/furnace_bottom.png.import b/assets/placeholders/functional/furnace_bottom.png.import new file mode 100644 index 0000000..6e2d83f --- /dev/null +++ b/assets/placeholders/functional/furnace_bottom.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://clv6g0cbdd5el" +path="res://.godot/imported/furnace_bottom.png-83f681d7a1774b1395d06a838d566f16.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/furnace_bottom.png" +dest_files=["res://.godot/imported/furnace_bottom.png-83f681d7a1774b1395d06a838d566f16.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/furnace_side.png b/assets/placeholders/functional/furnace_side.png new file mode 100644 index 0000000..0c9c595 Binary files /dev/null and b/assets/placeholders/functional/furnace_side.png differ diff --git a/assets/placeholders/functional/furnace_side.png.import b/assets/placeholders/functional/furnace_side.png.import new file mode 100644 index 0000000..f8d8754 --- /dev/null +++ b/assets/placeholders/functional/furnace_side.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bwhyhm06n0hi5" +path="res://.godot/imported/furnace_side.png-b7c7aabc7a7e5d4bda181fdd2aa4d906.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/furnace_side.png" +dest_files=["res://.godot/imported/furnace_side.png-b7c7aabc7a7e5d4bda181fdd2aa4d906.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/furnace_top.png b/assets/placeholders/functional/furnace_top.png new file mode 100644 index 0000000..0d0df82 Binary files /dev/null and b/assets/placeholders/functional/furnace_top.png differ diff --git a/assets/placeholders/functional/furnace_top.png.import b/assets/placeholders/functional/furnace_top.png.import new file mode 100644 index 0000000..3663888 --- /dev/null +++ b/assets/placeholders/functional/furnace_top.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://cu3iu2jiumelg" +path="res://.godot/imported/furnace_top.png-5c94a6d05142f868b078fbda8c0a3580.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/furnace_top.png" +dest_files=["res://.godot/imported/furnace_top.png-5c94a6d05142f868b078fbda8c0a3580.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/ladder.png b/assets/placeholders/functional/ladder.png new file mode 100644 index 0000000..e685405 Binary files /dev/null and b/assets/placeholders/functional/ladder.png differ diff --git a/assets/placeholders/functional/ladder.png.import b/assets/placeholders/functional/ladder.png.import new file mode 100644 index 0000000..6af4de9 --- /dev/null +++ b/assets/placeholders/functional/ladder.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://ddex5d0u3nhho" +path="res://.godot/imported/ladder.png-838834cb99b3257779672f0710c7fead.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/ladder.png" +dest_files=["res://.godot/imported/ladder.png-838834cb99b3257779672f0710c7fead.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/assets/placeholders/functional/sign.png b/assets/placeholders/functional/sign.png new file mode 100644 index 0000000..93ec2b6 Binary files /dev/null and b/assets/placeholders/functional/sign.png differ diff --git a/assets/placeholders/functional/sign.png.import b/assets/placeholders/functional/sign.png.import new file mode 100644 index 0000000..ed5b1af --- /dev/null +++ b/assets/placeholders/functional/sign.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://mjmt8vyreo3g" +path="res://.godot/imported/sign.png-863140e02cd74291d12155e60499c567.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/functional/sign.png" +dest_files=["res://.godot/imported/sign.png-863140e02cd74291d12155e60499c567.ctex"] + +[params] + +compress/mode=0 +compress/high_quality=false +compress/lossy_quality=0.7 +compress/uastc_level=0 +compress/rdo_quality_loss=0.0 +compress/hdr_compression=1 +compress/normal_map=0 +compress/channel_pack=0 +mipmaps/generate=false +mipmaps/limit=-1 +roughness/mode=0 +roughness/src_normal="" +process/channel_remap/red=0 +process/channel_remap/green=1 +process/channel_remap/blue=2 +process/channel_remap/alpha=3 +process/fix_alpha_border=true +process/premult_alpha=false +process/normal_map_invert_y=false +process/hdr_as_srgb=false +process/hdr_clamp_exposure=false +process/size_limit=0 +detect_3d/compress_to=1 diff --git a/autoload/audio_manager.gd b/autoload/audio_manager.gd index d1e1425..34b8592 100644 --- a/autoload/audio_manager.gd +++ b/autoload/audio_manager.gd @@ -126,6 +126,8 @@ func _build_block_materials() -> void: _block_materials[block_id] = material for level in range(BlockRegistry.BLOCK_WATER_FLOW_7, BlockRegistry.BLOCK_WATER_FLOW_1 + 1): _block_materials[level] = "water" + for block_id in range(BlockRegistry.BLOCK_WOOD_STAIRS, BlockRegistry.BLOCK_WOOD_BED_LAST + 1): + _block_materials[block_id] = "wood" func material_for_block(block_id: int) -> String: diff --git a/game/crafting_recipes.gd b/game/crafting_recipes.gd index b3fc8df..61be870 100644 --- a/game/crafting_recipes.gd +++ b/game/crafting_recipes.gd @@ -35,6 +35,12 @@ const RECIPES := [ {"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"}, + {"id": "wood_stairs", "ingredients": {B.BLOCK_PLANKS: 6}, "output": B.BLOCK_WOOD_STAIRS, "count": 4, "table": true, "category": "Building"}, + {"id": "wood_slabs", "ingredients": {B.BLOCK_PLANKS: 3}, "output": B.BLOCK_WOOD_SLAB, "count": 6, "table": true, "category": "Building"}, + {"id": "wood_door", "ingredients": {B.BLOCK_PLANKS: 6}, "output": B.BLOCK_WOOD_DOOR, "count": 1, "table": true, "category": "Building"}, + {"id": "wood_ladders", "ingredients": {B.BLOCK_PLANKS: 3, I.ITEM_STICK: 4}, "output": B.BLOCK_WOOD_LADDER, "count": 3, "table": true, "category": "Building"}, + {"id": "wood_signs", "ingredients": {B.BLOCK_PLANKS: 6, I.ITEM_STICK: 1}, "output": B.BLOCK_WOOD_SIGN, "count": 3, "table": true, "category": "Building"}, + {"id": "wood_bed", "ingredients": {B.BLOCK_PLANKS: 6}, "output": B.BLOCK_WOOD_BED, "count": 1, "table": true, "category": "Building", "description": "Use at night to skip to morning and set your respawn point."}, ] ## Input ID -> output ID. One input and ten seconds produce one output. diff --git a/game/item_registry.gd b/game/item_registry.gd index 6741953..8a65f77 100644 --- a/game/item_registry.gd +++ b/game/item_registry.gd @@ -43,7 +43,7 @@ static func is_item(item_id: int) -> bool: 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()) + return is_item(item_id) or BlockRegistry.is_inventory_block(item_id) static func tool_kind(item_id: int) -> String: @@ -106,7 +106,7 @@ static func harvest_drop(block_id: int) -> Dictionary: return {"id": BlockRegistry.BLOCK_COBBLESTONE, "count": 1} BlockRegistry.BLOCK_COAL_ORE: return {"id": ITEM_COAL, "count": 1} - return {"id": block_id, "count": 1} + return {"id": BlockRegistry.canonical_id(block_id), "count": 1} static func get_item_name(item_id: int) -> String: diff --git a/game/main.gd b/game/main.gd index e592996..60fe8ad 100644 --- a/game/main.gd +++ b/game/main.gd @@ -525,7 +525,9 @@ func _connect_player() -> void: 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] + var block_id := world.get_block_world(position) + return block_id in [BlockRegistry.BLOCK_CRAFTING_TABLE, BlockRegistry.BLOCK_CHEST, BlockRegistry.BLOCK_FURNACE] \ + or BlockRegistry.is_door(block_id) or BlockRegistry.is_bed(block_id) func _build_pause_menu() -> void: @@ -922,8 +924,37 @@ func _on_block_picked(block_id: int) -> void: func _on_block_interacted(position: Vector3i) -> void: - if not player.dead: - _inventory_overlay.open_station(position, world.get_block_world(position), containers) + if player.dead: + return + var block_id := world.get_block_world(position) + if BlockRegistry.is_door(block_id): + if world.toggle_door(position): + set_status("Door opened" if BlockRegistry.door_open(world.get_block_world(position)) else "Door closed") + return + if BlockRegistry.is_bed(block_id): + _attempt_sleep(position) + return + _inventory_overlay.open_station(position, block_id, containers) + + +func _attempt_sleep(bed_position: Vector3i) -> void: + if not _day_night.is_night(): + set_status("You can only sleep at night") + return + if _weather.is_raining(): + set_status("You cannot sleep while it is raining") + return + if world.is_threatened(player.global_position): + set_status("You cannot sleep while threatened") + return + var bed_spawn := world.find_bed_spawn(bed_position) + if not bool(bed_spawn.get("found", false)): + set_status("No safe respawn point near this bed") + return + player.spawn_position = bed_spawn.position + _day_night.skip_to_morning() + set_status("Slept until morning · respawn point set") + _flush_world_save() func _drop_container_contents(position: Vector3i) -> void: diff --git a/player/player.gd b/player/player.gd index 5905cd7..adf68db 100644 --- a/player/player.gd +++ b/player/player.gd @@ -208,7 +208,8 @@ func respawn() -> void: global_position = spawn_position if world != null: world.setup_player(self, true) - global_position = world.find_safe_spawn(spawn_position) + if not world.is_standable_spawn(spawn_position): + global_position = world.find_safe_spawn(spawn_position) world.setup_player(self, true) health = MAX_HEALTH hunger = MAX_HUNGER @@ -342,7 +343,15 @@ func _physics_process(delta: float) -> void: 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) - if not is_on_floor(): + if _on_ladder(): + var climb := Input.get_action_strength("move_forward") - Input.get_action_strength("move_backward") + if Input.is_action_pressed("jump"): + climb = 1.0 + elif crouching: + climb = -1.0 + velocity.y = move_toward(velocity.y, climb * WALK_SPEED, GROUND_ACCEL * delta) + _fall_start = NAN + elif not is_on_floor(): velocity.y -= GRAVITY * delta elif Input.is_action_just_pressed("jump"): velocity.y = JUMP_VELOCITY @@ -484,7 +493,7 @@ func _voxel_raycast(origin: Vector3, direction_value: Vector3, max_distance: flo func _pick_target() -> void: if not dead and has_target and world != null: - block_picked.emit(world.get_block_world(target_block)) + block_picked.emit(BlockRegistry.canonical_id(world.get_block_world(target_block))) func _break_target() -> void: @@ -558,11 +567,42 @@ func _place_target() -> void: var place_position := target_block + target_normal if _player_occupies(place_position): return - if world.place_block(place_position, selected_block): + if BlockRegistry.canonical_id(selected_block) == BlockRegistry.BLOCK_WOOD_DOOR \ + and _player_occupies(place_position + Vector3i.UP): + return + if world.place_block(place_position, selected_block, _placement_facing(), target_normal): block_placed.emit(selected_block) AudioManager.play_block_place(selected_block, Vector3(place_position) + Vector3(0.5, 0.5, 0.5)) +func _placement_facing() -> int: + var forward := -global_transform.basis.z + if absf(forward.x) > absf(forward.z): + return BlockRegistry.FACING_EAST if forward.x > 0.0 else BlockRegistry.FACING_WEST + return BlockRegistry.FACING_SOUTH if forward.z > 0.0 else BlockRegistry.FACING_NORTH + + +func _on_ladder() -> bool: + if world == null: + return false + var feet := world.get_block_world(Vector3i((global_position + Vector3.UP * 0.2).floor())) + var chest := world.get_block_world(Vector3i((global_position + Vector3.UP * 1.1).floor())) + for block_id in [feet, chest]: + if not BlockRegistry.is_ladder(block_id): + continue + var local := Vector2(fposmod(global_position.x, 1.0), fposmod(global_position.z, 1.0)) + match BlockRegistry.facing(block_id): + BlockRegistry.FACING_NORTH: + return local.y < 0.4 + BlockRegistry.FACING_EAST: + return local.x > 0.6 + BlockRegistry.FACING_SOUTH: + return local.y > 0.6 + BlockRegistry.FACING_WEST: + return local.x < 0.4 + return false + + func _handle_double_tap_jump() -> void: if game_mode != GameMode.CREATIVE or dead: return diff --git a/tools/functional_blocks_verify.gd b/tools/functional_blocks_verify.gd new file mode 100644 index 0000000..3bcb0c7 --- /dev/null +++ b/tools/functional_blocks_verify.gd @@ -0,0 +1,223 @@ +extends SceneTree + +var failures := 0 + + +func _init() -> void: + _verify_registry_states() + _verify_shape_meshes() + _verify_recipes_and_drops() + _verify_sleep_clock_and_threat_hook() + _verify_indoor_bed_spawn() + _verify_world_block_lifecycle() + if failures == 0: + print("functional_blocks_verify: PASS") + quit(0) + else: + push_error("functional_blocks_verify: %d failure(s)" % failures) + quit(1) + + +func _verify_registry_states() -> void: + _check(BlockRegistry.BLOCK_DEFS.size() == BlockRegistry.BLOCK_WOOD_BED_LAST + 1, + "state IDs stay contiguous") + for direction in 4: + var stair := BlockRegistry.placement_variant(BlockRegistry.BLOCK_WOOD_STAIRS, direction) + _check(BlockRegistry.facing(stair) == direction, "stairs preserve facing %d" % direction) + _check(BlockRegistry.canonical_id(stair) == BlockRegistry.BLOCK_WOOD_STAIRS, + "stairs map to their inventory ID") + var lower := BlockRegistry.door_state(direction, false, false) + var upper := BlockRegistry.door_with_upper(lower, true) + _check(BlockRegistry.door_upper(upper), "door upper state %d" % direction) + _check(BlockRegistry.door_open(BlockRegistry.door_with_open(lower, true)), + "door open state %d" % direction) + _check(BlockRegistry.facing(upper) == direction, "door halves share facing %d" % direction) + _check(BlockRegistry.placement_variant(BlockRegistry.BLOCK_WOOD_SLAB, 0, Vector3i.DOWN) + == BlockRegistry.BLOCK_WOOD_SLAB_TOP, "underside placement creates a top slab") + _check(BlockRegistry.placement_variant(BlockRegistry.BLOCK_WOOD_LADDER, 0, Vector3i.RIGHT) + == BlockRegistry.BLOCK_WOOD_LADDER + BlockRegistry.FACING_WEST, + "ladder faces away from its east support") + _check(not BlockRegistry.is_inventory_block(BlockRegistry.BLOCK_WOOD_STAIRS_EAST), + "voxel-only states stay out of inventories") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_CRAFTING_TABLE][2] == "crafting_top.png", + "crafting table has a dedicated texture set") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_CHEST][3] == "chest_side.png", + "chest has a dedicated texture set") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_FURNACE][3] == "furnace_side.png", + "furnace has a dedicated texture set") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_WOOD_DOOR][3] == "door_lower.png", + "door lower half has a dedicated texture") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_WOOD_DOOR + 8][3] == "door_upper.png", + "door upper half has a dedicated texture") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_WOOD_LADDER][3] == "ladder.png", + "ladder has a dedicated texture") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_WOOD_SIGN][3] == "sign.png", + "sign has a dedicated texture") + _check(BlockRegistry.BLOCK_DEFS[BlockRegistry.BLOCK_WOOD_BED][3] == "bed_side.png", + "bed has a dedicated texture set") + + +func _verify_shape_meshes() -> void: + var blocks := BlockRegistry.new() + var mesher := ChunkMesher.new(blocks) + for block_id in [BlockRegistry.BLOCK_WOOD_STAIRS, BlockRegistry.BLOCK_WOOD_SLAB, + BlockRegistry.BLOCK_WOOD_DOOR, BlockRegistry.BLOCK_WOOD_SIGN, + BlockRegistry.BLOCK_WOOD_BED]: + var result := _mesh_single(mesher, block_id) + _check(not result.verts.is_empty(), "%s emits geometry" % blocks.get_block_name(block_id)) + _check(not result.collision.is_empty(), "%s emits collision" % blocks.get_block_name(block_id)) + var ladder := _mesh_single(mesher, BlockRegistry.BLOCK_WOOD_LADDER) + _check(not ladder.verts.is_empty(), "ladder emits geometry") + _check(ladder.collision.is_empty(), "ladder remains pass-through for climbing") + var north_stairs := mesher.make_block_mesh(BlockRegistry.BLOCK_WOOD_STAIRS) + var east_stairs := mesher.make_block_mesh(BlockRegistry.BLOCK_WOOD_STAIRS_EAST) + _check(north_stairs != null and east_stairs != null, "rotated preview meshes are available") + _check(north_stairs.get_aabb().size.length_squared() > 0.0, "stair preview has bounds") + + +func _mesh_single(mesher: ChunkMesher, block_id: int) -> ChunkMesher.MeshResult: + var data := PackedByteArray() + data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + data[1 + VoxelDefs.DATA_STRIDE_Z + VoxelDefs.DATA_STRIDE_Y] = block_id + var heights := PackedInt32Array() + heights.resize(VoxelDefs.CHUNK_AREA) + heights.fill(-1) + heights[1 + VoxelDefs.DATA_STRIDE_Z] = 1 + var tints := PackedColorArray() + tints.resize(VoxelDefs.CHUNK_AREA) + tints.fill(Color.WHITE) + return mesher.build(data, 1, heights, tints, tints, ChunkMesher.NeighborSet.new(), true) + + +func _verify_recipes_and_drops() -> void: + for recipe_id in ["wood_stairs", "wood_slabs", "wood_door", "wood_ladders", + "wood_signs", "wood_bed"]: + _check(not CraftingRecipes.get_recipe(recipe_id).is_empty(), "%s recipe exists" % recipe_id) + _check(ItemRegistry.harvest_drop(BlockRegistry.BLOCK_WOOD_STAIRS_EAST).get("id", 0) + == BlockRegistry.BLOCK_WOOD_STAIRS, "rotated states drop their canonical item") + _check(ItemRegistry.is_valid(BlockRegistry.BLOCK_WOOD_BED), "bed is an inventory item") + _check(not ItemRegistry.is_valid(BlockRegistry.BLOCK_WOOD_BED + 1), + "rotated bed state is not an inventory item") + + +func _verify_sleep_clock_and_threat_hook() -> void: + var clock := DayNightCycle.new() + clock.set_time(23.0) + _check(clock.is_night(), "23:00 is sleepable night") + clock.skip_to_morning() + _check(is_equal_approx(clock.time_hours, DayNightCycle.SUNRISE_HOUR), + "sleep skips to sunrise") + _check(not clock.is_night(), "sunrise is daytime") + var world := VoxelWorld.new() + _check(not world.is_threatened(Vector3.ZERO), "no entity system means no default threat") + world.threat_check = func(_position: Vector3, radius: float) -> bool: return radius >= 8.0 + _check(world.is_threatened(Vector3.ZERO), "installed hostile query blocks sleep") + + +func _verify_indoor_bed_spawn() -> void: + var fixture := _world_fixture() + var world: VoxelWorld = fixture.world + var chunk: VoxelWorld.Chunk = fixture.chunk + var bed := Vector3i(8, 5, 8) + # Solid floor, occupied bed cell, and a roof that the general surface-spawn + # search would prefer. The dedicated search must stay beside the bed. + for z in range(5, 12): + for x in range(5, 12): + _set_fixture_block(chunk.data, Vector3i(x, 4, z), BlockRegistry.BLOCK_STONE) + _set_fixture_block(chunk.data, Vector3i(x, 8, z), BlockRegistry.BLOCK_STONE) + _set_fixture_block(chunk.data, bed, BlockRegistry.BLOCK_WOOD_BED) + var result := world.find_bed_spawn(bed) + _check(bool(result.get("found", false)), "indoor bed finds a same-floor respawn") + if bool(result.get("found", false)): + var position: Vector3 = result.position + _check(position.y < 7.0, "indoor bed respawn stays below the roof") + _check(absi(floori(position.x) - bed.x) <= 3 and absi(floori(position.z) - bed.z) <= 3, + "bed respawn stays nearby") + _check(world.is_standable_spawn(position), "stored indoor bed respawn remains directly usable") + + +func _verify_world_block_lifecycle() -> void: + var fixture := _world_fixture() + var world: VoxelWorld = fixture.world + var chunk: VoxelWorld.Chunk = fixture.chunk + var chunk_position := Vector2i.ZERO + var door := Vector3i(4, 5, 4) + _check(world.place_block(door, BlockRegistry.BLOCK_WOOD_DOOR, BlockRegistry.FACING_EAST), + "door placement succeeds in two empty cells") + var lower := world.get_block_world(door) + var upper := world.get_block_world(door + Vector3i.UP) + _check(BlockRegistry.is_door(lower) and not BlockRegistry.door_upper(lower) + and BlockRegistry.facing(lower) == BlockRegistry.FACING_EAST, + "door placement writes the oriented lower half") + _check(BlockRegistry.is_door(upper) and BlockRegistry.door_upper(upper) + and BlockRegistry.facing(upper) == BlockRegistry.FACING_EAST, + "door placement writes the matching upper half") + var edits: Dictionary = world._edits_by_chunk.get(chunk_position, {}) + _check(edits.get(door, -1) == lower and edits.get(door + Vector3i.UP, -1) == upper, + "door placement persists both halves") + _check(world.toggle_door(door + Vector3i.UP), "door toggles from either half") + lower = world.get_block_world(door) + upper = world.get_block_world(door + Vector3i.UP) + _check(BlockRegistry.door_open(lower) and BlockRegistry.door_open(upper), + "door toggle opens both halves") + edits = world._edits_by_chunk.get(chunk_position, {}) + _check(edits.get(door, -1) == lower and edits.get(door + Vector3i.UP, -1) == upper, + "door toggle persists both states") + _check(world.break_block(door + Vector3i.UP) == BlockRegistry.BLOCK_WOOD_DOOR, + "breaking either door half returns the canonical item") + _check(world.get_block_world(door) == BlockRegistry.BLOCK_AIR + and world.get_block_world(door + Vector3i.UP) == BlockRegistry.BLOCK_AIR, + "breaking a door clears both halves") + edits = world._edits_by_chunk.get(chunk_position, {}) + _check(edits.get(door, -1) == BlockRegistry.BLOCK_AIR + and edits.get(door + Vector3i.UP, -1) == BlockRegistry.BLOCK_AIR, + "door removal persists both cleared cells") + + var blocked_door := Vector3i(6, 5, 6) + _set_fixture_block(chunk.data, blocked_door + Vector3i.UP, BlockRegistry.BLOCK_STONE) + _check(not world.place_block(blocked_door, BlockRegistry.BLOCK_WOOD_DOOR), + "door placement refuses an occupied upper cell") + var ladder := Vector3i(8, 5, 8) + _check(not world.place_block(ladder, BlockRegistry.BLOCK_WOOD_LADDER, + BlockRegistry.FACING_NORTH, Vector3i.RIGHT), "ladder placement requires solid support") + _set_fixture_block(chunk.data, ladder - Vector3i.RIGHT, BlockRegistry.BLOCK_STONE) + _check(world.place_block(ladder, BlockRegistry.BLOCK_WOOD_LADDER, + BlockRegistry.FACING_NORTH, Vector3i.RIGHT), "ladder places against solid support") + + var blast_door := Vector3i(10, 5, 10) + _check(world.place_block(blast_door, BlockRegistry.BLOCK_WOOD_DOOR), + "explosion fixture door places") + world.carve_sphere(blast_door, 1) + _check(world.get_block_world(blast_door) == BlockRegistry.BLOCK_AIR + and world.get_block_world(blast_door + Vector3i.UP) == BlockRegistry.BLOCK_AIR, + "explosion removes both door halves") + var burnt_door := Vector3i(12, 5, 12) + _check(world.place_block(burnt_door, BlockRegistry.BLOCK_WOOD_DOOR), + "fire fixture door places") + var changed_chunks := {} + world._destroy_burnt_block(burnt_door + Vector3i.UP, changed_chunks) + _check(world.get_block_world(burnt_door) == BlockRegistry.BLOCK_AIR + and world.get_block_world(burnt_door + Vector3i.UP) == BlockRegistry.BLOCK_AIR, + "burning either half removes the whole door") + _check(changed_chunks.has(chunk_position), "burnt door marks its chunk changed") + + +func _world_fixture() -> Dictionary: + var world := VoxelWorld.new() + world._blocks = BlockRegistry.new() + var chunk := VoxelWorld.Chunk.new() + chunk.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + world._chunks[Vector2i.ZERO] = chunk + return {"world": world, "chunk": chunk} + + +func _set_fixture_block(data: PackedByteArray, position: Vector3i, block_id: int) -> void: + data[position.x + position.z * VoxelDefs.DATA_STRIDE_Z \ + + position.y * VoxelDefs.DATA_STRIDE_Y] = block_id + + +func _check(condition: bool, label: String) -> void: + if condition: + return + failures += 1 + push_error("functional_blocks_verify: " + label) diff --git a/tools/functional_blocks_verify.gd.uid b/tools/functional_blocks_verify.gd.uid new file mode 100644 index 0000000..cf9154a --- /dev/null +++ b/tools/functional_blocks_verify.gd.uid @@ -0,0 +1 @@ +uid://6hv5g4pwp4xk diff --git a/tools/gen_functional_textures.gd b/tools/gen_functional_textures.gd new file mode 100644 index 0000000..e919736 --- /dev/null +++ b/tools/gen_functional_textures.gd @@ -0,0 +1,188 @@ +extends SceneTree + +const OUTPUT := "res://assets/placeholders/functional" +const SIZE := 64 +const WOOD := Color("b98c54") +const WOOD_LIGHT := Color("cca466") +const WOOD_DARK := Color("715033") +const WOOD_DEEP := Color("513722") +const IRON := Color("b8c1c4") +const IRON_DARK := Color("3a4146") +const STONE := Color("777a7c") +const STONE_DARK := Color("373b3d") + + +func _init() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT)) + _write("crafting_top.png", _crafting_top()) + _write("crafting_side.png", _crafting_side()) + _write("crafting_bottom.png", _planks(WOOD.darkened(0.16))) + _write("chest_top.png", _chest_top()) + _write("chest_side.png", _chest_side()) + _write("chest_bottom.png", _planks(Color("8b673c"))) + _write("furnace_top.png", _stone(false)) + _write("furnace_side.png", _furnace_side()) + _write("furnace_bottom.png", _stone(true)) + _write("door_lower.png", _door(false)) + _write("door_upper.png", _door(true)) + _write("ladder.png", _ladder()) + _write("sign.png", _sign()) + _write("bed_top.png", _bed_top()) + _write("bed_side.png", _bed_side()) + _write("bed_bottom.png", _planks(WOOD.darkened(0.22))) + print("Generated functional block textures in %s" % OUTPUT) + quit() + + +func _image(color: Color) -> Image: + var image := Image.create(SIZE, SIZE, false, Image.FORMAT_RGBA8) + image.fill(color) + return image + + +func _planks(base: Color = WOOD) -> Image: + var image := _image(base) + for y in SIZE: + for x in SIZE: + if y % 16 == 0 or (x + int(y / 16) * 23) % 48 == 0: + image.set_pixel(x, y, WOOD_DARK) + elif (x * 13 + y * 7) % 31 == 0: + image.set_pixel(x, y, base.lightened(0.08)) + return image + + +func _crafting_top() -> Image: + var image := _planks() + image.fill_rect(Rect2i(0, 0, 64, 4), WOOD_DEEP) + image.fill_rect(Rect2i(0, 60, 64, 4), WOOD_DEEP) + image.fill_rect(Rect2i(0, 0, 4, 64), WOOD_DEEP) + image.fill_rect(Rect2i(60, 0, 4, 64), WOOD_DEEP) + for line in [22, 42]: + image.fill_rect(Rect2i(line, 5, 3, 54), WOOD_DARK) + image.fill_rect(Rect2i(5, line, 54, 3), WOOD_DARK) + image.fill_rect(Rect2i(9, 9, 9, 9), IRON_DARK) + image.fill_rect(Rect2i(31, 29, 4, 15), WOOD_DEEP) + image.fill_rect(Rect2i(27, 28, 12, 4), IRON) + return image + + +func _crafting_side() -> Image: + var image := _planks() + image.fill_rect(Rect2i(0, 0, 64, 9), WOOD_DARK) + image.fill_rect(Rect2i(8, 17, 6, 36), WOOD_DEEP) + image.fill_rect(Rect2i(45, 17, 7, 36), WOOD_DEEP) + image.fill_rect(Rect2i(13, 21, 34, 5), IRON_DARK) + image.fill_rect(Rect2i(27, 25, 5, 25), IRON) + image.fill_rect(Rect2i(21, 47, 17, 5), IRON_DARK) + return image + + +func _chest_top() -> Image: + var image := _planks(Color("916a3d")) + image.fill_rect(Rect2i(27, 0, 10, 64), IRON_DARK) + image.fill_rect(Rect2i(30, 0, 4, 64), IRON) + for y in [9, 53]: + image.fill_rect(Rect2i(4, y, 56, 3), WOOD_LIGHT) + return image + + +func _chest_side() -> Image: + var image := _planks(Color("916a3d")) + image.fill_rect(Rect2i(0, 27, 64, 4), WOOD_DEEP) + image.fill_rect(Rect2i(28, 0, 8, 64), IRON_DARK) + image.fill_rect(Rect2i(25, 23, 14, 13), IRON) + image.fill_rect(Rect2i(30, 28, 4, 8), IRON_DARK) + image.fill_rect(Rect2i(3, 3, 58, 3), WOOD_LIGHT) + return image + + +func _stone(dark: bool) -> Image: + var base := STONE.darkened(0.14) if dark else STONE + var image := _image(base) + for y in range(0, SIZE, 16): + image.fill_rect(Rect2i(0, y, SIZE, 3), STONE_DARK) + var offset := 16 if int(y / 16) % 2 == 1 else 0 + for x in range(offset, SIZE, 32): + image.fill_rect(Rect2i(x, y, 3, 16), STONE_DARK) + return image + + +func _furnace_side() -> Image: + var image := _stone(false) + image.fill_rect(Rect2i(17, 37, 30, 19), Color("25292b")) + image.fill_rect(Rect2i(20, 41, 24, 12), Color("6f3021")) + image.fill_rect(Rect2i(23, 46, 18, 7), Color("e26e2f")) + image.fill_rect(Rect2i(29, 44, 8, 8), Color("ffc15a")) + for y in [18, 24, 30]: + image.fill_rect(Rect2i(22, y, 20, 3), STONE_DARK) + return image + + +func _door(upper: bool) -> Image: + var image := _image(WOOD) + for x in [0, 21, 42, 61]: + image.fill_rect(Rect2i(x, 0, 3, 64), WOOD_DARK) + image.fill_rect(Rect2i(0, 0, 64, 4), WOOD_DEEP) + image.fill_rect(Rect2i(0, 60, 64, 4), WOOD_DEEP) + if upper: + image.fill_rect(Rect2i(20, 12, 25, 24), WOOD_DEEP) + for y in [15, 27]: + for x in [23, 35]: + image.fill_rect(Rect2i(x, y, 8, 8), Color("354957")) + image.fill_rect(Rect2i(x + 1, y + 1, 3, 2), Color("a8c7d5")) + image.fill_rect(Rect2i(50, 42, 7, 7), IRON) + else: + image.fill_rect(Rect2i(4, 45, 56, 5), WOOD_DEEP) + image.fill_rect(Rect2i(4, 56, 56, 5), IRON_DARK) + return image + + +func _ladder() -> Image: + var image := _image(Color("604729")) + image.fill_rect(Rect2i(6, 0, 8, 64), WOOD_DARK) + image.fill_rect(Rect2i(50, 0, 8, 64), WOOD_DARK) + for y in range(8, 64, 12): + image.fill_rect(Rect2i(10, y, 44, 6), WOOD_LIGHT) + image.fill_rect(Rect2i(10, y + 5, 44, 2), WOOD_DARK) + return image + + +func _sign() -> Image: + var image := _image(Color("c6a06b")) + image.fill_rect(Rect2i(0, 0, 64, 5), WOOD_DARK) + image.fill_rect(Rect2i(0, 59, 64, 5), WOOD_DARK) + image.fill_rect(Rect2i(0, 0, 5, 64), WOOD_DARK) + image.fill_rect(Rect2i(59, 0, 5, 64), WOOD_DARK) + image.fill_rect(Rect2i(29, 8, 6, 6), IRON_DARK) + for row in 3: + var width := 39 - row * 5 + image.fill_rect(Rect2i(12 + row * 3, 23 + row * 10, width, 3), Color("67472c")) + return image + + +func _bed_top() -> Image: + var image := _image(Color("a9413d")) + image.fill_rect(Rect2i(0, 0, 64, 19), Color("e8dfcf")) + image.fill_rect(Rect2i(5, 3, 54, 12), Color("f6f0e5")) + image.fill_rect(Rect2i(0, 18, 64, 3), Color("7d302e")) + for diagonal in range(-64, 64, 12): + for y in range(22, 64): + var x := diagonal + y + if x >= 0 and x < 64: + image.set_pixel(x, y, Color("c25954")) + return image + + +func _bed_side() -> Image: + var image := _image(Color("a9413d")) + image.fill_rect(Rect2i(0, 0, 64, 4), Color("d06b65")) + image.fill_rect(Rect2i(0, 39, 64, 16), WOOD_DARK) + image.fill_rect(Rect2i(4, 52, 9, 12), WOOD_DEEP) + image.fill_rect(Rect2i(51, 52, 9, 12), WOOD_DEEP) + return image + + +func _write(filename: String, image: Image) -> void: + var error := image.save_png("%s/%s" % [OUTPUT, filename]) + if error != OK: + push_error("Failed to save %s: %s" % [filename, error_string(error)]) diff --git a/tools/gen_functional_textures.gd.uid b/tools/gen_functional_textures.gd.uid new file mode 100644 index 0000000..d923cd0 --- /dev/null +++ b/tools/gen_functional_textures.gd.uid @@ -0,0 +1 @@ +uid://cl8dntxcngdwa diff --git a/ui/inventory_overlay.gd b/ui/inventory_overlay.gd index 1c89640..854f2f4 100644 --- a/ui/inventory_overlay.gd +++ b/ui/inventory_overlay.gd @@ -214,6 +214,8 @@ func _build_catalog() -> void: var ids: Array[int] = [] for definition in BlockRegistry.BLOCK_DEFS: var id := int(definition[0]) + if not BlockRegistry.is_inventory_block(id): + continue 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: diff --git a/world/block_registry.gd b/world/block_registry.gd index 0dffb4e..ce82cc4 100644 --- a/world/block_registry.gd +++ b/world/block_registry.gd @@ -77,6 +77,37 @@ const BLOCK_CHEST := 72 const BLOCK_FURNACE := 73 const BLOCK_PLANKS := 74 +## State-backed wooden building blocks. IDs 0..74 are part of the persistent +## world format and must never move. The first ID in each group is the item and +## canonical block ID; the remaining IDs are voxel-only placement states. +const BLOCK_WOOD_STAIRS := 75 +const BLOCK_WOOD_STAIRS_EAST := 76 +const BLOCK_WOOD_STAIRS_SOUTH := 77 +const BLOCK_WOOD_STAIRS_WEST := 78 +const BLOCK_WOOD_SLAB := 79 +const BLOCK_WOOD_SLAB_TOP := 80 +const BLOCK_WOOD_DOOR := 81 +const BLOCK_WOOD_DOOR_LAST := 96 +const BLOCK_WOOD_LADDER := 97 +const BLOCK_WOOD_LADDER_LAST := 100 +const BLOCK_WOOD_SIGN := 101 +const BLOCK_WOOD_SIGN_LAST := 104 +const BLOCK_WOOD_BED := 105 +const BLOCK_WOOD_BED_LAST := 108 + +const FACING_NORTH := 0 +const FACING_EAST := 1 +const FACING_SOUTH := 2 +const FACING_WEST := 3 + +const SHAPE_CUBE := 0 +const SHAPE_STAIRS := 1 +const SHAPE_SLAB := 2 +const SHAPE_DOOR := 3 +const SHAPE_LADDER := 4 +const SHAPE_SIGN := 5 +const SHAPE_BED := 6 + const FLAG_OPAQUE := 1 const FLAG_CUTOUT := 2 const FLAG_UNBREAKABLE := 4 @@ -162,10 +193,44 @@ const BLOCK_DEFS := [ [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], + [71, "CRAFTING TABLE", "crafting_top.png", "crafting_side.png", "crafting_bottom.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.5, "axe", 0], + [72, "CHEST", "chest_top.png", "chest_side.png", "chest_bottom.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.5, "axe", 0], + [73, "FURNACE", "furnace_top.png", "furnace_side.png", "furnace_bottom.png", FLAG_OPAQUE, 3.5, "pickaxe", 1], [74, "PLANKS", "planks.png", "planks.png", "planks.png", FLAG_OPAQUE | FLAG_FLAMMABLE, 2.0, "axe", 0], + [75, "WOOD STAIRS", "planks.png", "planks.png", "planks.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [76, "WOOD STAIRS EAST", "planks.png", "planks.png", "planks.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [77, "WOOD STAIRS SOUTH", "planks.png", "planks.png", "planks.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [78, "WOOD STAIRS WEST", "planks.png", "planks.png", "planks.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [79, "WOOD SLAB", "planks.png", "planks.png", "planks.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [80, "WOOD SLAB TOP", "planks.png", "planks.png", "planks.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [81, "WOOD DOOR", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [82, "WOOD DOOR EAST", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [83, "WOOD DOOR SOUTH", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [84, "WOOD DOOR WEST", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [85, "WOOD DOOR OPEN", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [86, "WOOD DOOR EAST OPEN", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [87, "WOOD DOOR SOUTH OPEN", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [88, "WOOD DOOR WEST OPEN", "door_lower.png", "door_lower.png", "door_lower.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [89, "WOOD DOOR UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [90, "WOOD DOOR EAST UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [91, "WOOD DOOR SOUTH UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [92, "WOOD DOOR WEST UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [93, "WOOD DOOR OPEN UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [94, "WOOD DOOR EAST OPEN UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [95, "WOOD DOOR SOUTH OPEN UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [96, "WOOD DOOR WEST OPEN UPPER", "door_upper.png", "door_upper.png", "door_upper.png", FLAG_FLAMMABLE, 2.0, "axe", 0], + [97, "WOOD LADDER", "ladder.png", "ladder.png", "ladder.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [98, "WOOD LADDER EAST", "ladder.png", "ladder.png", "ladder.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [99, "WOOD LADDER SOUTH", "ladder.png", "ladder.png", "ladder.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [100, "WOOD LADDER WEST", "ladder.png", "ladder.png", "ladder.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [101, "WOOD SIGN", "sign.png", "sign.png", "sign.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [102, "WOOD SIGN EAST", "sign.png", "sign.png", "sign.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [103, "WOOD SIGN SOUTH", "sign.png", "sign.png", "sign.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [104, "WOOD SIGN WEST", "sign.png", "sign.png", "sign.png", FLAG_FLAMMABLE, 1.0, "axe", 0], + [105, "WOOD BED", "bed_top.png", "bed_side.png", "bed_bottom.png", FLAG_FLAMMABLE, 0.2, "axe", 0], + [106, "WOOD BED EAST", "bed_top.png", "bed_side.png", "bed_bottom.png", FLAG_FLAMMABLE, 0.2, "axe", 0], + [107, "WOOD BED SOUTH", "bed_top.png", "bed_side.png", "bed_bottom.png", FLAG_FLAMMABLE, 0.2, "axe", 0], + [108, "WOOD BED WEST", "bed_top.png", "bed_side.png", "bed_bottom.png", FLAG_FLAMMABLE, 0.2, "axe", 0], ] const TEXTURE_ROOT := "res://assets/placeholders/zigcraft/default/" @@ -175,7 +240,24 @@ const UNDERWATER_TEXTURE_ROOT := "res://assets/placeholders/underwater/" const EXPLOSIVE_TEXTURE_ROOT := "res://assets/placeholders/explosives/" const CAVE_TEXTURE_ROOT := "res://assets/placeholders/caves/" const FIRE_TEXTURE_ROOT := "res://assets/placeholders/fire/" +const FUNCTIONAL_TEXTURE_ROOT := "res://assets/placeholders/functional/" const EXTRA_TEXTURE_PATHS := { + "crafting_top.png": FUNCTIONAL_TEXTURE_ROOT + "crafting_top.png", + "crafting_side.png": FUNCTIONAL_TEXTURE_ROOT + "crafting_side.png", + "crafting_bottom.png": FUNCTIONAL_TEXTURE_ROOT + "crafting_bottom.png", + "chest_top.png": FUNCTIONAL_TEXTURE_ROOT + "chest_top.png", + "chest_side.png": FUNCTIONAL_TEXTURE_ROOT + "chest_side.png", + "chest_bottom.png": FUNCTIONAL_TEXTURE_ROOT + "chest_bottom.png", + "furnace_top.png": FUNCTIONAL_TEXTURE_ROOT + "furnace_top.png", + "furnace_side.png": FUNCTIONAL_TEXTURE_ROOT + "furnace_side.png", + "furnace_bottom.png": FUNCTIONAL_TEXTURE_ROOT + "furnace_bottom.png", + "door_lower.png": FUNCTIONAL_TEXTURE_ROOT + "door_lower.png", + "door_upper.png": FUNCTIONAL_TEXTURE_ROOT + "door_upper.png", + "ladder.png": FUNCTIONAL_TEXTURE_ROOT + "ladder.png", + "sign.png": FUNCTIONAL_TEXTURE_ROOT + "sign.png", + "bed_top.png": FUNCTIONAL_TEXTURE_ROOT + "bed_top.png", + "bed_side.png": FUNCTIONAL_TEXTURE_ROOT + "bed_side.png", + "bed_bottom.png": FUNCTIONAL_TEXTURE_ROOT + "bed_bottom.png", "coral_substrate.png": UNDERWATER_TEXTURE_ROOT + "coral_substrate.png", "seagrass.png": UNDERWATER_TEXTURE_ROOT + "seagrass.png", "kelp.png": UNDERWATER_TEXTURE_ROOT + "kelp.png", @@ -260,6 +342,127 @@ func _init() -> void: _load_water_material() +static func shape_type(block_id: int) -> int: + if block_id >= BLOCK_WOOD_STAIRS and block_id <= BLOCK_WOOD_STAIRS_WEST: + return SHAPE_STAIRS + if block_id >= BLOCK_WOOD_SLAB and block_id <= BLOCK_WOOD_SLAB_TOP: + return SHAPE_SLAB + if is_door(block_id): + return SHAPE_DOOR + if is_ladder(block_id): + return SHAPE_LADDER + if is_sign(block_id): + return SHAPE_SIGN + if is_bed(block_id): + return SHAPE_BED + return SHAPE_CUBE + + +## Horizontal state direction. Non-directional blocks deliberately resolve to +## north so callers can use one helper for every placeable block. +static func facing(block_id: int) -> int: + if block_id >= BLOCK_WOOD_STAIRS and block_id <= BLOCK_WOOD_STAIRS_WEST: + return block_id - BLOCK_WOOD_STAIRS + if is_door(block_id): + return (block_id - BLOCK_WOOD_DOOR) % 4 + if is_ladder(block_id): + return block_id - BLOCK_WOOD_LADDER + if is_sign(block_id): + return block_id - BLOCK_WOOD_SIGN + if is_bed(block_id): + return block_id - BLOCK_WOOD_BED + return FACING_NORTH + + +## Maps every persisted state to its inventory/crafting ID. +static func canonical_id(block_id: int) -> int: + match shape_type(block_id): + SHAPE_STAIRS: + return BLOCK_WOOD_STAIRS + SHAPE_SLAB: + return BLOCK_WOOD_SLAB + SHAPE_DOOR: + return BLOCK_WOOD_DOOR + SHAPE_LADDER: + return BLOCK_WOOD_LADDER + SHAPE_SIGN: + return BLOCK_WOOD_SIGN + SHAPE_BED: + return BLOCK_WOOD_BED + return block_id + + +## State IDs remain valid world blocks but must never appear as inventory items. +static func is_inventory_block(block_id: int) -> bool: + return block_id > BLOCK_AIR and block_id < BLOCK_DEFS.size() and canonical_id(block_id) == block_id + + +## Selects the state written to voxel bytes for a placement. `normal` is the +## face normal that was clicked; only slabs distinguish upper/lower placement. +static func placement_variant(canonical: int, direction: int, normal: Vector3i = Vector3i.UP) -> int: + var state_facing := posmod(direction, 4) + match canonical_id(canonical): + BLOCK_WOOD_STAIRS: + return BLOCK_WOOD_STAIRS + state_facing + BLOCK_WOOD_SLAB: + return BLOCK_WOOD_SLAB_TOP if normal.y < 0 else BLOCK_WOOD_SLAB + BLOCK_WOOD_DOOR: + return door_state(state_facing, false, false) + BLOCK_WOOD_LADDER: + if normal.x > 0: + state_facing = FACING_WEST + elif normal.x < 0: + state_facing = FACING_EAST + elif normal.z > 0: + state_facing = FACING_NORTH + elif normal.z < 0: + state_facing = FACING_SOUTH + return BLOCK_WOOD_LADDER + state_facing + BLOCK_WOOD_SIGN: + return BLOCK_WOOD_SIGN + state_facing + BLOCK_WOOD_BED: + return BLOCK_WOOD_BED + state_facing + return canonical + + +static func is_door(block_id: int) -> bool: + return block_id >= BLOCK_WOOD_DOOR and block_id <= BLOCK_WOOD_DOOR_LAST + + +static func door_upper(block_id: int) -> bool: + return is_door(block_id) and block_id - BLOCK_WOOD_DOOR >= 8 + + +static func door_open(block_id: int) -> bool: + return is_door(block_id) and (int((block_id - BLOCK_WOOD_DOOR) / 4) % 2) == 1 + + +## Converts door state fields to the compact voxel ID. The order is facing, +## open, upper: 4 facings x closed/open x lower/upper. +static func door_state(direction: int, opened: bool = false, upper: bool = false) -> int: + return BLOCK_WOOD_DOOR + posmod(direction, 4) + (4 if opened else 0) + (8 if upper else 0) + + +static func door_with_upper(block_id: int, upper: bool) -> int: + return door_state(facing(block_id), door_open(block_id), upper) + + +static func door_with_open(block_id: int, opened: bool) -> int: + return door_state(facing(block_id), opened, door_upper(block_id)) + + +static func is_ladder(block_id: int) -> bool: + return block_id >= BLOCK_WOOD_LADDER and block_id <= BLOCK_WOOD_LADDER_LAST + + +static func is_sign(block_id: int) -> bool: + return block_id >= BLOCK_WOOD_SIGN and block_id <= BLOCK_WOOD_SIGN_LAST + + +static func is_bed(block_id: int) -> bool: + return block_id >= BLOCK_WOOD_BED and block_id <= BLOCK_WOOD_BED_LAST + + ## 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(): @@ -353,6 +556,8 @@ func is_emissive(block_id: int) -> bool: func layer_for(block_id: int, face: int) -> int: + if block_id < 0 or block_id >= _layer_top.size(): + return 0 if face == 0: return _layer_top[block_id] if face == 1: diff --git a/world/chunk_mesher.gd b/world/chunk_mesher.gd index 1d943fc..7373fea 100644 --- a/world/chunk_mesher.gd +++ b/world/chunk_mesher.gd @@ -27,6 +27,9 @@ var _emissive: PackedByteArray = PackedByteArray() var _emission_r: PackedByteArray = PackedByteArray() var _emission_g: PackedByteArray = PackedByteArray() var _emission_b: PackedByteArray = PackedByteArray() +## Immutable state-shape lookup. It is populated once in the constructor, then +## only read by concurrent worker jobs. +var _shape: PackedByteArray = PackedByteArray() class MeshTimings: @@ -249,6 +252,9 @@ func build(data: PackedByteArray, data_max_y: int, heights: PackedInt32Array, fo float(volume.block_b[light_index]) / float(MAX_LEVEL)) _append_cross_block(Vector3(local_x, y, local_z), id, block_light, sky_light, _tint_for(id, column, foliage_tints), result) continue + if _shape[id] != BlockRegistry.SHAPE_CUBE: + _append_custom_block(Vector3(local_x, y, local_z), id, _tint_for(id, column, foliage_tints), result) + continue var is_opaque := _opacity[id] >= MAX_LEVEL var tint := _tint_for(id, column, foliage_tints) for face in 6: @@ -488,6 +494,10 @@ func make_block_mesh(block_id: int) -> ArrayMesh: var cross := MeshResult.new() _append_cross_block(Vector3(-0.5, -0.5, -0.5), block_id, Color.BLACK, 1.0, Color.WHITE, cross) return arrays_to_mesh(cross.verts, cross.normals, cross.uvs, cross.colors, cross.indices, _blocks.material, cross.light, cross.layers) + if _shape[block_id] != BlockRegistry.SHAPE_CUBE: + var custom := MeshResult.new() + _append_custom_block(Vector3(-0.5, -0.5, -0.5), block_id, Color.WHITE, custom) + return arrays_to_mesh(custom.verts, custom.normals, custom.uvs, custom.colors, custom.indices, _blocks.material, custom.light, custom.layers) var verts := PackedVector3Array() var normals := PackedVector3Array() var uvs := PackedVector2Array() @@ -551,6 +561,7 @@ func _build_light_tables() -> void: _emission_r.resize(256) _emission_g.resize(256) _emission_b.resize(256) + _shape.resize(256) for id in 256: _opacity[id] = _blocks.light_attenuation(id) _cross[id] = 1 if _blocks.has_flag(id, BlockRegistry.FLAG_CROSS) else 0 @@ -563,6 +574,7 @@ func _build_light_tables() -> void: else: _wind[id] = 0.0 _water_level[id] = _blocks.water_level(id) + _shape[id] = BlockRegistry.shape_type(id) if id == BlockRegistry.BLOCK_AIR or _blocks.is_valid_id(id): _layer_top[id] = _blocks.layer_for(id, 0) _layer_bottom[id] = _blocks.layer_for(id, 1) @@ -1046,6 +1058,144 @@ func _append_face(face: int, pad_index: int, local_x: int, y: int, local_z: int, result.collision.append_array(PackedVector3Array([p0, p2, p1, p0, p3, p2])) +## Stateful blocks deliberately do not participate in cube face culling: their +## partial volume cannot occlude a neighboring full cube, and emitting every +## box face keeps the simple shapes correct at chunk boundaries. +func _append_custom_block(origin: Vector3, block_id: int, tint: Color, result: MeshResult) -> void: + var facing := BlockRegistry.facing(block_id) + match _shape[block_id]: + BlockRegistry.SHAPE_STAIRS: + _append_custom_box(origin, Vector3.ZERO, Vector3(1.0, 0.5, 1.0), block_id, tint, result, true) + match facing: + BlockRegistry.FACING_NORTH: + _append_custom_box(origin, Vector3(0.0, 0.5, 0.5), Vector3(1.0, 1.0, 1.0), block_id, tint, result, true) + BlockRegistry.FACING_EAST: + _append_custom_box(origin, Vector3(0.0, 0.5, 0.0), Vector3(0.5, 1.0, 1.0), block_id, tint, result, true) + BlockRegistry.FACING_SOUTH: + _append_custom_box(origin, Vector3(0.0, 0.5, 0.0), Vector3(1.0, 1.0, 0.5), block_id, tint, result, true) + _: + _append_custom_box(origin, Vector3(0.5, 0.5, 0.0), Vector3(1.0, 1.0, 1.0), block_id, tint, result, true) + BlockRegistry.SHAPE_SLAB: + var min_y := 0.5 if block_id == BlockRegistry.BLOCK_WOOD_SLAB_TOP else 0.0 + _append_custom_box(origin, Vector3(0.0, min_y, 0.0), Vector3(1.0, min_y + 0.5, 1.0), block_id, tint, result, true) + BlockRegistry.SHAPE_DOOR: + _append_door(origin, block_id, facing, tint, result) + BlockRegistry.SHAPE_LADDER: + _append_wall_panel(origin, facing, 0.0625, 0.0, 1.0, block_id, tint, result, false) + BlockRegistry.SHAPE_SIGN: + _append_sign(origin, block_id, facing, tint, result) + BlockRegistry.SHAPE_BED: + _append_bed(origin, block_id, facing, tint, result) + + +func _append_door(origin: Vector3, block_id: int, facing: int, tint: Color, result: MeshResult) -> void: + const THICKNESS := 0.125 + if not BlockRegistry.door_open(block_id): + if facing == BlockRegistry.FACING_NORTH or facing == BlockRegistry.FACING_SOUTH: + _append_custom_box(origin, Vector3(0.0, 0.0, 0.5 - THICKNESS * 0.5), Vector3(1.0, 1.0, 0.5 + THICKNESS * 0.5), block_id, tint, result, true) + else: + _append_custom_box(origin, Vector3(0.5 - THICKNESS * 0.5, 0.0, 0.0), Vector3(0.5 + THICKNESS * 0.5, 1.0, 1.0), block_id, tint, result, true) + return + # Each facing has one deterministic hinge side. Door state stores only facing, + # open, and upper, so this keeps lower/upper halves geometrically identical. + match facing: + BlockRegistry.FACING_NORTH: + _append_custom_box(origin, Vector3(0.0, 0.0, 0.0), Vector3(THICKNESS, 1.0, 1.0), block_id, tint, result, true) + BlockRegistry.FACING_EAST: + _append_custom_box(origin, Vector3(0.0, 0.0, 0.0), Vector3(1.0, 1.0, THICKNESS), block_id, tint, result, true) + BlockRegistry.FACING_SOUTH: + _append_custom_box(origin, Vector3(1.0 - THICKNESS, 0.0, 0.0), Vector3(1.0, 1.0, 1.0), block_id, tint, result, true) + _: + _append_custom_box(origin, Vector3(0.0, 0.0, 1.0 - THICKNESS), Vector3(1.0, 1.0, 1.0), block_id, tint, result, true) + + +func _append_wall_panel(origin: Vector3, facing: int, thickness: float, min_y: float, max_y: float, block_id: int, tint: Color, result: MeshResult, collision: bool) -> void: + match facing: + BlockRegistry.FACING_NORTH: + _append_custom_box(origin, Vector3(0.0, min_y, 0.0), Vector3(1.0, max_y, thickness), block_id, tint, result, collision) + BlockRegistry.FACING_EAST: + _append_custom_box(origin, Vector3(1.0 - thickness, min_y, 0.0), Vector3(1.0, max_y, 1.0), block_id, tint, result, collision) + BlockRegistry.FACING_SOUTH: + _append_custom_box(origin, Vector3(0.0, min_y, 1.0 - thickness), Vector3(1.0, max_y, 1.0), block_id, tint, result, collision) + _: + _append_custom_box(origin, Vector3(0.0, min_y, 0.0), Vector3(thickness, max_y, 1.0), block_id, tint, result, collision) + + +func _append_sign(origin: Vector3, block_id: int, facing: int, tint: Color, result: MeshResult) -> void: + const THICKNESS := 0.0625 + # The board is freestanding on a short central post; facing chooses its broad + # side, rather than making all four state IDs visually identical. + if facing == BlockRegistry.FACING_NORTH or facing == BlockRegistry.FACING_SOUTH: + _append_custom_box(origin, Vector3(0.0, 0.35, 0.5 - THICKNESS * 0.5), Vector3(1.0, 0.95, 0.5 + THICKNESS * 0.5), block_id, tint, result, true) + else: + _append_custom_box(origin, Vector3(0.5 - THICKNESS * 0.5, 0.35, 0.0), Vector3(0.5 + THICKNESS * 0.5, 0.95, 1.0), block_id, tint, result, true) + _append_custom_box(origin, Vector3(0.4375, 0.0, 0.4375), Vector3(0.5625, 0.4, 0.5625), block_id, tint, result, true) + + +func _append_bed(origin: Vector3, block_id: int, facing: int, tint: Color, result: MeshResult) -> void: + const HEADBOARD := 0.125 + _append_custom_box(origin, Vector3(0.0, 0.0, 0.0), Vector3(1.0, 0.5625, 1.0), block_id, tint, result, true) + match facing: + BlockRegistry.FACING_NORTH: + _append_custom_box(origin, Vector3(0.0, 0.5625, 0.0), Vector3(1.0, 0.8125, HEADBOARD), block_id, tint, result, true) + BlockRegistry.FACING_EAST: + _append_custom_box(origin, Vector3(1.0 - HEADBOARD, 0.5625, 0.0), Vector3(1.0, 0.8125, 1.0), block_id, tint, result, true) + BlockRegistry.FACING_SOUTH: + _append_custom_box(origin, Vector3(0.0, 0.5625, 1.0 - HEADBOARD), Vector3(1.0, 0.8125, 1.0), block_id, tint, result, true) + _: + _append_custom_box(origin, Vector3(0.0, 0.5625, 0.0), Vector3(HEADBOARD, 0.8125, 1.0), block_id, tint, result, true) + + +## Box emission is intentionally call-local. Partial shapes are not suitable +## for the padded cube AO lookup, so they use their cell's baked light while +## preserving the shader's custom light/layer attribute layout. +func _append_custom_box(origin: Vector3, minimum: Vector3, maximum: Vector3, block_id: int, tint: Color, result: MeshResult, collision: bool) -> void: + for face in 6: + var normal: Vector3i = VoxelDefs.FACE_NORMALS[face] + var base := result.verts.size() + var layer := _layer_for(block_id, face) + var sampled_light := _custom_box_light(result.light_volume, origin, normal) + var shade: float = VoxelDefs.FACE_SHADE[face] + for corner in 4: + var corner_offset: Vector3i = VoxelDefs.FACE_VERTS[face][corner] + var position := origin + Vector3( + minimum.x if corner_offset.x == 0 else maximum.x, + minimum.y if corner_offset.y == 0 else maximum.y, + minimum.z if corner_offset.z == 0 else maximum.z) + result.verts.append(position) + result.normals.append(Vector3(normal)) + result.uvs.append(VoxelDefs.FACE_UVS[face][corner]) + result.colors.append(Color(shade * tint.r, shade * tint.g, shade * tint.b, _wind[block_id])) + result.layers.push_back(float(layer)) + result.light.push_back(sampled_light.r) + result.light.push_back(sampled_light.g) + result.light.push_back(sampled_light.b) + result.light.push_back(sampled_light.a) + result.indices.append_array(PackedInt32Array([base, base + 2, base + 1, base, base + 3, base + 2])) + if collision and result.build_collision: + result.collision.append_array(PackedVector3Array([ + result.verts[base], result.verts[base + 2], result.verts[base + 1], + result.verts[base], result.verts[base + 3], result.verts[base + 2], + ])) + + +func _custom_box_light(volume: LightVolume, origin: Vector3, normal: Vector3i) -> Color: + if volume == null: + return Color(0.0, 0.0, 0.0, 1.0) + var local_x := clampi(floori(origin.x) + LIGHT_PAD + normal.x, 0, volume.w - 1) + var local_y := clampi(floori(origin.y) + normal.y, 0, volume.h - 1) + var local_z := clampi(floori(origin.z) + LIGHT_PAD + normal.z, 0, volume.d - 1) + var index := (local_y * volume.d + local_z) * volume.w + local_x + var sky := float(volume.sky[index]) / float(MAX_LEVEL) + if volume.block_r.is_empty(): + return Color(0.0, 0.0, 0.0, sky) + return Color( + float(volume.block_r[index]) / float(MAX_LEVEL), + float(volume.block_g[index]) / float(MAX_LEVEL), + float(volume.block_b[index]) / float(MAX_LEVEL), + sky) + + func _append_cross_block(origin: Vector3, block_id: int, block_light: Color, sky_light: float, tint: Color, result: MeshResult) -> void: var layer := _layer_side[block_id] var shade := 0.96 diff --git a/world/day_night_cycle.gd b/world/day_night_cycle.gd index 4354eff..2fa71f7 100644 --- a/world/day_night_cycle.gd +++ b/world/day_night_cycle.gd @@ -152,6 +152,16 @@ func set_time(hours: float) -> void: _apply() +func is_night() -> bool: + return time_hours >= 18.0 or time_hours < SUNRISE_HOUR + + +func skip_to_morning() -> void: + var skipped_hours := fposmod(SUNRISE_HOUR - time_hours, 24.0) + _moon_phase = fposmod(_moon_phase + skipped_hours / 24.0 / LUNAR_CYCLE_DAYS, 1.0) + set_time(SUNRISE_HOUR) + + func persistent_state() -> Dictionary: return {"hours": time_hours, "moon_phase": _moon_phase} diff --git a/world/voxel_world.gd b/world/voxel_world.gd index a0c1c4e..468eb06 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -124,6 +124,10 @@ var _debug_task := -1 var _debug_slot: Dictionary = {} var _debug_key := "" var _debug_cache: Dictionary = {} +## Future hostile-entity systems can install this query without coupling block +## interactions to an entity implementation. Until mobs land there are no +## threats, but sleeping already honors the contract when a query is present. +var threat_check: Callable = Callable() class Chunk: @@ -1225,6 +1229,10 @@ func is_burning_at(cell: Vector3i) -> bool: return _burning.has(cell) +func is_threatened(world_position: Vector3, radius: float = 8.0) -> bool: + return threat_check.is_valid() and bool(threat_check.call(world_position, radius)) + + func get_block_world(block_position: Vector3i) -> int: var chunk := _loaded_chunk_for(block_position) if chunk == null or chunk.lod: @@ -1319,16 +1327,33 @@ func break_block(block_position: Vector3i) -> int: var block_id: int = chunk.data[index] if not _blocks.is_breakable(block_id): return BlockRegistry.BLOCK_AIR + if BlockRegistry.is_door(block_id): + var lower_position := block_position - Vector3i.UP if BlockRegistry.door_upper(block_id) else block_position + _remove_door_part(lower_position) + _remove_door_part(lower_position + Vector3i.UP) + return BlockRegistry.BLOCK_WOOD_DOOR chunk.data[index] = BlockRegistry.BLOCK_AIR _record_edit(block_position, BlockRegistry.BLOCK_AIR) _touch_chunk(chunk_position, block_position, block_id, BlockRegistry.BLOCK_AIR) _seed_water(block_position) - return block_id + return BlockRegistry.canonical_id(block_id) -func place_block(block_position: Vector3i, block_id: int) -> bool: +func place_block(block_position: Vector3i, block_id: int, facing: int = BlockRegistry.FACING_NORTH, + placement_normal: Vector3i = Vector3i.UP) -> bool: if not _blocks.is_valid_id(block_id): return false + var canonical := BlockRegistry.canonical_id(block_id) + var placed_id := BlockRegistry.placement_variant(canonical, facing, placement_normal) + if canonical == BlockRegistry.BLOCK_WOOD_DOOR: + return _place_door(block_position, placed_id) + if canonical == BlockRegistry.BLOCK_WOOD_LADDER: + if placement_normal.y != 0 or placement_normal == Vector3i.ZERO: + return false + var support := get_block_world(block_position - placement_normal) + if support == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(support) \ + or _blocks.has_flag(support, BlockRegistry.FLAG_CROSS): + return false if block_position.y < 0 or block_position.y >= VoxelDefs.WORLD_HEIGHT: return false var chunk_position := _chunk_for_block(block_position) @@ -1340,13 +1365,86 @@ func place_block(block_position: Vector3i, block_id: int) -> bool: var existing: int = chunk.data[index] if existing != BlockRegistry.BLOCK_AIR and not _blocks.is_water_id(existing): return false - chunk.data[index] = block_id - _record_edit(block_position, block_id) - _touch_chunk(chunk_position, block_position, existing, block_id) + chunk.data[index] = placed_id + _record_edit(block_position, placed_id) + _touch_chunk(chunk_position, block_position, existing, placed_id) _seed_water(block_position) return true +func _place_door(lower_position: Vector3i, lower_id: int) -> bool: + var upper_position := lower_position + Vector3i.UP + if lower_position.y < 0 or upper_position.y >= VoxelDefs.WORLD_HEIGHT: + return false + for position in [lower_position, upper_position]: + var loaded := _loaded_chunk_for(position) + if loaded == null or loaded.lod: + return false + var existing := get_block_world(position) + if existing != BlockRegistry.BLOCK_AIR and not _blocks.is_water_id(existing): + return false + _set_placed_block(lower_position, lower_id) + _set_placed_block(upper_position, BlockRegistry.door_with_upper(lower_id, true)) + return true + + +func _set_placed_block(position: Vector3i, block_id: int) -> void: + var chunk_position := _chunk_for_block(position) + var chunk: Chunk = _chunks[chunk_position] + _restore_chunk_data(chunk) + var index := _data_index(position) + var existing: int = chunk.data[index] + chunk.data[index] = block_id + _record_edit(position, block_id) + _touch_chunk(chunk_position, position, existing, block_id) + _seed_water(position) + + +func _remove_door_part(position: Vector3i) -> void: + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod: + return + _restore_chunk_data(chunk) + var index := _data_index(position) + var existing: int = chunk.data[index] + if not BlockRegistry.is_door(existing): + return + chunk.data[index] = BlockRegistry.BLOCK_AIR + _record_edit(position, BlockRegistry.BLOCK_AIR) + _touch_chunk(_chunk_for_block(position), position, existing, BlockRegistry.BLOCK_AIR) + _seed_water(position) + + +## Opens or closes both persisted halves together. Interaction is valid from +## either half and survives chunk boundaries because each voxel is an edit. +func toggle_door(block_position: Vector3i) -> bool: + var clicked := get_block_world(block_position) + if not BlockRegistry.is_door(clicked): + return false + var lower_position := block_position - Vector3i.UP if BlockRegistry.door_upper(clicked) else block_position + var upper_position := lower_position + Vector3i.UP + var lower := get_block_world(lower_position) + var upper := get_block_world(upper_position) + if not BlockRegistry.is_door(lower) or not BlockRegistry.is_door(upper): + return false + var opened := not BlockRegistry.door_open(lower) + _set_existing_block(lower_position, BlockRegistry.door_with_open(lower, opened)) + _set_existing_block(upper_position, BlockRegistry.door_with_open(upper, opened)) + return true + + +func _set_existing_block(position: Vector3i, block_id: int) -> void: + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod: + return + _restore_chunk_data(chunk) + var index := _data_index(position) + var previous: int = chunk.data[index] + chunk.data[index] = block_id + _record_edit(position, block_id) + _touch_chunk(_chunk_for_block(position), position, previous, block_id) + + ## Activates an explosive block and returns a small result payload for HUD ## feedback. There is deliberately no fuse or animation: these blocks are ## inspection tools for opening terrain and exposing the generation layers. @@ -1378,6 +1476,7 @@ func carve_sphere(center: Vector3i, radius: int) -> int: var rebuild_chunks: Dictionary = {} var water_shell: Array[Vector3i] = [] var interior_water: Dictionary = {} + var door_counterparts: Dictionary = {} var removed := 0 var shell_inner_squared := (safe_radius - 1) * (safe_radius - 1) # Walk vertical runs so the horizontal chunk lookup and local data offset are @@ -1411,6 +1510,9 @@ func carve_sphere(center: Vector3i, radius: int) -> int: continue if not _blocks.is_breakable(block_id): continue + if BlockRegistry.is_door(block_id): + var counterpart := position + (Vector3i.DOWN if BlockRegistry.door_upper(block_id) else Vector3i.UP) + door_counterparts[counterpart] = true chunk.data[index] = BlockRegistry.BLOCK_AIR _record_edit_in_chunk(position, BlockRegistry.BLOCK_AIR, chunk_position) removed += 1 @@ -1420,6 +1522,8 @@ func carve_sphere(center: Vector3i, radius: int) -> int: water_shell.append(position) if column_changed: changed_chunks[chunk_position] = true + for position: Vector3i in door_counterparts: + removed += _remove_door_for_batch(position, changed_chunks) for chunk_position in changed_chunks: _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 _stage_chunk_edits(chunk_position) @@ -1717,6 +1821,11 @@ func _extinguish_fire(position: Vector3i, changed_chunks: Dictionary) -> void: ## only voxel change in the burning lifecycle, so it is the one persisted. Like ## `break_block()`/`carve_sphere()`, the new air lets adjacent water flow in. func _destroy_burnt_block(position: Vector3i, changed_chunks: Dictionary) -> void: + if BlockRegistry.is_door(get_block_world(position)): + var lower := position - Vector3i.UP if BlockRegistry.door_upper(get_block_world(position)) else position + _remove_door_for_batch(lower, changed_chunks) + _remove_door_for_batch(lower + Vector3i.UP, changed_chunks) + return var chunk := _loaded_chunk_for(position) if chunk == null or chunk.lod: return @@ -1727,6 +1836,21 @@ func _destroy_burnt_block(position: Vector3i, changed_chunks: Dictionary) -> voi changed_chunks[_chunk_for_block(position)] = true +func _remove_door_for_batch(position: Vector3i, changed_chunks: Dictionary) -> int: + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod: + return 0 + _restore_chunk_data(chunk) + var index := _data_index(position) + if not BlockRegistry.is_door(chunk.data[index]): + return 0 + chunk.data[index] = BlockRegistry.BLOCK_AIR + _record_edit(position, BlockRegistry.BLOCK_AIR) + _seed_water(position) + changed_chunks[_chunk_for_block(position)] = true + return 1 + + ## Batches fire edits like the water tick: each touched chunk is versioned once ## and its full light ring invalidated once, however many cells changed. func _flush_fire_changes(changed_chunks: Dictionary) -> void: @@ -2078,6 +2202,41 @@ func find_safe_spawn(desired: Vector3) -> Vector3: return desired +## Beds need a same-floor search rather than the surface spawn scanner above. +## A roof is often the topmost opaque block in an indoor column, so using +## find_safe_spawn() would incorrectly move the saved respawn onto the roof. +func find_bed_spawn(bed_position: Vector3i, radius: int = 3) -> Dictionary: + var safe_radius := clampi(radius, 1, 8) + for ring in range(1, safe_radius + 1): + for dx in range(-ring, ring + 1): + for dz in range(-ring, ring + 1): + if maxi(absi(dx), absi(dz)) != ring: + continue + var feet_cell := bed_position + Vector3i(dx, 0, dz) + if not _is_standable_cell(feet_cell): + continue + return {"found": true, "position": Vector3(feet_cell) + Vector3(0.5, 0.5, 0.5)} + return {"found": false} + + +## Tests whether a previously validated spawn is still safe. Bed respawns use +## this before falling back to the surface-oriented find_safe_spawn(). +func is_standable_spawn(world_position: Vector3) -> bool: + if not world_position.is_finite(): + return false + return _is_standable_cell(Vector3i( + floori(world_position.x), floori(world_position.y), floori(world_position.z))) + + +func _is_standable_cell(feet_cell: Vector3i) -> bool: + if get_block_world(feet_cell) != BlockRegistry.BLOCK_AIR \ + or get_block_world(feet_cell + Vector3i.UP) != BlockRegistry.BLOCK_AIR: + return false + var below := get_block_world(feet_cell + Vector3i.DOWN) + return below != BlockRegistry.BLOCK_AIR and not _blocks.is_water_id(below) \ + and not _blocks.has_flag(below, BlockRegistry.FLAG_CROSS) + + func _find_column_spawn(block_x: int, block_z: int, scan_top: int) -> int: for y in range(scan_top, 0, -1): var id := get_block_world(Vector3i(block_x, y, block_z))