From baf102619f07d929a2d675b3aaf83079fea5f670 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 18 Sep 2026 21:12:49 +0100 Subject: [PATCH 1/4] Add functional blocks and sleeping --- ROADMAP.md | 4 +- assets/placeholders/functional/README.md | 12 + assets/placeholders/functional/bed_bottom.png | Bin 0 -> 516 bytes .../functional/bed_bottom.png.import | 40 ++++ assets/placeholders/functional/bed_side.png | Bin 0 -> 290 bytes .../functional/bed_side.png.import | 40 ++++ assets/placeholders/functional/bed_top.png | Bin 0 -> 370 bytes .../functional/bed_top.png.import | 40 ++++ .../placeholders/functional/chest_bottom.png | Bin 0 -> 524 bytes .../functional/chest_bottom.png.import | 40 ++++ assets/placeholders/functional/chest_side.png | Bin 0 -> 704 bytes .../functional/chest_side.png.import | 40 ++++ assets/placeholders/functional/chest_top.png | Bin 0 -> 653 bytes .../functional/chest_top.png.import | 40 ++++ .../functional/crafting_bottom.png | Bin 0 -> 510 bytes .../functional/crafting_bottom.png.import | 40 ++++ .../placeholders/functional/crafting_side.png | Bin 0 -> 732 bytes .../functional/crafting_side.png.import | 40 ++++ .../placeholders/functional/crafting_top.png | Bin 0 -> 703 bytes .../functional/crafting_top.png.import | 40 ++++ assets/placeholders/functional/door_lower.png | Bin 0 -> 358 bytes .../functional/door_lower.png.import | 40 ++++ assets/placeholders/functional/door_upper.png | Bin 0 -> 386 bytes .../functional/door_upper.png.import | 40 ++++ .../functional/furnace_bottom.png | Bin 0 -> 268 bytes .../functional/furnace_bottom.png.import | 40 ++++ .../placeholders/functional/furnace_side.png | Bin 0 -> 412 bytes .../functional/furnace_side.png.import | 40 ++++ .../placeholders/functional/furnace_top.png | Bin 0 -> 266 bytes .../functional/furnace_top.png.import | 40 ++++ assets/placeholders/functional/ladder.png | Bin 0 -> 309 bytes .../placeholders/functional/ladder.png.import | 40 ++++ assets/placeholders/functional/sign.png | Bin 0 -> 342 bytes .../placeholders/functional/sign.png.import | 40 ++++ autoload/audio_manager.gd | 2 + game/crafting_recipes.gd | 6 + game/item_registry.gd | 4 +- game/main.gd | 38 +++- player/player.gd | 45 +++- tools/functional_blocks_verify.gd | 119 ++++++++++ tools/functional_blocks_verify.gd.uid | 1 + tools/gen_functional_textures.gd | 188 ++++++++++++++++ tools/gen_functional_textures.gd.uid | 1 + ui/inventory_overlay.gd | 2 + world/block_registry.gd | 211 +++++++++++++++++- world/chunk_mesher.gd | 150 +++++++++++++ world/day_night_cycle.gd | 10 + world/voxel_world.gd | 134 ++++++++++- 48 files changed, 1549 insertions(+), 18 deletions(-) create mode 100644 assets/placeholders/functional/README.md create mode 100644 assets/placeholders/functional/bed_bottom.png create mode 100644 assets/placeholders/functional/bed_bottom.png.import create mode 100644 assets/placeholders/functional/bed_side.png create mode 100644 assets/placeholders/functional/bed_side.png.import create mode 100644 assets/placeholders/functional/bed_top.png create mode 100644 assets/placeholders/functional/bed_top.png.import create mode 100644 assets/placeholders/functional/chest_bottom.png create mode 100644 assets/placeholders/functional/chest_bottom.png.import create mode 100644 assets/placeholders/functional/chest_side.png create mode 100644 assets/placeholders/functional/chest_side.png.import create mode 100644 assets/placeholders/functional/chest_top.png create mode 100644 assets/placeholders/functional/chest_top.png.import create mode 100644 assets/placeholders/functional/crafting_bottom.png create mode 100644 assets/placeholders/functional/crafting_bottom.png.import create mode 100644 assets/placeholders/functional/crafting_side.png create mode 100644 assets/placeholders/functional/crafting_side.png.import create mode 100644 assets/placeholders/functional/crafting_top.png create mode 100644 assets/placeholders/functional/crafting_top.png.import create mode 100644 assets/placeholders/functional/door_lower.png create mode 100644 assets/placeholders/functional/door_lower.png.import create mode 100644 assets/placeholders/functional/door_upper.png create mode 100644 assets/placeholders/functional/door_upper.png.import create mode 100644 assets/placeholders/functional/furnace_bottom.png create mode 100644 assets/placeholders/functional/furnace_bottom.png.import create mode 100644 assets/placeholders/functional/furnace_side.png create mode 100644 assets/placeholders/functional/furnace_side.png.import create mode 100644 assets/placeholders/functional/furnace_top.png create mode 100644 assets/placeholders/functional/furnace_top.png.import create mode 100644 assets/placeholders/functional/ladder.png create mode 100644 assets/placeholders/functional/ladder.png.import create mode 100644 assets/placeholders/functional/sign.png create mode 100644 assets/placeholders/functional/sign.png.import create mode 100644 tools/functional_blocks_verify.gd create mode 100644 tools/functional_blocks_verify.gd.uid create mode 100644 tools/gen_functional_textures.gd create mode 100644 tools/gen_functional_textures.gd.uid 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 0000000000000000000000000000000000000000..1ccb56ad7f82f77e7d4b68887f694073ae03ef3a GIT binary patch literal 516 zcmV+f0{i`mP)Px$zDYzuRCt{2n@euPFbqT)!J8mJbAqCW=tX*!-qzc~CrE*!%XZPmK>U$ONn?>R zXH91&&sGv?kkk9y>kl1R9TCyX^HX%_e7Vy1*KL^DDUJY&^8bg K=d#Wzp$P!$F>B@k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..87c9fa91087f320208867df7be4bb8b88e72fdfa GIT binary patch literal 370 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D%zbv#`hLn`LH zow1wukb{8B$CHL9IOod=EON|R!s5ikDa7l*XvfTV%=q-VmgVZ-Hg2ln@vXPqFV-1+ zgXztZc!y}~n6lS9ZPQMEE|j0Mw4P}X!!`!%IELs3S^Wmx1AXEL#1f8kCva~Fe#^A= z{+a)?zW6)*Vl`R*_~3tzRKH)WTYl#qEnATGh4V(P`D8l{E&E*!+4ZwcOtk)glwF|I z`DFh-7EzUnfBd&IL<^n#U&HJ6D=7GfkfYGa=^T?h_PI=O`Lwibg<9tm@3#TILMQq2 z7+pP8;`g#$RhhVFH$(CYq4#Px$#z{m$RCt{2n!#?uFc3tymM@WzI9BR!^hf$D{oq_HI995}u|2dzB?->1?TOdR z&UwQOFEL;bPw#K9-}JzWL_{yoLvm<19O?4)xl16a_s99NnLnk#8RES8;c(0j(bAg* zfTLa^tpd;(6y?1NKx0spclHvn0x3v_rRo7vkPIW#+MB%uS_egWSNj?y>J!pk0NR40 zyzc_g78K=Ov;-_bngD1Iit?TSXb+0=jzqs(N|v~D2qnDP`BxN6_R?I<_w1U^7m^=> z79aV!cM^c6peXO+UxKY3Wv=%Yq#FRJyU9_jC+P;jBRukRiI;#iNL6=(SM>yG6M&YW zDDO=GT7sgyt1N+@AuRxC4T|z!0MHr~<(;(zEI|sr1VwqraXdjn%(c2_S8%>Cod=Zm z?5+3s<9N!-@AbH3xKtV;c|D$cp%qA8k4uITYVB>N9?zATdYmJ#$Gsk3%IonOVN%i# zNd!Rq-OP~)fcCqY;~oF+(@T2xNe%b-IcFQm{|D6G<4evQ_Bvnidc5=ndWW=2J+2v& z*W=O)tU>a6Tryl!S|E8nUUc=JdWPinxMcV$OOU)C?zM-p O0000Px%dPzhbl_tvL*yuN68rp>G`4?JD_q~@&-u>wr8Bu3>`{#@cPGrhU&LQEd6Iw; zLg=h}%p4R(G5Y)7X)(CuOhfy^v;F@0!}Xs<02u^%_%Mp4ooLcF&VCsU`_dC7bh#r& zLq!wxd;+!!&o8U=GbjRV#(4p9OCTE>Iw(tEi`5bHH{<@Vkd3M&2u<7p8EgVwmBW?T)1+X>bX(|PCsIxT7fT`L56J@+DLd|FGEYB03fOvEJSf}dVdP4zO?iPUfNdd<5Q6Um z=)qOAxfJ53*AJx$64-O}#&Wa5d{gsZI0@=vHl5|ZKTbu`qduJP3pg(TzP&ncv~01s z9-JDRo9q|xdb2fVgwT`xoys|%PcnCUAE4T2*ZB?s0qRc}#b~|RO8KuX2efJTm!(ty zmrjM9`TgY{A@L<38}d53!)3N*m$38_uw{$v+v6gGo5Hup4MvhR?QsacJ?`7%MSOc) zhche=y%Y)wU}Iho62OL#r@`4Xx1RGiZw}e_1Ng|?Z7|;t(0#^-JRI@I#Qj1UzCErw zLEA;Dr9FPgrO^5IxbPBad&txH_PEI4y7BGt8f0&NzlAb@M7upoeIrBG@Kv^nHy m0@p&GK%P5rE#wL0x&Hxm(r}k@d_3F$0000P literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..075ce49e0752d957736c996535b0bd3a462e0959 GIT binary patch literal 653 zcmV;80&@L{P)Px%M@d9MRCt{2o4szrFc8O&CN~LY9lG%X2=NAej6O&!@dO~T zbShH^hJI{xK#mfF?ekY+`}~_iYjVy%=ezhW4Cb@x5jtXhxUP>E%SG#dtL3UbzN@~v zni7-%0Jk?+(m}&<1%KZMYh!EVDVp<9+5Y(aQO;jRff8rf7m=a+i2S-4)yJ~Ez|-UxZM&04VG${$!dd{TsjwD+YAP%N zPFqSG*l{98>LDPSkJvVT{^yi70R#&RR3`NR*YJT`8U#~xd48!&1xO*vtFV3$3O&lJ zL~02@sSv_w2|zIwYA=D*;)0jWP)u<_DL`s24*>Y*0DiEDm|UL6$r6ZJq%#4i?F!EX zpq2`amO#ud&r?uyc?P@ga=Y96o(}-9J-bakz%?fubv?kgxQHo``l<&E#}#aMd!za1 z_PEaAn91#N0l7V{Ggy}x;sW{hIC=?$?((KMEr|5#@)$1NAfW)Mx;&3ifY4pumF?Uf zcQF|mp{GJE0aA<$S^}it@;qg8@9kzEE<1b7$@sLyD;N|(T^`oi$t}ro33ea5Y%6ElbrX$^Vg?**M+#YX`+v7TeZ3s0L=JvSB nmtuB#9`fz+f||=S*meH{I=61qHy~Y100000NkvXXu0mjfzz;2s literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..213483c76136496ff70671e9d649dad1b56602af GIT binary patch literal 510 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D%zcX+xuhE&XX zJIgTdumO*&9wJ{|c78Uw=N7$#bv2{Pd%LG)w0wo|}DNzVD`DRMlpN zf)vq9u?$kpi}t){x*#F)bnaf3Jr1u_4hKaxT=8rZ%zAq^j%h7Wc6BYU(9ISTZincA zDc^M%avcNjxq2U9UZTzL7RU@|xCK;Gy=cF#s{b9!mS=l^{#^db``e|M-tTW_ofDrV z-y|phb7Q$s!?Fb$?;piCt(8k%_g!d-G{cJBCj(cjzhpZgCiipW5$^-iOIGbZ=r~P5 zm+69#$kV+X4&DJ%%BQL(NM2%TxCYeA$*>k^U8MH`;U(w1fG)}V{GVUq+smc4)vs;; z-TrNic;2hRzD08VRW++t9M5j?^t4tH zWO!R{?5@Oc>wQ{Z`5g8o-!5$Z{$rAH`_^;fi{$ic8DCEbTY22nxvMIss?ppdu;05a y+lzb2isRku{!LE>^Vut#M858r3UnFRIr?hN%?A6gIkp2Ml)=;0&t;ucLK6To-{pP) literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b9bebaeeb3568a7589af99c8921883700259c79d GIT binary patch literal 732 zcmV<20wev2P)Px%mPtfGRCt{2TE9!fKoour$q*rmbSQ2XIy)%h(p4;o;Nt(_>MphRUvTN{S~n>a zoP;i29GnYv5pfVvp$-l@NJ?|P%iSe+xpzsvF?qS~%a{A{?p`Wq$2-4JU^u1)m_})U zX#u8D8em$0X_N+-7GN5MH^2n|Xf^9f8uR=Be!jnyv?Yq+XbP=nJu$b^9v~vX^8=NX zskxa3U=yl~n`rh;r;ZDEpd ztqzekUtnnx(M58{PJ(&t@+5bx0hom9Lh4K~>(o~kybt7(!6Gm8y3&Bl!KZ|bDvYJ# zIap}`06=Xv;fC_wC(;A3e@z|8-F8@ZPRgADvJ;QPJ8KEx6{p10sur!N@PH7 zHi;x0yvZ|6glH+}#o?+ne_SFY3nfFqWWFGRIPN6_ije&+V#xrLOQ8vqw5;bN!n93 z2Vmbvu!cI-0IX|v8n8YP)c~w(whkm70ybxVi*RIuWr4jXd4BM3fGumb4sNBHppElF z=H+pdKv5nSL0KNpuqRnt9v6XBd0ZE;_aqdj!X`~pek$zw0i54F({B#MPk_n8QeE=` z_y2&W7y8z7b7P(P%=n|4P89&`t-VQW0*Kc|L+t+no*%%~^*!}o+;b}GfJggV%&(r= zc>wkaOa;pFI2o+sg;>=d*0GyPZW6pUBL>id$$xH430>rt`9FyV=^=o9UO-VEx5*@~ z0a%3U;^rEFMW`-r>&xS5xy$mnOmK)inUrV<^kVhd4uCTN O0000Px%c}YY;RCt{2n!QQ`K@i6$Vu}=rh?SlC02YFzu@!`1Vc|Pi`Ub%_u*^H8lt92X zO%N=6f!JAz7z+!F*ciF&?cUDp$Ia~B{VEghc834$eB5kWSN)?E)R--92e@ej;C6tU zZS{+r>l0{qy72S;#o-Ux_H?#@i?c)Y^`r6g#^;;>0DyA9=IC^``2U;YplQwQdY9+N z!5)xv2ypEXtnGHEi&~342Vfngf_*?}0XQ9vWgEbd161xNO8{6FUNao@q9xA|ImtxO z5a4{8uC|Q=NOLrnZKD8H$?`VXtAHz^L=qtYXSj(FfHT}$>OA0Je>Zp0{BzH`P=Dt= zoL3Qfef!w>oa+O0efxY;+ym(D%l7W-|6OXf>$`=@XlxN6%i7W5pyzB*N6f~CN?{I= zRcWo literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..991d7e63509c875907ecf551890a55c295dfa385 GIT binary patch literal 358 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D%z6+B%WLn`LH zoo&c<$U%g)cgZG!BTjEZY#Mue*#*|<>}lREkU!CSiI`18Z?8H>L^P+^wp*40eyyRi4&i)zq6i&Zi_Icmjop;Y*}z7{ zZsE)SV(T9vEcgAQy}{mw=h(N?{^^q|%Z$rc=jFbINKbBg&v)M{_|#K%o_Vghva(GN i#sA5E?{E0ckgqpKBR%3}_#0rrF?hQAxvX5zl-~Vr-((9iwWO?&>hV|i^5;1dX*P2vS09waa{b2qh!;>2*Lbsw=4dK z-#nA{?ELopp6P3HjNaVUH88be=w-RUnDJ_E*yBn=xzbp*yYlCz&NH?T|G_nZ0V=7| zz~>PBeO|$C>%B4e_e?i1o_}8~Z50D&!F{Gmy|a!FD}Gn~d0EFliOaw^T4(zC^RKU- z`ui{bqL|0t1Ka&K_AH;LUc>6aAlsm}r(sEa)BSn3HP8L@Z&}a4xq$h?3h{#ZA6dUL ziMsD~Sh+e>03_BTF0fC$K-^&Vx5@kEA9()IRbVV(kOIl5yk|Z2$Zp1d+0%Wqw$9wZFS-W@A@;@{4R@?L3ev>}^_1k|oywBFKnmlG+^S=G` zHd(z7H_EN*|L|%wh+PnI;LhM$z-q#JfoT>~2IJWu4Bo#R9`^8WsQbls{ryk>=l%N` zp>n>ATN;)zL^%XA=q`|yUr-Cw-NS$3f5Tz*e-Z?g?DvWgY+k literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..0c9c595dce7fa0cd76ebeddfc661425993f7a5e4 GIT binary patch literal 412 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D%z!#rIaLn`LH zon^>%$U($qF$)WKcZ{2h$+fbNN?h}tFEnRZNlS12$TIJMbOcA!g11~+FaF&%JhMlS z^QSe>3PxjsXm!tSx z)vKX8;qFn+-qyHZEEl8`e$_kVuvNah>))`3+3&P+(v9geq#vxidVTinz|h-Y_jCWX tZn(!V*!9ZqJ5YFJjoupOxXvDk|@6Zx1g zFqu4M-oe^r+jg8`3xn@ZhCIfohGV)HZYs;NEnv+!#a_V8Qq9v3Gh+)wZ}fuA?p7d| oOyPeZ#_>%;4rT_(n&=0t9*u^~Im^y;0KLxO>FVdQ&MBb@07|ubD*ylh literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..93ec2b6a4b92df39fec2cde7cb03d46b91c15a14 GIT binary patch literal 342 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7TyC=jKx9jP7LeL$-D%z1w36GLn`LH zoxPFwkb#H`W2&gyf`!SgCL$p-89NOlltQ^?t#LTc$HUD0L9<78|AgJ)QC%ffR>$o- zH`#69ecfvYuWZ9HrWXt*pO|K4e7x~4`}vAPxivLwPMSN!IqYCv_Uw}V;jh`-*UmY@ zyMcjw0rQ0u+zx(>kMjQW{$z__fa(=3_xKCP;K zi9gv+aBFO1{a30rCBFeAr_;dakjz-ZAO&&<)U+Q4B7bAIhCGo@07+Cb=C3yrpYOJs R!yOn<44$rjF6*2UngDxCeK!CA literal 0 HcmV?d00001 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..c41266d 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,38 @@ 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 requested_spawn := Vector3(bed_position) + Vector3(0.5, 2.0, 0.5) + var safe_spawn := world.find_safe_spawn(requested_spawn) + if safe_spawn.is_equal_approx(requested_spawn): + set_status("No safe respawn point near this bed") + return + player.spawn_position = safe_spawn + _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..db40b4a 100644 --- a/player/player.gd +++ b/player/player.gd @@ -342,7 +342,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 +492,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 +566,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..f85ec9d --- /dev/null +++ b/tools/functional_blocks_verify.gd @@ -0,0 +1,119 @@ +extends SceneTree + +var failures := 0 + + +func _init() -> void: + _verify_registry_states() + _verify_shape_meshes() + _verify_recipes_and_drops() + _verify_sleep_clock_and_threat_hook() + 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 _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..1fe2c08 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_WOOD_BED_LAST 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..0d6b8d4 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: From 533122dc4ea78df1f04ed2bc9dccb2e6643ee862 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 18 Sep 2026 21:25:18 +0100 Subject: [PATCH 2/4] Fix indoor bed respawn placement --- game/main.gd | 7 +++---- tools/functional_blocks_verify.gd | 29 +++++++++++++++++++++++++++++ world/block_registry.gd | 2 +- world/voxel_world.gd | 22 ++++++++++++++++++++++ 4 files changed, 55 insertions(+), 5 deletions(-) diff --git a/game/main.gd b/game/main.gd index c41266d..60fe8ad 100644 --- a/game/main.gd +++ b/game/main.gd @@ -947,12 +947,11 @@ func _attempt_sleep(bed_position: Vector3i) -> void: if world.is_threatened(player.global_position): set_status("You cannot sleep while threatened") return - var requested_spawn := Vector3(bed_position) + Vector3(0.5, 2.0, 0.5) - var safe_spawn := world.find_safe_spawn(requested_spawn) - if safe_spawn.is_equal_approx(requested_spawn): + 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 = safe_spawn + player.spawn_position = bed_spawn.position _day_night.skip_to_morning() set_status("Slept until morning · respawn point set") _flush_world_save() diff --git a/tools/functional_blocks_verify.gd b/tools/functional_blocks_verify.gd index f85ec9d..d0db4ee 100644 --- a/tools/functional_blocks_verify.gd +++ b/tools/functional_blocks_verify.gd @@ -8,6 +8,7 @@ func _init() -> void: _verify_shape_meshes() _verify_recipes_and_drops() _verify_sleep_clock_and_threat_hook() + _verify_indoor_bed_spawn() if failures == 0: print("functional_blocks_verify: PASS") quit(0) @@ -112,6 +113,34 @@ func _verify_sleep_clock_and_threat_hook() -> void: _check(world.is_threatened(Vector3.ZERO), "installed hostile query blocks sleep") +func _verify_indoor_bed_spawn() -> void: + 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 + 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") + + +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 diff --git a/world/block_registry.gd b/world/block_registry.gd index 1fe2c08..ce82cc4 100644 --- a/world/block_registry.gd +++ b/world/block_registry.gd @@ -394,7 +394,7 @@ static func canonical_id(block_id: int) -> int: ## 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_WOOD_BED_LAST and canonical_id(block_id) == block_id + 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 diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 0d6b8d4..5793134 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -2202,6 +2202,28 @@ 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 get_block_world(feet_cell) != BlockRegistry.BLOCK_AIR \ + or get_block_world(feet_cell + Vector3i.UP) != BlockRegistry.BLOCK_AIR: + continue + var below := get_block_world(feet_cell + Vector3i.DOWN) + if below == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(below) \ + or _blocks.has_flag(below, BlockRegistry.FLAG_CROSS): + continue + return {"found": true, "position": Vector3(feet_cell) + Vector3(0.5, 0.5, 0.5)} + return {"found": false} + + 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)) From a269bcfa81f14c427faf3913cdb98772f558363d Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 18 Sep 2026 21:32:15 +0100 Subject: [PATCH 3/4] Preserve validated bed respawns --- player/player.gd | 3 ++- tools/functional_blocks_verify.gd | 1 + world/voxel_world.gd | 25 +++++++++++++++++++------ 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/player/player.gd b/player/player.gd index db40b4a..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 diff --git a/tools/functional_blocks_verify.gd b/tools/functional_blocks_verify.gd index d0db4ee..f8b2a02 100644 --- a/tools/functional_blocks_verify.gd +++ b/tools/functional_blocks_verify.gd @@ -134,6 +134,7 @@ func _verify_indoor_bed_spawn() -> void: _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 _set_fixture_block(data: PackedByteArray, position: Vector3i, block_id: int) -> void: diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 5793134..468eb06 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -2213,17 +2213,30 @@ func find_bed_spawn(bed_position: Vector3i, radius: int = 3) -> Dictionary: if maxi(absi(dx), absi(dz)) != ring: continue var feet_cell := bed_position + Vector3i(dx, 0, dz) - if get_block_world(feet_cell) != BlockRegistry.BLOCK_AIR \ - or get_block_world(feet_cell + Vector3i.UP) != BlockRegistry.BLOCK_AIR: - continue - var below := get_block_world(feet_cell + Vector3i.DOWN) - if below == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(below) \ - or _blocks.has_flag(below, BlockRegistry.FLAG_CROSS): + 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)) From dfc367108f4070fa8e9bfce84d0e8c7280ba7b99 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Fri, 18 Sep 2026 21:42:42 +0100 Subject: [PATCH 4/4] Cover functional block lifecycles --- tools/functional_blocks_verify.gd | 84 +++++++++++++++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/tools/functional_blocks_verify.gd b/tools/functional_blocks_verify.gd index f8b2a02..3bcb0c7 100644 --- a/tools/functional_blocks_verify.gd +++ b/tools/functional_blocks_verify.gd @@ -9,6 +9,7 @@ func _init() -> void: _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) @@ -114,11 +115,9 @@ func _verify_sleep_clock_and_threat_hook() -> void: func _verify_indoor_bed_spawn() -> void: - 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 + 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. @@ -137,6 +136,81 @@ func _verify_indoor_bed_spawn() -> void: _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