From 08b295f5964cfaa5aa3e7b7a0373529e9e7b5f33 Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 15 Sep 2026 10:04:04 +0100 Subject: [PATCH 1/3] Add fire that burns blocks in place Flammable blocks enter a burning state instead of being replaced by a fire block: the block keeps its texture and collision while a new FireOverlay draws animated flame and smoke billboards over it. Burning creeps into flammable neighbours a few blocks a second and the block crumbles to air when its burn timer expires. BLOCK_FIRE remains only the standalone flame flint-and-steel lights on a non-flammable face. --- .github/workflows/tests.yml | 2 + AGENTS.md | 2 + ROADMAP.md | 2 +- assets/placeholders/fire/README.md | 6 + assets/placeholders/fire/fire.png | Bin 0 -> 2334 bytes assets/placeholders/fire/fire.png.import | 40 +++ assets/placeholders/fire/smoke.png | Bin 0 -> 1919 bytes assets/placeholders/fire/smoke.png.import | 40 +++ game/main.gd | 9 +- tools/fire_verify.gd | 191 +++++++++++++ tools/fire_verify.gd.uid | 1 + tools/fire_verify.tscn | 6 + tools/gen_fire_texture.gd | 137 +++++++++ tools/gen_fire_texture.gd.uid | 1 + world/block.gdshader | 20 +- world/block_registry.gd | 61 ++-- world/fire_overlay.gd | 127 +++++++++ world/fire_overlay.gd.uid | 1 + world/fire_overlay.gdshader | 32 +++ world/fire_overlay.gdshader.uid | 1 + world/smoke_overlay.gdshader | 29 ++ world/smoke_overlay.gdshader.uid | 1 + world/voxel_world.gd | 331 ++++++++++++++++++++++ 23 files changed, 1012 insertions(+), 28 deletions(-) create mode 100644 assets/placeholders/fire/README.md create mode 100644 assets/placeholders/fire/fire.png create mode 100644 assets/placeholders/fire/fire.png.import create mode 100644 assets/placeholders/fire/smoke.png create mode 100644 assets/placeholders/fire/smoke.png.import create mode 100644 tools/fire_verify.gd create mode 100644 tools/fire_verify.gd.uid create mode 100644 tools/fire_verify.tscn create mode 100644 tools/gen_fire_texture.gd create mode 100644 tools/gen_fire_texture.gd.uid create mode 100644 world/fire_overlay.gd create mode 100644 world/fire_overlay.gd.uid create mode 100644 world/fire_overlay.gdshader create mode 100644 world/fire_overlay.gdshader.uid create mode 100644 world/smoke_overlay.gdshader create mode 100644 world/smoke_overlay.gdshader.uid diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 53fdae5..b69a135 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -68,6 +68,8 @@ jobs: tools/input_rebind_verify.gd \ tools/photo_mode_verify.gd \ tools/player_target_verify.tscn \ + tools/explosives_verify.tscn \ + tools/fire_verify.tscn \ tools/worldgen_cactus_verify.gd \ tools/worldgen_tree_verify.gd \ tools/worldgen_cave_verify.gd \ diff --git a/AGENTS.md b/AGENTS.md index 5108c9e..633a072 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,7 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - IDs are stored in `PackedByteArray`, so stay under 256. `TEXTURE_TINTS` applies per-texture tinting (grass, leaves, sand, ...). - Cutout blocks (leaves, glass, torch) are antialiased with `alpha_to_coverage`, so their edge quality depends on MSAA being enabled. - Water: `BLOCK_WATER` (16) is a source; IDs 28-34 are flowing levels 7..1, resolved through `BlockRegistry.is_water_id()`/`water_level()`/`water_id_for_level()`. `VoxelWorld` runs a 4 Hz cellular flow tick (`_water_tick`, 1024 cells/tick) seeded by breaks/places near water; flow replaces only air or weaker flowing water, falls when the cell below is air, and dries when unfed. Every change goes through `_record_edit()`, so the settled state is reapplied on chunk regeneration; `_edited_blocks` is mirrored into `_edits_by_chunk` so job snapshots and `TerrainGenerator.generate_data()` only see the chunk's own edits. Meshes are non-opaque, non-breakable, no collision and get the same `ARRAY_CUSTOM0` light attribute as blocks; flowing surfaces render at `_water_top(level)` and set vertex-color alpha so `water.gdshader` can calm the waves. `water.gdshader` samples `hint_screen_texture` for screen-space refraction (wave-distorted background with a faint chromatic split, carried through `EMISSION` so the refracted scene is not lit twice), `hint_depth_texture` for shore blending (shallow water is lighter, more transparent, and foams at the edge), and block light for warm glow. +- Fire: burning a block never replaces it. A `BlockRegistry.FLAG_FLAMMABLE` log/leaf/dry plant/mushroom enters the `_burning` state for `FUEL_BURN_TICKS` with its texture and collision intact, and `FireOverlay` (`world/fire_overlay.gd` + `fire_overlay.gdshader`/`smoke_overlay.gdshader`) clusters flame and smoke billboards on it — one MultiMesh per effect, animated entirely in-shader from per-instance phase/progress and rebuilt only on the 4 Hz `VoxelWorld._fire_tick`. Burning blocks crawl into flammable neighbours one at a time, paced by a per-block `FIRE_IGNITION_DELAY_TICKS` (4), `FIRE_SPREAD_PER_TICK` (1) on every `FIRE_SPREAD_PERIOD_TICKS` (2), plus a per-cell `FIRE_SPREAD_INTERVAL_TICKS` cooldown (~2 blocks/s), and can chain-detonate adjacent TNT/nukes. When the timer expires the block crumbles to air and that edit goes through `_record_edit()`, so the settled result survives chunk regeneration (mid-burn state does not persist). `BLOCK_FIRE` (70) is only the standalone cutout cross flame that flint-and-steel leaves on a non-flammable face (`FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE`, animated by `block.gdshader`); it decays when starved/unsupported and can catch adjacent fuel. `use_flint_and_steel()` detonates an explosive, starts the clicked flammable block burning, or lights the clicked face. `tools/fire_verify.tscn` covers the lot. - Underwater biomes: `OCEAN`/`DEEP_OCEAN` are split with `KELP_FOREST`, `SEAGRASS_MEADOW`, `CORAL_REEF`, `FROZEN_OCEAN` (BiomeCatalog IDs 15-18), chosen in `TerrainSampler._underwater_biome()` from depth, temperature, and patch noise; `TerrainSampler._ocean_floor_at()` shapes the shelf, slope, and abyss. `VoxelPopulator._decorate_underwater()` grows seabed plants from `DecorationCatalog._underwater_sets` (new blocks 52-58: coral substrate, seagrass, kelp, coral fan/branch, sponge, anemone) and only replaces water; the water mesh culls faces against `FLAG_CROSS` plants and `get_water_ambience()` treats a camera inside one as submerged, so foliage does not sit in glassy air pockets. `VoxelWorld.get_water_ambience()` + `Main._update_underwater()` grade the overlay, `DayNightCycle.set_underwater()` the fog/light/caustics global, and `AudioManager.set_underwater()` a Master low-pass; keep their throttled world queries out of per-frame hot paths. ## Weather / atmosphere @@ -88,6 +89,7 @@ RedotCraft: a Minecraft-like voxel sandbox built with **Redot Engine** (Godot 4 - `redot --headless --path . res://tools/player_target_verify.tscn` is scene-based because autoload identifiers are unavailable to `--script` harnesses; use a `.tscn` whenever a test references `GameConfig`/`AudioManager`. - `redot --headless --path . res://tools/explosives_verify.tscn` checks flint-and-steel activation, TNT/nuke radii, spherical edit persistence, unbreakable blocks, and item-only placement rejection. - `redot --path . res://tools/shadow_proxy_measure.tscn` needs a rendering display: it loads the real scene with a flat canopy, sweeps the sun, and prints the shadow-edge temporal MAD for the leaf shadow proxy off/off-control/on with and without TAA. +- `redot --headless --path . res://tools/fire_verify.tscn` checks flammability flags, fire ignition/spread/decay, chain detonation of explosives, the flint-and-steel face-light path, persistence reseeding, and the fire shader wiring. Scene-based for the same class_name reason as above. - `tools/icon_check.gd` needs a rendering display and fails headless. - Benchmarks: `worldgen_benchmark.gd`, `worldgen_mesh_benchmark.gd`, and `worldgen_stream_benchmark.gd` (the last is minutes long at 32 chunks). diff --git a/ROADMAP.md b/ROADMAP.md index 5ec9764..a0fd0fa 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -177,7 +177,7 @@ Current state: the TerraForged-inspired staged pipeline is live under `world/wor ## World / simulation - [x] Water polish — flowing water levels (IDs 28-34) with a 4 Hz cellular spill/dry sim seeded by edits, falling cascades, settled state persisted through `_edited_blocks`; the mesher renders `_water_top(level)` surfaces with a flowing vertex-color flag. `water.gdshader` uses sharp, gently distorted scene refraction with chromatic split disabled, progressive depth absorption, and faint narrow shore foam. - [ ] Block physics — gravity blocks (falling sand/gravel) and other environment reactions that must persist through saving -- [ ] Fire, TNT, and explosions — fire spread/decay, ignitable blocks, and explosion destruction routed through `_record_edit()` so settled state survives chunk regeneration +- [x] Fire, TNT, and explosions — TNT (5-block radius) and nuke (18-block radius) destruction already routes through `VoxelWorld.carve_sphere()`/`_record_edit()`. Burning works in three states and never swaps the block for fire: a `BlockRegistry.FLAG_FLAMMABLE` log/leaf/plant/mushroom enters `_burning` for `FUEL_BURN_TICKS` with its texture and collision intact while `FireOverlay` (`world/fire_overlay.gd` + `fire_overlay.gdshader`/`smoke_overlay.gdshader`, one MultiMesh per effect) draws animated flame and smoke billboards over it; it crawls into flammable neighbours one at a time, paced by a per-block `FIRE_IGNITION_DELAY_TICKS` (4), `FIRE_SPREAD_PER_TICK` (1) on `FIRE_SPREAD_PERIOD_TICKS` (2) and a per-cell `FIRE_SPREAD_INTERVAL_TICKS` cooldown (~2 blocks/s), and can chain-detonate adjacent TNT/nukes; when the timer expires the block crumbles to air and that edit goes through `_record_edit()`. `BLOCK_FIRE` (70) is only the standalone cross flame flint-and-steel leaves on a non-flammable face (`assets/placeholders/fire/fire.png` via `tools/gen_fire_texture.gd`; smoke puff likewise), decaying when starved/unsupported; `_seed_fire_edits()` re-arms persisted flames on commit. Flint and steel (`use_flint_and_steel()`) detonates an explosive, starts the clicked flammable block burning, or lights the clicked face. `tools/fire_verify.tscn` covers flammability, burn-in-place, spread, decay, chain detonation, flint and steel, persistence reseeding, and the shader wiring - [ ] Buckets and fluid sources — water/lava buckets and source rules on top of the flowing levels 28-34 and `_water_tick`, plus lava/water interaction - [ ] Growth and decay — grass spread, leaf decay, sapling growth, and crop farming - [ ] Ambient life (deferred until the world-detail passes land) — fireflies at night, butterflies/birds by day, and subtle particle life with per-biome density and time-of-day gating; fireflies can reuse the emissive block-light path for glow diff --git a/assets/placeholders/fire/README.md b/assets/placeholders/fire/README.md new file mode 100644 index 0000000..4c4c1da --- /dev/null +++ b/assets/placeholders/fire/README.md @@ -0,0 +1,6 @@ +# Fire placeholder texture + +`fire.png` is a 64 px transparent flame sheet generated specifically for +RedotCraft by `tools/gen_fire_texture.gd`. It contains no third-party artwork. +The block shader scrolls and warps this texture on the fire layer, so one deterministic +sheet is enough for the animated cross-block flame. diff --git a/assets/placeholders/fire/fire.png b/assets/placeholders/fire/fire.png new file mode 100644 index 0000000000000000000000000000000000000000..0188c0255ae2cc2c296714a5edde42ecf70d8aa1 GIT binary patch literal 2334 zcmV+(3E}pMP)Px-*hxe|RCt{2T78UE)fNApx%bJ;?9NzWmECn#*yUrFZW?V%7c2y^5^KOxBc(;t zQfOV1#I@0oMh+Tcg{Qach5QZytfY+!x+XehB2H!i1f$axzIkyp!0b% zdGWODl`GHZy)PP!Rs=j!F>W->e0cHb#Bo)o`HL;p#xUC0n2IjC5W4?2*gPrP3;+>( z;KNo_(sj|*rRXz;DnKHDF?f!pi?xt!nHF2i7#WLT5dAIFVrv}&?l^YloyV%9v+UpY z;x9%L7(+EEUb(W2jzwINK7DCpbgO{@ZR;)K7*z$S z1F8s;^&JAX*2kV?oKjosV^69KJyje1eraxx9$x%pTrdX47?|I+stA(x9RilTp1qwj zqYvZC6ea|Vt8B)(* z6FV!9Z)=Qo*ix$VHcE*M7I8}$?4A=BvTi{Hki{5TH|@_}B9AYAHQU4(Y8XRJSZAM@ z6kVYj=Nz?RZ2`bb^U4Hg1X;ph?w$3t2m>|FgHur zMSsc}CadN+qqoXcN!t)6H2_uiu>%0SbK4rddR!}kI$Bgt6vC5f~@bP z0B2s2!Ff*R|1I3^-*PZ}9p{+Lxi=NI*2mIeYyrSO7nTdg2(pC5)h`T?t?Ljlt9^iG zwY&36#@sy_VvMkNURmg4hjY4$bGk|%+twKCusG_%o-BnYK-g2D&<-!0;GPlSk2&H*ZxF^M}f4gRc#H;^56*Blg&g=lTf%4%`$j{CCfBN5Jkm zaWQvKo&?TwNA71jg4LC<`EFTg0C31;Lw+Nq6~|$zfp~3^e>l$FlOfItw#bX$_35~< zBO@q+_$cEm|33Y!pA9Zgs4(Tvx!r$#ak%{3cY`-t_T5k>X8tu-JFFtAcbKMU>-5;; z_TNi7vtQNOa0t+~lt-5)#H1~K6t)l0UBCavGBG&l9t&Dj!&H3B!i*p`0;j-~XZne8 zf-ELn^Opfv_`3xz39|T{kEE0Q)* z9Qs_j=XzT{{-T?lpa_%y+h@jwWl_SiC?Ste*wE|hbzA{axw+5tpure$e0f5o((bub z+RM6(!d;dHa1(Oy=5nDrg*-M58ziHBc3fB-u%&%={xs^pGwIqG7Xc?%COwauoDnYH z*hj~gyCc94Q=jSArhtwh6S6Hyp^JcQOHyQ8lFlNpdnrSfMUcft`S08j;M}&iIjP-& z-sYqj9&(SJT$vP?J=Uv?g05u=<8F2>ONgPNa7|s<$6iZA=-KbM*XdlAT;EHXZ|X@< zp|?3HlIy)B`0Zxp6S?$}iSkP+7BSZfwx-9qVE>KkhHdz+OFiR`t7pEgU;OZJxX0n3 z*UCh__vM7JiBTaEqHnM?g;iz4HKJk&0EUM3fido3Zv>qBx_>0;QlR2-JqhB6muc6T~A6|51>&RxO?Bg48|d*PP?LqiC4`~iSiHNi-#ST#XV>j!jXiQzO+ zbW(0txxUqkQ1J5LmjcCnnMJK1khrJ9*9p1ed!R@G_li|-8=N->Y7svg?{O;n+g?K# z=xn>_4OnbMOcdA^n`}%&$+F?GOMfq7>cY0p#*mClXG7D=91sP>E=*7KOU` z6!=xO|IP(S1F_mCl8XH{HNdm5?ijZ*(mjAkjqBM+DuO14ZBgtf-TPiX{O_A6zzIef z#{xx?4DnEn(XM!?MgTy>BBQW1JyfVm03aT$33kTmYQPV=@+ACHpQ|Q2GLb5XHIwu; z;I5;&b^O@eE1;^}Gn7jKzwi?0>?-Vhd)J{_21qbbl0gnpV@j<`q=ztM*( z0Bsrp0NwqzXN$z6Q~Y~?JKiAC>QlrkV#?uM3Iy3X1qsyrhI{dAYm4qBwD3t!T6vfN zfL3*rc)(E0dd>plqh5Xc53yld=oA0|`r6^VjrBgeWr!rl-koo{N!>U-7-Ky?pWU&+EfE> zKa{tzslF&10CEl%s{GO5Q=$Hs4wCr&RP*)a-uD5yBKW6|mh@j&`r>OjJ82(>{Ybd& zkURXJZVcvEX3+ndxOedY5E1KYcN#;|F9c6y+W^4VP5=?>YInN4dq30W+no-Mc- z(dd&{SGyBciTQf!d^X|T{x$$WWx2tIZOUNl*g%`>ceTkU34;M&RcU&JbH|{m%L@4P z;(L?|v#nHOKDzqa^7aDWw>#4oh%Ajga>B0mBWfc-vhb}_Z2*AD8XG~3z17p^8PoYB zBWPctd99v*Cs^#qHUKbn{QTmTOSBpEb^X6iw5e?V7Z4$+K#Re7IsgCw07*qoM6N<$ Ef^DFaU;qFB literal 0 HcmV?d00001 diff --git a/assets/placeholders/fire/fire.png.import b/assets/placeholders/fire/fire.png.import new file mode 100644 index 0000000..33291e1 --- /dev/null +++ b/assets/placeholders/fire/fire.png.import @@ -0,0 +1,40 @@ +[remap] + +importer="texture" +type="CompressedTexture2D" +uid="uid://bmmyojifiaj0i" +path="res://.godot/imported/fire.png-21315587809dcbb752fb58ffa6332c60.ctex" +metadata={ +"vram_texture": false +} + +[deps] + +source_file="res://assets/placeholders/fire/fire.png" +dest_files=["res://.godot/imported/fire.png-21315587809dcbb752fb58ffa6332c60.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/fire/smoke.png b/assets/placeholders/fire/smoke.png new file mode 100644 index 0000000000000000000000000000000000000000..9dca4e05c6bc04071f4e5f852dd06a9931957cfc GIT binary patch literal 1919 zcmV-_2Y~pAP)Px+IY~r8RCt{2Tib5qHWVCRV#moQoBfM@>HmLLfi|(@i|js#1CGXrwq(gs(S`sa zMJrL9;knGAyp!L*|G@vbO~;ykDh`>&RYitVKyIKR#lZdY%fFK!gr)Ux|mHQmvQI01$ zvPFb+YWF6lOw zexCXR&aHq1XaIBzIM~>g%qI$xSKF-wcu*^)I1PFP{(ww`lA;zwz;6CC0Qw)vsI70@`OwcMA-b#RjpCLzc4RAE?l>mMO@S4sw0CLq93BX<^ukN)) z)&S~iB*bIIpaewvo}e|wJ*U2sBf;~W?%H|sJ7@$KuOnA z;LzL&`W3(nvboa@9??vP1jBTPGzQP<1}bw8@?Kg*lqIf`(3SvCcM6^uJQm%civ_sCV`_UwUS)!RlddF?fGM)c zA5f>fJ+f@3fAd|BLdpS6IX#zqOM45&)`kH0EfqFT2_Q|KEB^-IXFC6dZ1!3UFmP>< zb-YXANqUaKZI7nO(-OT1Q)%9dfT+k}s^g0PNML`WUTLm8T|j|52^?>e-mycy!Zg`g zIv&$ATK+~mOFC!my)t&y_;yH|Qq3iSE+$^t$Z!E1AnUaNcQselwrZCMP*xSVZ&~k9uNXmdrAjM*lINWO1w!E! zeK@Ibz8IVr@99kdTNK17SeUM!3ZS)!(y=Q0HzkGd@m*%s{_bMFDGD^nytx)ov(g<~ zsxit_%HKBM`;;V&&?jVZqt&2WP&A&q;h;fKcV(x@%Ip)hNMYVq+3Pzru*v#;e8)PC zOF*9L+$Ezl8@UruAf=53@JIr7$WrzLIbBt#tHKup?0_+i7HAePTQs-9{ByddR%RD1e|7hm1gH*(f@JG@ z;FeEg?habnsi09{-28B1#OFDNZiH-+Np|7msZTrSahGC9TV!sWK`X!foKH8>o^Np%G ze void: func _on_item_used(item_id: int, block_position: Vector3i) -> void: if item_id != ItemRegistry.ITEM_FLINT_AND_STEEL: return - var result := world.trigger_explosive(block_position) + var result := world.use_flint_and_steel(block_position, player.target_normal) if result.is_empty(): - set_status("Flint and steel only ignites TNT or a nuke") + set_status("Flint and steel needs a solid face to light") return + if int(result.get("ignited", 0)) > 0: + AudioManager.play_block_place(BlockRegistry.BLOCK_FIRE, Vector3(block_position) + Vector3(0.5, 0.5, 0.5)) + set_status("Lit fire") + return + AudioManager.play_block_break(BlockRegistry.BLOCK_TNT, Vector3(block_position) + Vector3(0.5, 0.5, 0.5)) set_status("Detonated %s · carved %d blocks" % [result["name"], result["removed"]]) diff --git a/tools/fire_verify.gd b/tools/fire_verify.gd new file mode 100644 index 0000000..4be0699 --- /dev/null +++ b/tools/fire_verify.gd @@ -0,0 +1,191 @@ +## Headless regression check for fire spread/decay, ignitable blocks, and the +## flint-and-steel ignition path. Scene-based so class_name globals resolve. +## Run: +## redot --headless --path . res://tools/fire_verify.tscn +extends Node + +var _failures := 0 + + +func _ready() -> void: + _check_registry() + _check_ignition_and_spread() + _check_decay() + _check_explosive_chain() + _check_flint_and_steel() + _check_persistence_reseed() + _check_shader_wiring() + if _failures == 0: + print("FIRE VERIFY: PASS") + else: + print("FIRE VERIFY: FAIL (%d)" % _failures) + get_tree().quit(1 if _failures > 0 else 0) + + +func _check_registry() -> void: + var registry := BlockRegistry.new() + _expect(registry.is_flammable(BlockRegistry.BLOCK_LOG), "logs should burn") + _expect(registry.is_flammable(BlockRegistry.BLOCK_LEAVES), "leaves should burn") + _expect(registry.is_flammable(BlockRegistry.BLOCK_TALL_GRASS), "tall grass should burn") + _expect(registry.is_flammable(BlockRegistry.BLOCK_MELON), "melon should burn") + _expect(not registry.is_flammable(BlockRegistry.BLOCK_STONE), "stone should not burn") + _expect(not registry.is_flammable(BlockRegistry.BLOCK_FIRE), "fire is not fuel") + _expect(registry.is_emissive(BlockRegistry.BLOCK_FIRE), "fire should be emissive") + _expect(registry.has_flag(BlockRegistry.BLOCK_FIRE, BlockRegistry.FLAG_CROSS), "fire should be a cross block") + _expect(registry.is_breakable(BlockRegistry.BLOCK_FIRE), "fire should be breakable") + _expect(registry.emission_color(BlockRegistry.BLOCK_FIRE) != Color.BLACK, "fire needs an emission color") + _expect(registry._fire_layer >= 0, "fire texture layer was not resolved") + + +func _check_ignition_and_spread() -> void: + var world := _make_world(8) + var fire_position := Vector3i(4, 9, 4) + var fuel_position := Vector3i(5, 9, 4) + _set_block(world._chunks[Vector2i.ZERO].data, fuel_position, BlockRegistry.BLOCK_LOG) + _expect(world.ignite_fire(fire_position), "failed to ignite a supported air cell") + _expect(world.get_block_world(fire_position) == BlockRegistry.BLOCK_FIRE, "ignite did not place fire") + _expect(world._edited_blocks.get(fire_position, -1) == BlockRegistry.BLOCK_FIRE, + "ignition was not recorded as a persistent edit") + _expect(world._chunk_edit_version.get(Vector2i.ZERO, 0) == 1, + "ignition invalidated its chunk more than once") + for step in 12: + world._fire_tick() + # The log catches fire but is never replaced by a fire block. + _expect(world._burning.has(fuel_position), "fire did not catch the adjacent log") + _expect(world.get_block_world(fuel_position) == BlockRegistry.BLOCK_LOG, + "burning log was replaced instead of burning in place") + # Burn-out destroys the block and persists the settled air edit. + for step in 30: + world._fire_tick() + _expect(world.get_block_world(fuel_position) == BlockRegistry.BLOCK_AIR, + "burning log never crumbled away") + _expect(world._edited_blocks.get(fuel_position, -1) == BlockRegistry.BLOCK_AIR, + "burnt log did not persist its settled air edit") + world.free() + + +func _check_decay() -> void: + var world := _make_world(8) + var fire_position := Vector3i(4, 9, 4) + var fuel_position := Vector3i(5, 9, 4) + _set_block(world._chunks[Vector2i.ZERO].data, fuel_position, BlockRegistry.BLOCK_LEAVES) + world.ignite_fire(fire_position) + for step in 45: + world._fire_tick() + _expect(world.get_block_world(fire_position) == BlockRegistry.BLOCK_AIR, + "burned-out fire did not decay to air") + _expect(world.get_block_world(fuel_position) == BlockRegistry.BLOCK_AIR, + "burned fuel did not decay to air") + _expect(world._edited_blocks.get(fire_position, -1) == BlockRegistry.BLOCK_AIR, + "extinguished fire did not persist its settled air edit") + _expect(world._edited_blocks.get(fuel_position, -1) == BlockRegistry.BLOCK_AIR, + "burnt fuel did not persist its settled air edit") + + # A lone spark in open air has no support and should extinguish immediately. + var floating := Vector3i(10, 20, 10) + _expect(world.ignite_fire(floating), "failed to place a floating spark") + world._fire_tick() + _expect(world.get_block_world(floating) == BlockRegistry.BLOCK_AIR, + "unsupported fire was not extinguished") + world.free() + + +func _check_explosive_chain() -> void: + var world := _make_world(8) + var fire_position := Vector3i(5, 9, 4) + var tnt_position := Vector3i(6, 9, 4) + _set_block(world._chunks[Vector2i.ZERO].data, tnt_position, BlockRegistry.BLOCK_TNT) + world.ignite_fire(fire_position) + world._fire_tick() + _expect(world.get_block_world(tnt_position) == BlockRegistry.BLOCK_AIR, + "fire did not chain-detonate the adjacent TNT") + _expect(world._edited_blocks.get(tnt_position, -1) == BlockRegistry.BLOCK_AIR, + "detonation was not recorded as a persistent edit") + world.free() + + +func _check_flint_and_steel() -> void: + var world := _make_world(8) + var stone_position := Vector3i(4, 9, 4) + _set_block(world._chunks[Vector2i.ZERO].data, stone_position, BlockRegistry.BLOCK_STONE) + var lit := world.use_flint_and_steel(stone_position, Vector3i.UP) + _expect(int(lit.get("ignited", 0)) == 1, "flint and steel did not light a fire on a block face") + _expect(world.get_block_world(stone_position + Vector3i.UP) == BlockRegistry.BLOCK_FIRE, + "flint and steel lit the wrong cell") + + var tnt_position := Vector3i(9, 9, 4) + _set_block(world._chunks[Vector2i.ZERO].data, tnt_position, BlockRegistry.BLOCK_TNT) + var blast := world.use_flint_and_steel(tnt_position, Vector3i.UP) + _expect(int(blast.get("removed", 0)) > 0, "flint and steel did not detonate TNT") + _expect(world.get_block_world(tnt_position) == BlockRegistry.BLOCK_AIR, + "detonated TNT survived") + + # Clicking a flammable block lights that block, not the air beside it. + var log_position := Vector3i(1, 9, 1) + _set_block(world._chunks[Vector2i.ZERO].data, log_position, BlockRegistry.BLOCK_LOG) + var lit_log := world.use_flint_and_steel(log_position, Vector3i.UP) + _expect(int(lit_log.get("ignited", 0)) == 1, "flint and steel did not ignite a log") + _expect(world._burning.has(log_position), "clicked log did not start burning") + _expect(world.get_block_world(log_position) == BlockRegistry.BLOCK_LOG, + "clicked log was replaced by a fire block instead of burning in place") + + var far_away := Vector3i(4096, 40, 4096) + _expect(world.use_flint_and_steel(far_away, Vector3i.UP).is_empty(), + "flint and steel ignited unloaded terrain") + world.free() + + +func _check_persistence_reseed() -> void: + var world := _make_world(8) + var fire_position := Vector3i(2, 9, 2) + world._edited_blocks[fire_position] = BlockRegistry.BLOCK_FIRE + world._edits_by_chunk[Vector2i.ZERO] = { + fire_position: BlockRegistry.BLOCK_FIRE, + Vector3i(3, 9, 2): BlockRegistry.BLOCK_AIR, + } + world._seed_fire_edits(Vector2i.ZERO) + _expect(world._fire_queued.has(fire_position), "regenerated fire was not requeued") + _expect(world._fire_life.has(fire_position), "regenerated fire had no burn life") + _expect(not world._fire_queued.has(Vector3i(3, 9, 2)), "non-fire edits should not be queued") + world.free() + + +func _check_shader_wiring() -> void: + var code := FileAccess.get_file_as_string("res://world/block.gdshader") + _expect(code.contains("fire_layer"), "block shader is missing the fire_layer uniform") + _expect(code.contains("fire > 0.5"), "block shader is missing the fire UV animation") + # The burning-block overlay is what keeps the block's own texture visible. + var flame_shader := FileAccess.get_file_as_string("res://world/fire_overlay.gdshader") + _expect(flame_shader.contains("INSTANCE_CUSTOM"), "flame overlay shader is missing per-instance data") + var smoke_shader := FileAccess.get_file_as_string("res://world/smoke_overlay.gdshader") + _expect(smoke_shader.contains("INSTANCE_CUSTOM"), "smoke overlay shader is missing per-instance data") + _expect(FileAccess.file_exists("res://assets/placeholders/fire/smoke.png"), + "smoke overlay texture is missing") + + +func _make_world(floor_y: int) -> VoxelWorld: + 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 + for direction in VoxelDefs.DIRS_8: + var neighbor := VoxelWorld.Chunk.new() + neighbor.data.resize(VoxelDefs.CHUNK_AREA * VoxelDefs.WORLD_HEIGHT) + world._chunks[direction] = neighbor + for z in VoxelDefs.CHUNK_SIZE: + for x in VoxelDefs.CHUNK_SIZE: + _set_block(chunk.data, Vector3i(x, floor_y, z), BlockRegistry.BLOCK_STONE) + return world + + +func _set_block(data: PackedByteArray, position: Vector3i, block_id: int) -> void: + var index := position.x + position.z * VoxelDefs.DATA_STRIDE_Z + position.y * VoxelDefs.DATA_STRIDE_Y + data[index] = block_id + + +func _expect(condition: bool, message: String) -> void: + if condition: + return + _failures += 1 + push_error("fire_verify: %s" % message) diff --git a/tools/fire_verify.gd.uid b/tools/fire_verify.gd.uid new file mode 100644 index 0000000..0feb7bd --- /dev/null +++ b/tools/fire_verify.gd.uid @@ -0,0 +1 @@ +uid://b47eq8xe17ibx diff --git a/tools/fire_verify.tscn b/tools/fire_verify.tscn new file mode 100644 index 0000000..9b2bade --- /dev/null +++ b/tools/fire_verify.tscn @@ -0,0 +1,6 @@ +[gd_scene load_steps=2 format=3] + +[ext_resource type="Script" path="res://tools/fire_verify.gd" id="1_fire"] + +[node name="FireVerify" type="Node"] +script = ExtResource("1_fire") diff --git a/tools/gen_fire_texture.gd b/tools/gen_fire_texture.gd new file mode 100644 index 0000000..b775557 --- /dev/null +++ b/tools/gen_fire_texture.gd @@ -0,0 +1,137 @@ +extends SceneTree + +## One-off generator for RedotCraft's fire placeholder under +## assets/placeholders/fire/. The block shader stretches and wavers this sheet, +## so a single deterministic set of flame tongues is enough for the animated +## cross-block flame. Hottest at the base, cooling to red tips. + +const SIZE := 64 +const OUTPUT_DIR := "res://assets/placeholders/fire" + +var _rng := RandomNumberGenerator.new() + + +func _initialize() -> void: + DirAccess.make_dir_recursive_absolute(ProjectSettings.globalize_path(OUTPUT_DIR)) + _rng.seed = 0xF17E + _save("fire.png", _fire()) + _save("smoke.png", _smoke()) + print("FIRE TEXTURE: done") + quit() + + +func _new_image() -> Image: + var image := Image.create(SIZE, SIZE, false, Image.FORMAT_RGBA8) + image.fill(Color(0, 0, 0, 0)) + return image + + +func _save(name: String, image: Image) -> void: + var path := "%s/%s" % [OUTPUT_DIR, name] + var error := image.save_png(path) + if error != OK: + push_error("failed to save %s (%d)" % [path, error]) + else: + print("saved ", path) + + +func _fire() -> Image: + var image := _new_image() + # A low ember bed across the cell base ties the tongues together. + for x in SIZE: + var bed := 3 + _rng.randi_range(0, 2) + for y in range(SIZE - bed, SIZE): + _put(image, x, y, _flame_color(0.2), 0.75) + # Overlapping tongues of varying height; each is thick at the base and + # tapers and sways toward a cooling red tip. + var count := 7 + for index in count: + var center := (float(index) + 0.5) * (float(SIZE) / float(count)) + _rng.randf_range(-2.5, 2.5) + var height := _rng.randf_range(34.0, 58.0) + var sway := _rng.randf_range(-3.5, 3.5) + var half_base := _rng.randf_range(4.0, 6.5) + _tongue(image, center, height, sway, half_base) + # A few sparks breaking off above the flames. + for index in 6: + var x := _rng.randi_range(3, SIZE - 4) + var y := _rng.randi_range(2, 20) + _disc(image, x, y, _rng.randf_range(0.7, 1.6), Color(1.0, 0.66, 0.18, 0.9)) + return image + + +## Soft billowy grey puff for the smoke overlay, masked to a round falloff. +func _smoke() -> Image: + var image := _new_image() + var base := Color(0.16, 0.16, 0.18) + for index in 30: + var x := SIZE / 2 + _rng.randi_range(-15, 15) + var y := SIZE / 2 + _rng.randi_range(-15, 15) + _soft_disc(image, x, y, _rng.randf_range(6.0, 16.0), _rng.randf_range(0.12, 0.34)) + for y in SIZE: + for x in SIZE: + var distance := Vector2(float(x), float(y)).distance_to(Vector2(SIZE * 0.5, SIZE * 0.5)) / (SIZE * 0.5) + var mask := clampf(1.0 - distance, 0.0, 1.0) + var pixel := image.get_pixel(x, y) + image.set_pixel(x, y, Color(base.r, base.g, base.b, pixel.a * mask)) + return image + + +## Accumulates a soft additive blob so overlapping puffs build a billowy edge. +func _soft_disc(image: Image, center_x: int, center_y: int, radius: float, strength: float) -> void: + var limit := int(ceilf(radius)) + for dy in range(-limit, limit + 1): + for dx in range(-limit, limit + 1): + var distance := sqrt(float(dx * dx + dy * dy)) + if distance > radius: + continue + var x := center_x + dx + var y := center_y + dy + if x < 0 or x >= SIZE or y < 0 or y >= SIZE: + continue + var falloff := 1.0 - distance / radius + var alpha := clampf(image.get_pixel(x, y).a + strength * falloff, 0.0, 1.0) + image.set_pixel(x, y, Color(1, 1, 1, alpha)) + + +## Warm yellow base cooling through orange into a red tip. +func _flame_color(t: float) -> Color: + if t < 0.42: + return Color(1.0, 0.74, 0.2).lerp(Color(1.0, 0.44, 0.06), t / 0.42) + return Color(1.0, 0.44, 0.06).lerp(Color(0.6, 0.08, 0.03), (t - 0.42) / 0.58) + + +func _tongue(image: Image, center: float, height: float, sway: float, half_base: float) -> void: + var pixels := int(height) + for step in pixels: + var t := float(step) / float(maxi(pixels - 1, 1)) + var y := SIZE - 1 - step + var x := center + sway * t + sin(t * 3.4) * 1.6 + var half := maxf(0.4, half_base * (1.0 - t * t)) + var alpha := 1.0 + if t > 0.82: + alpha = clampf((1.0 - t) / 0.18, 0.0, 1.0) + for offset in range(-int(ceilf(half)), int(ceilf(half)) + 1): + if absf(float(offset)) > half: + continue + # Erode the outer edges a little so the silhouette is ragged. + if absf(float(offset)) > half - 1.0 and _rng.randf() < 0.35: + continue + _put(image, int(round(x)) + offset, y, _flame_color(t), alpha) + + +func _put(image: Image, x: int, y: int, color: Color, alpha: float) -> void: + if x < 0 or x >= SIZE or y < 0 or y >= SIZE: + return + var existing := image.get_pixel(x, y) + if existing.a > alpha: + return + image.set_pixel(x, y, Color(color.r, color.g, color.b, alpha)) + + +func _disc(image: Image, center_x: int, center_y: int, radius: float, color: Color) -> void: + var limit := int(ceilf(radius)) + for dy in range(-limit, limit + 1): + for dx in range(-limit, limit + 1): + if sqrt(float(dx * dx + dy * dy)) > radius: + continue + _put(image, center_x + dx, center_y + dy, color, color.a) diff --git a/tools/gen_fire_texture.gd.uid b/tools/gen_fire_texture.gd.uid new file mode 100644 index 0000000..4481d20 --- /dev/null +++ b/tools/gen_fire_texture.gd.uid @@ -0,0 +1 @@ +uid://dpryomi0fq181 diff --git a/world/block.gdshader b/world/block.gdshader index f682192..57538eb 100644 --- a/world/block.gdshader +++ b/world/block.gdshader @@ -11,6 +11,9 @@ uniform float min_light : hint_range(0.0, 1.0) = 0.05; // shadow. Non-leaf cutout blocks (glass, cross foliage) are unaffected. Driven // by the Advanced Graphics -> Shadows "Solid Leaf Shadows" setting. uniform float solid_leaf_shadows : hint_range(0.0, 1.0) = 1.0; +// Texture-array layer of the fire sheet (-1 disables). That layer gets a +// scrolling, wobbling UV so the flame reads as animated without extra frames. +uniform float fire_layer : hint_range(-1.0, 255.0) = -1.0; // Camera-submerged strength (0 above water), driven by DayNightCycle. Caustics // are projected from the water surface onto sky-lit upward faces. global uniform float underwater_caustics; @@ -44,10 +47,21 @@ void vertex() { } void fragment() { - vec4 tex = texture(albedo_texture, vec3(UV, texture_layer)); + vec2 uv = UV; + float fire = abs(texture_layer - fire_layer) < 0.5 ? 1.0 : 0.0; + float flicker = 1.0; + if (fire > 0.5) { + // Grow and waver from the base without wrapping the sheet, so the flame + // stays anchored to the bottom of the cell and only licks upward. + float t = TIME * 2.4; + uv.y = clamp(UV.y * (1.0 + 0.08 * sin(t) + 0.04 * sin(t * 1.7 + 1.3)), 0.0, 1.0); + uv.x = clamp(UV.x + 0.05 * sin(UV.y * 4.0 + t * 1.4) * (0.3 + UV.y), 0.0, 1.0); + flicker = 0.85 + 0.15 * (0.5 + 0.5 * sin(TIME * 5.0 + UV.y * 9.0)); + } + vec4 tex = texture(albedo_texture, vec3(uv, texture_layer)); vec3 block = light_data.rgb; vec3 light = max(block + vec3(light_data.a), vec3(min_light)); - ALBEDO = tex.rgb * COLOR.rgb * light; + ALBEDO = tex.rgb * COLOR.rgb * light * flicker; if (underwater_caustics > 0.001) { vec2 p = world_position.xz * 0.55; float wave = sin(p.x + TIME * 0.8) + sin(p.y * 0.9 - TIME * 0.6) @@ -65,5 +79,5 @@ void fragment() { ROUGHNESS = 1.0; METALLIC = 0.0; SPECULAR = 0.05; - EMISSION = tex.rgb * COLOR.rgb * block * block_emission; + EMISSION = tex.rgb * COLOR.rgb * block * block_emission * flicker; } diff --git a/world/block_registry.gd b/world/block_registry.gd index 0b91322..6ed3527 100644 --- a/world/block_registry.gd +++ b/world/block_registry.gd @@ -71,6 +71,7 @@ const BLOCK_CALCITE := 66 const BLOCK_GEODE_SHELL := 67 const BLOCK_AMETHYST := 68 const BLOCK_CRYSTAL_BUD := 69 +const BLOCK_FIRE := 70 const FLAG_OPAQUE := 1 const FLAG_CUTOUT := 2 @@ -79,6 +80,10 @@ const FLAG_LEAVES := 8 const FLAG_EMISSIVE := 16 const FLAG_CROSS := 32 const FLAG_TINTED := 64 +## Burnable fuel for the fire simulation. Logs, leaves, and dry plants carry it; +## the flag is independent of opacity so burning a tree can cascade through +## both its trunk and its canopy. +const FLAG_FLAMMABLE := 128 const BLOCK_DEFS := [ [0, "AIR", "", "", "", 0], @@ -86,8 +91,8 @@ const BLOCK_DEFS := [ [2, "DIRT", "dirt.png", "dirt.png", "dirt.png", FLAG_OPAQUE], [3, "STONE", "stone.png", "stone.png", "stone.png", FLAG_OPAQUE], [4, "COBBLESTONE", "cobblestone.png", "cobblestone.png", "cobblestone.png", FLAG_OPAQUE], - [5, "OAK LOG", "wood_top.png", "wood_side.png", "wood_top.png", FLAG_OPAQUE], - [6, "OAK LEAVES", "leaves.png", "leaves.png", "leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED], + [5, "OAK LOG", "wood_top.png", "wood_side.png", "wood_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], + [6, "OAK LEAVES", "leaves.png", "leaves.png", "leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], [7, "SAND", "sand.png", "sand.png", "sand.png", FLAG_OPAQUE], [8, "GLASS", "glass.png", "glass.png", "glass.png", FLAG_CUTOUT], [9, "GLOWSTONE", "glowstone.png", "glowstone.png", "glowstone.png", FLAG_OPAQUE | FLAG_EMISSIVE], @@ -102,10 +107,10 @@ const BLOCK_DEFS := [ [18, "MUD", "mud.png", "mud.png", "mud.png", FLAG_OPAQUE], [19, "RED SAND", "red_sand.png", "red_sand.png", "red_sand.png", FLAG_OPAQUE], [20, "CACTUS", "cactus_top.png", "cactus_side.png", "cactus_top.png", FLAG_OPAQUE], - [21, "SPRUCE LOG", "spruce_log_top.png", "spruce_log_side.png", "spruce_log_top.png", FLAG_OPAQUE], - [22, "SPRUCE LEAVES", "spruce_leaves.png", "spruce_leaves.png", "spruce_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED], - [23, "BIRCH LOG", "birch_log_top.png", "birch_log_side.png", "birch_log_top.png", FLAG_OPAQUE], - [24, "BIRCH LEAVES", "birch_leaves.png", "birch_leaves.png", "birch_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED], + [21, "SPRUCE LOG", "spruce_log_top.png", "spruce_log_side.png", "spruce_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], + [22, "SPRUCE LEAVES", "spruce_leaves.png", "spruce_leaves.png", "spruce_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], + [23, "BIRCH LOG", "birch_log_top.png", "birch_log_side.png", "birch_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], + [24, "BIRCH LEAVES", "birch_leaves.png", "birch_leaves.png", "birch_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], [25, "TERRACOTTA", "terracotta.png", "terracotta.png", "terracotta.png", FLAG_OPAQUE], [26, "MYCELIUM", "mycelium_top.png", "mycelium_side.png", "dirt.png", FLAG_OPAQUE], [27, "TORCH", "torch.png", "torch.png", "torch.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE], @@ -116,23 +121,23 @@ const BLOCK_DEFS := [ [32, "WATER FLOW 3", "water.png", "water.png", "water.png", 0], [33, "WATER FLOW 2", "water.png", "water.png", "water.png", 0], [34, "WATER FLOW 1", "water.png", "water.png", "water.png", 0], - [35, "TALL GRASS", "tall_grass.png", "tall_grass.png", "tall_grass.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED], - [36, "YELLOW FLOWER", "flower_yellow.png", "flower_yellow.png", "flower_yellow.png", FLAG_CUTOUT | FLAG_CROSS], - [37, "RED FLOWER", "flower_red.png", "flower_red.png", "flower_red.png", FLAG_CUTOUT | FLAG_CROSS], - [38, "DEAD BUSH", "dead_bush.png", "dead_bush.png", "dead_bush.png", FLAG_CUTOUT | FLAG_CROSS], - [39, "BAMBOO", "bamboo.png", "bamboo.png", "bamboo.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED], - [40, "VINE", "vine.png", "vine.png", "vine.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED], - [41, "ACACIA LOG", "acacia_log_top.png", "acacia_log_side.png", "acacia_log_top.png", FLAG_OPAQUE], - [42, "ACACIA LEAVES", "acacia_leaves.png", "acacia_leaves.png", "acacia_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED], - [43, "JUNGLE LOG", "jungle_log_top.png", "jungle_log_side.png", "jungle_log_top.png", FLAG_OPAQUE], - [44, "JUNGLE LEAVES", "jungle_leaves.png", "jungle_leaves.png", "jungle_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED], - [45, "MANGROVE LOG", "mangrove_log_top.png", "mangrove_log_side.png", "mangrove_log_top.png", FLAG_OPAQUE], - [46, "MANGROVE LEAVES", "mangrove_leaves.png", "mangrove_leaves.png", "mangrove_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED], - [47, "MANGROVE ROOTS", "mangrove_roots.png", "mangrove_roots.png", "mangrove_roots.png", FLAG_OPAQUE], + [35, "TALL GRASS", "tall_grass.png", "tall_grass.png", "tall_grass.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE], + [36, "YELLOW FLOWER", "flower_yellow.png", "flower_yellow.png", "flower_yellow.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], + [37, "RED FLOWER", "flower_red.png", "flower_red.png", "flower_red.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], + [38, "DEAD BUSH", "dead_bush.png", "dead_bush.png", "dead_bush.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], + [39, "BAMBOO", "bamboo.png", "bamboo.png", "bamboo.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE], + [40, "VINE", "vine.png", "vine.png", "vine.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_TINTED | FLAG_FLAMMABLE], + [41, "ACACIA LOG", "acacia_log_top.png", "acacia_log_side.png", "acacia_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], + [42, "ACACIA LEAVES", "acacia_leaves.png", "acacia_leaves.png", "acacia_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], + [43, "JUNGLE LOG", "jungle_log_top.png", "jungle_log_side.png", "jungle_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], + [44, "JUNGLE LEAVES", "jungle_leaves.png", "jungle_leaves.png", "jungle_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], + [45, "MANGROVE LOG", "mangrove_log_top.png", "mangrove_log_side.png", "mangrove_log_top.png", FLAG_OPAQUE | FLAG_FLAMMABLE], + [46, "MANGROVE LEAVES", "mangrove_leaves.png", "mangrove_leaves.png", "mangrove_leaves.png", FLAG_CUTOUT | FLAG_LEAVES | FLAG_TINTED | FLAG_FLAMMABLE], + [47, "MANGROVE ROOTS", "mangrove_roots.png", "mangrove_roots.png", "mangrove_roots.png", FLAG_OPAQUE | FLAG_FLAMMABLE], [48, "LAVA", "lava.png", "lava.png", "lava.png", FLAG_OPAQUE | FLAG_EMISSIVE], - [49, "BROWN MUSHROOM", "brown_mushroom_block.png", "brown_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS], - [50, "RED MUSHROOM", "red_mushroom_block.png", "red_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS], - [51, "MELON", "melon_top.png", "melon_side.png", "melon_side.png", FLAG_OPAQUE], + [49, "BROWN MUSHROOM", "brown_mushroom_block.png", "brown_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], + [50, "RED MUSHROOM", "red_mushroom_block.png", "red_mushroom_block.png", "mushroom_stem.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_FLAMMABLE], + [51, "MELON", "melon_top.png", "melon_side.png", "melon_side.png", FLAG_OPAQUE | FLAG_FLAMMABLE], [52, "CORAL SUBSTRATE", "coral_substrate.png", "coral_substrate.png", "coral_substrate.png", FLAG_OPAQUE], [53, "SEAGRASS", "seagrass.png", "seagrass.png", "seagrass.png", FLAG_CUTOUT | FLAG_CROSS], [54, "KELP", "kelp.png", "kelp.png", "kelp.png", FLAG_CUTOUT | FLAG_CROSS], @@ -151,6 +156,7 @@ const BLOCK_DEFS := [ [67, "GEODE SHELL", "geode_shell.png", "geode_shell.png", "geode_shell.png", FLAG_OPAQUE], [68, "AMETHYST", "amethyst.png", "amethyst.png", "amethyst.png", FLAG_OPAQUE | FLAG_EMISSIVE], [69, "CRYSTAL BUD", "crystal_bud.png", "crystal_bud.png", "crystal_bud.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE], + [70, "FIRE", "fire.png", "fire.png", "fire.png", FLAG_CUTOUT | FLAG_CROSS | FLAG_EMISSIVE], ] const TEXTURE_ROOT := "res://assets/placeholders/zigcraft/default/" @@ -159,6 +165,7 @@ const TEXTURE_ROOT := "res://assets/placeholders/zigcraft/default/" 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 EXTRA_TEXTURE_PATHS := { "coral_substrate.png": UNDERWATER_TEXTURE_ROOT + "coral_substrate.png", "seagrass.png": UNDERWATER_TEXTURE_ROOT + "seagrass.png", @@ -182,6 +189,7 @@ const EXTRA_TEXTURE_PATHS := { "geode_shell.png": CAVE_TEXTURE_ROOT + "geode_shell.png", "amethyst.png": CAVE_TEXTURE_ROOT + "amethyst.png", "crystal_bud.png": CAVE_TEXTURE_ROOT + "crystal_bud.png", + "fire.png": FIRE_TEXTURE_ROOT + "fire.png", } const WATER_TEXTURE_PATH := TEXTURE_ROOT + "water.png" const WATER_SHADER_PATH := "res://assets/placeholders/zigcraft/water.gdshader" @@ -202,6 +210,7 @@ const EMISSIVE_COLORS := { BLOCK_SCULK: Color(0.08, 0.34, 0.42), BLOCK_AMETHYST: Color(0.48, 0.24, 0.72), BLOCK_CRYSTAL_BUD: Color(0.72, 0.42, 1.0), + BLOCK_FIRE: Color(1.0, 0.55, 0.14), } const ATTENUATION_LEAVES := 3 # Light drops 3 per water block so sky light stops fading in within ~5 blocks of @@ -233,6 +242,7 @@ var _layer_top: PackedInt32Array = PackedInt32Array() var _layer_side: PackedInt32Array = PackedInt32Array() var _layer_bottom: PackedInt32Array = PackedInt32Array() var _names: PackedStringArray = PackedStringArray() +var _fire_layer := -1 func _init() -> void: @@ -259,6 +269,11 @@ func has_flag(block_id: int, flag: int) -> bool: return block_id >= 0 and block_id < _flags.size() and (_flags[block_id] & flag) != 0 +## Fuel for the fire simulation: logs, leaves, and dry plants. +func is_flammable(block_id: int) -> bool: + return has_flag(block_id, FLAG_FLAMMABLE) + + func is_water_id(block_id: int) -> bool: return block_id == BLOCK_WATER or (block_id >= BLOCK_WATER_FLOW_7 and block_id <= BLOCK_WATER_FLOW_1) @@ -337,6 +352,7 @@ func _build_texture_array() -> Dictionary: tile_lookup[texture_name] = index texture_array = Texture2DArray.new() texture_array.create_from_images(images) + _fire_layer = tile_lookup.get("fire.png", -1) _build_material(texture_array, images[0] if not images.is_empty() else null) return tile_lookup @@ -357,6 +373,7 @@ func _build_material(array_texture: Texture2DArray, fallback_image: Image) -> vo var shader_material := ShaderMaterial.new() shader_material.shader = shader shader_material.set_shader_parameter("albedo_texture", array_texture) + shader_material.set_shader_parameter("fire_layer", float(_fire_layer)) material = shader_material return var fallback_texture: Texture2D = null diff --git a/world/fire_overlay.gd b/world/fire_overlay.gd new file mode 100644 index 0000000..33b914c --- /dev/null +++ b/world/fire_overlay.gd @@ -0,0 +1,127 @@ +class_name FireOverlay +extends Node3D + +## Draws the "block on fire" state. The burning block keeps its own mesh; this +## node clusters flame and smoke billboards on it so the block reads as alight +## without ever replacing it. One MultiMesh per effect keeps the whole fire to +## two draw calls, and the shaders animate from per-instance phase/progress, so +## `refresh()` only runs on the 4 Hz fire tick. + +const MAX_CELLS := 384 +const FLAMES_PER_CELL := 4 +const SMOKES_PER_CELL := 2 +## Flames are drawn larger than a block so they read around its silhouette +## instead of being buried inside the solid faces. +const FLAME_SCALE_MIN := 1.45 +const FLAME_SCALE_RANGE := 0.75 +const FLAME_SHADER := preload("res://world/fire_overlay.gdshader") +const SMOKE_SHADER := preload("res://world/smoke_overlay.gdshader") +const FLAME_TEXTURE := preload("res://assets/placeholders/fire/fire.png") +const SMOKE_TEXTURE := preload("res://assets/placeholders/fire/smoke.png") + +var _flame_multimesh: MultiMesh +var _smoke_multimesh: MultiMesh + + +func _ready() -> void: + _flame_multimesh = _make_layer(_flame_material(), MAX_CELLS * FLAMES_PER_CELL) + _smoke_multimesh = _make_layer(_smoke_material(), MAX_CELLS * SMOKES_PER_CELL) + add_child(_make_instance("Flames", _flame_multimesh)) + add_child(_make_instance("Smoke", _smoke_multimesh)) + + +func _flame_material() -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = FLAME_SHADER + material.set_shader_parameter("flame_texture", FLAME_TEXTURE) + return material + + +func _smoke_material() -> ShaderMaterial: + var material := ShaderMaterial.new() + material.shader = SMOKE_SHADER + material.set_shader_parameter("smoke_texture", SMOKE_TEXTURE) + return material + + +func _make_layer(material: Material, capacity: int) -> MultiMesh: + var quad := QuadMesh.new() + quad.size = Vector2.ONE + quad.material = material + var multimesh := MultiMesh.new() + multimesh.transform_format = MultiMesh.TRANSFORM_3D + multimesh.use_custom_data = true + multimesh.mesh = quad + multimesh.instance_count = capacity + multimesh.visible_instance_count = 0 + return multimesh + + +func _make_instance(layer_name: String, multimesh: MultiMesh) -> MultiMeshInstance3D: + var instance := MultiMeshInstance3D.new() + instance.name = layer_name + instance.multimesh = multimesh + instance.cast_shadow = GeometryInstance3D.SHADOW_CASTING_SETTING_OFF + return instance + + +## Rebuilds the overlay from `cells` (block position -> remaining burn ticks). +## Instance placement is derived from a stable per-position hash, so flames do +## not jitter between the 4 Hz refreshes; the animation happens in the shader. +func refresh(cells: Dictionary, burn_ticks: int) -> void: + if _flame_multimesh == null: + return + var flame_count := 0 + var smoke_count := 0 + var cells_left := MAX_CELLS + for key in cells: + if cells_left <= 0: + break + cells_left -= 1 + var position: Vector3i = key + var center := Vector3(position) + Vector3(0.5, 0.5, 0.5) + var progress := 1.0 - float(cells[key]) / float(maxi(burn_ticks, 1)) + for index in FLAMES_PER_CELL: + if flame_count >= _flame_multimesh.instance_count: + break + var angle := _cell_random(position, index * 3) * TAU + var scale := FLAME_SCALE_MIN + _cell_random(position, index * 3 + 1) * FLAME_SCALE_RANGE + var offset := Vector3( + (_cell_random(position, index * 5 + 2) - 0.5) * 0.5, + (_cell_random(position, index * 7 + 3) - 0.4) * 0.5, + (_cell_random(position, index * 11 + 4) - 0.5) * 0.5) + _flame_multimesh.set_instance_transform(flame_count, + _billboard_transform(center + offset, angle, scale)) + _flame_multimesh.set_instance_custom_data(flame_count, + Color(_cell_random(position, 13), progress, 0.0, 0.0)) + flame_count += 1 + for index in SMOKES_PER_CELL: + if smoke_count >= _smoke_multimesh.instance_count: + break + var angle := _cell_random(position, 40 + index) * TAU + var scale := 1.2 + _cell_random(position, 50 + index) * 0.7 + var offset := Vector3( + (_cell_random(position, 60 + index) - 0.5) * 0.5, + 0.7, + (_cell_random(position, 70 + index) - 0.5) * 0.5) + _smoke_multimesh.set_instance_transform(smoke_count, + _billboard_transform(center + offset, angle, scale)) + _smoke_multimesh.set_instance_custom_data(smoke_count, + Color(_cell_random(position, 80 + index), progress, 0.0, 0.0)) + smoke_count += 1 + _flame_multimesh.visible_instance_count = flame_count + _smoke_multimesh.visible_instance_count = smoke_count + + +## `QuadMesh` already faces +Z (vertical), so a Y spin is all that is needed to +## stack several quads into a flame cluster. +func _billboard_transform(origin: Vector3, angle: float, scale: float) -> Transform3D: + var basis := Basis(Vector3.UP, angle).scaled(Vector3(scale, scale, scale)) + return Transform3D(basis, origin) + + +func _cell_random(position: Vector3i, salt: int) -> float: + var n := position.x * 73856093 ^ position.y * 19349663 ^ position.z * 83492791 ^ (salt * 2654435761) + n = (n ^ (n >> 13)) * 1274126177 + n = n ^ (n >> 16) + return float(absi(n) % 10000) / 10000.0 diff --git a/world/fire_overlay.gd.uid b/world/fire_overlay.gd.uid new file mode 100644 index 0000000..e10499a --- /dev/null +++ b/world/fire_overlay.gd.uid @@ -0,0 +1 @@ +uid://jem8b1mkkudp diff --git a/world/fire_overlay.gdshader b/world/fire_overlay.gdshader new file mode 100644 index 0000000..40b93df --- /dev/null +++ b/world/fire_overlay.gdshader @@ -0,0 +1,32 @@ +shader_type spatial; +render_mode unshaded, blend_add, cull_disabled, depth_draw_never, shadows_disabled; + +// Flame billboards drawn over a burning block. The block mesh itself is +// untouched; these quads are positioned/oriented per instance by +// `world/fire_overlay.gd` and animated here so the CPU only rebuilds the +// instance list on the fire tick. +uniform sampler2D flame_texture : source_color, filter_linear_mipmap, repeat_disable; +uniform float emission_strength : hint_range(0.0, 4.0) = 2.0; + +// INSTANCE_CUSTOM is a vertex-only built-in, so carry it to the fragment. +varying vec4 instance_data; + +void vertex() { + instance_data = INSTANCE_CUSTOM; + float phase = instance_data.x; + // The quad is vertical (local Y up); stretch and wobble the flame upward + // from its base. No UV wrapping, so the sheet never jumps. + float t = TIME * 3.2 + phase * 6.2831; + VERTEX.y *= 1.0 + 0.14 * sin(t) + 0.06 * sin(t * 1.7 + 1.1); + VERTEX.x += 0.06 * sin(t * 1.3 + VERTEX.y * 4.0) * clamp(VERTEX.y + 0.5, 0.0, 1.0); +} + +void fragment() { + float phase = instance_data.x; + float progress = instance_data.y; + vec4 tex = texture(flame_texture, UV); + float flicker = 0.85 + 0.25 * sin(TIME * 9.0 + phase * 20.0); + float fade = smoothstep(0.0, 0.12, progress) * (1.0 - smoothstep(0.72, 1.05, progress)); + ALBEDO = tex.rgb * emission_strength * flicker; + ALPHA = tex.a * fade; +} diff --git a/world/fire_overlay.gdshader.uid b/world/fire_overlay.gdshader.uid new file mode 100644 index 0000000..3751a17 --- /dev/null +++ b/world/fire_overlay.gdshader.uid @@ -0,0 +1 @@ +uid://btnk7jtjfgn52 diff --git a/world/smoke_overlay.gdshader b/world/smoke_overlay.gdshader new file mode 100644 index 0000000..a2dd1ee --- /dev/null +++ b/world/smoke_overlay.gdshader @@ -0,0 +1,29 @@ +shader_type spatial; +render_mode unshaded, blend_mix, cull_disabled, depth_draw_never, shadows_disabled; + +// Smoke puffs that rise off a burning block for the duration of its burn. Like +// the flame overlay, this is animated entirely in the shader and only needs an +// instance list refresh on the fire tick. +uniform sampler2D smoke_texture : source_color, filter_linear_mipmap, repeat_disable; +uniform vec3 smoke_color : source_color = vec3(0.17, 0.17, 0.19); + +// INSTANCE_CUSTOM is a vertex-only built-in, so carry it to the fragment. +varying vec4 instance_data; + +void vertex() { + instance_data = INSTANCE_CUSTOM; + float phase = instance_data.x; + float progress = instance_data.y; + // The quad is vertical (local Y up); rise, drift and expand as it burns. + float t = TIME * 0.9 + phase * 6.2831; + VERTEX.y += progress * 1.3; + VERTEX.x += 0.28 * sin(t) * progress; + VERTEX.xy *= 0.65 + progress * 1.5; +} + +void fragment() { + float progress = instance_data.y; + vec4 tex = texture(smoke_texture, UV); + ALBEDO = smoke_color; + ALPHA = tex.a * sin(clamp(progress, 0.0, 1.0) * PI) * 0.55; +} diff --git a/world/smoke_overlay.gdshader.uid b/world/smoke_overlay.gdshader.uid new file mode 100644 index 0000000..970089d --- /dev/null +++ b/world/smoke_overlay.gdshader.uid @@ -0,0 +1 @@ +uid://cip16w0vbtwfi diff --git a/world/voxel_world.gd b/world/voxel_world.gd index be32921..48fb8c5 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -27,6 +27,27 @@ const WATER_TICK_INTERVAL := 0.25 const WATER_CELLS_PER_TICK := 1024 const TNT_BLAST_RADIUS := 5 const NUKE_BLAST_RADIUS := 18 +## Fire advances in discrete steps like water. A flammable block is never +## replaced: it enters a "burning" state (see `_burning`) for `FUEL_BURN_TICKS` +## while `FireOverlay` animates flames and smoke over its intact texture, then +## the block is destroyed. Burning blocks crawl into flammable neighbours one at +## a time, paced by `FIRE_SPREAD_PER_TICK`/`FIRE_SPREAD_PERIOD_TICKS` globally +## and a per-cell `FIRE_SPREAD_INTERVAL_TICKS`/`FIRE_IGNITION_DELAY_TICKS` +## cooldown, so a tree burns gradually. `BLOCK_FIRE` is only the standalone +## flame the flint-and-steel leaves on a non-flammable face. +const FIRE_TICK_INTERVAL := 0.25 +const FIRE_CELLS_PER_TICK := 512 +const FIRE_LIFETIME_TICKS := 10 +const FUEL_BURN_TICKS := 16 +const FIRE_SPREAD_PER_TICK := 1 +const FIRE_IGNITION_DELAY_TICKS := 4 +const FIRE_SPREAD_PERIOD_TICKS := 2 +const FIRE_SPREAD_INTERVAL_TICKS := 2 +const FIRE_OFFSETS: Array[Vector3i] = [ + Vector3i(1, 0, 0), Vector3i(-1, 0, 0), + Vector3i(0, 1, 0), Vector3i(0, -1, 0), + Vector3i(0, 0, 1), Vector3i(0, 0, -1), +] const WATER_NEIGHBOR_OFFSETS := [ Vector3i.ZERO, Vector3i(1, 0, 0), Vector3i(-1, 0, 0), @@ -61,6 +82,17 @@ var _water_queue: Array[Vector3i] = [] var _water_queued: Dictionary = {} var _water_head := 0 var _water_accum := 0.0 +var _fire_queue: Array[Vector3i] = [] +var _fire_queued: Dictionary = {} +var _fire_accum := 0.0 +var _fire_life: Dictionary = {} +var _fire_spread_cooldown: Dictionary = {} +var _fire_spread_budget := 0 +var _fire_tick_count := 0 +var _burning: Dictionary = {} +var _burning_queue: Array[Vector3i] = [] +var _burning_queued: Dictionary = {} +var _fire_overlay: FireOverlay var _worldgen_revision := 0 var _max_active_jobs := MIN_ACTIVE_JOBS var _full_jobs_measured := 0 @@ -124,6 +156,9 @@ func _ready() -> void: _generator = TerrainGenerator.new() _mesher = ChunkMesher.new(_blocks) _max_active_jobs = clampi(OS.get_processor_count() / 2, MIN_ACTIVE_JOBS, MAX_ACTIVE_JOBS) + _fire_overlay = FireOverlay.new() + _fire_overlay.name = "FireOverlay" + add_child(_fire_overlay) func _process(delta: float) -> void: @@ -135,6 +170,10 @@ func _process(delta: float) -> void: if _water_accum >= WATER_TICK_INTERVAL: _water_accum = 0.0 _water_tick() + _fire_accum += delta + if _fire_accum >= FIRE_TICK_INTERVAL: + _fire_accum = 0.0 + _fire_tick() func _exit_tree() -> void: @@ -657,6 +696,8 @@ func _commit_chunk(pos: Vector2i, res: ChunkMesher.MeshResult, lod: bool) -> voi chunk.lod_water_level = res.lod_water_level chunk.max_y = res.max_y chunk.mask = res.mask + if not lod: + _seed_fire_edits(pos) chunk.mesh.mesh = ChunkMesher.arrays_to_mesh(res.verts, res.normals, res.uvs, res.colors, res.indices, _blocks.material, res.light, res.layers) chunk.water.mesh = ChunkMesher.arrays_to_mesh(res.water_verts, res.water_normals, res.water_uvs, res.water_colors, res.water_indices, _blocks.water_material, res.water_light) if not lod and _within_collision_range(pos) and not res.collision.is_empty(): @@ -1008,6 +1049,281 @@ func carve_sphere(center: Vector3i, radius: int) -> int: for position in interior_water: _seed_water(position) return removed + + +## Flint and steel either detonates an explosive block or lights fire on the +## face the player clicked. `normal` is the face normal reported by the player's +## voxel raycast, so the fire lands in the air cell in front of the target. +func use_flint_and_steel(block_position: Vector3i, normal: Vector3i) -> Dictionary: + var block_id := get_block_world(block_position) + if block_id == BlockRegistry.BLOCK_TNT or block_id == BlockRegistry.BLOCK_NUKE: + return trigger_explosive(block_position) + # A flammable block catches fire itself; anything else lights the air cell in + # front of the clicked face (so flint and steel can still start a ground fire). + if _blocks.is_flammable(block_id): + if not ignite_fire(block_position): + return {} + elif normal != Vector3i.ZERO and ignite_fire(block_position + normal): + pass + else: + return {} + return {"name": "FIRE", "radius": 0, "removed": 0, "ignited": 1} + + +## Public ignition entry point. A flammable target starts burning in place (its +## block stays put; `FireOverlay` draws the flames/smoke); an air target gets a +## standalone `BLOCK_FIRE` flame. Burning state is transient, so the overlay is +## refreshed immediately while the world tick keeps it moving. +func ignite_fire(block_position: Vector3i, life := FIRE_LIFETIME_TICKS) -> bool: + var changed_chunks: Dictionary = {} + var ignited := false + if _blocks.is_flammable(get_block_world(block_position)): + ignited = _start_burning(block_position, changed_chunks) + else: + ignited = _ignite_fire_cell(block_position, life, changed_chunks) + if not ignited: + return false + if not changed_chunks.is_empty(): + _flush_fire_changes(changed_chunks) + if _fire_overlay != null: + _fire_overlay.refresh(_burning, FUEL_BURN_TICKS) + return true + + +## Places a standalone flame in an air cell. Flammable blocks never take this +## path; they burn in place through `_start_burning()`. +func _ignite_fire_cell(block_position: Vector3i, life: int, changed_chunks: Dictionary) -> bool: + var chunk := _loaded_chunk_for(block_position) + if chunk == null or chunk.lod: + return false + var existing := get_block_world(block_position) + if existing == BlockRegistry.BLOCK_FIRE: + _fire_life[block_position] = maxi(int(_fire_life.get(block_position, 0)), life) + _queue_fire(block_position) + return false + if existing != BlockRegistry.BLOCK_AIR: + return false + chunk.data[_data_index(block_position)] = BlockRegistry.BLOCK_FIRE + _record_edit(block_position, BlockRegistry.BLOCK_FIRE) + _fire_life[block_position] = life + _fire_spread_cooldown[block_position] = FIRE_IGNITION_DELAY_TICKS + _queue_fire(block_position) + changed_chunks[_chunk_for_block(block_position)] = true + return true + + +## Enters the burning state without touching the block itself: it keeps its +## texture and collision while `FireOverlay` adds the flames and smoke. The +## block is only removed when the burn timer expires. `changed_chunks` stays +## empty because no voxel changed. +func _start_burning(block_position: Vector3i, _changed_chunks: Dictionary) -> bool: + if not _blocks.is_flammable(get_block_world(block_position)): + return false + if _burning.has(block_position): + return false + _burning[block_position] = FUEL_BURN_TICKS + _fire_spread_cooldown[block_position] = FIRE_IGNITION_DELAY_TICKS + _queue_burning(block_position) + return true + + +func _queue_fire(block_position: Vector3i) -> void: + if block_position.y < 0 or block_position.y >= VoxelDefs.WORLD_HEIGHT: + return + if _fire_queued.has(block_position): + return + _fire_queued[block_position] = true + _fire_queue.append(block_position) + + +func _queue_burning(block_position: Vector3i) -> void: + if block_position.y < 0 or block_position.y >= VoxelDefs.WORLD_HEIGHT: + return + if _burning_queued.has(block_position): + return + _burning_queued[block_position] = true + _burning_queue.append(block_position) + + +## Fire spreads in discrete ticks. Burning blocks keep their block until the +## timer expires and the destruction is recorded through `_record_edit()`, so +## the settled result (air, destroyed fuel, exploded explosives) survives chunk +## regeneration. A global per-tick ignition budget plus per-cell cooldowns keep +## the front creeping instead of the whole tree catching at once. +func _fire_tick() -> void: + var budget := FIRE_CELLS_PER_TICK + var changed_chunks := {} + _fire_spread_budget = FIRE_SPREAD_PER_TICK if _fire_tick_count % FIRE_SPREAD_PERIOD_TICKS == 0 else 0 + _fire_tick_count += 1 + # Snapshot the cells queued before this tick and clear the live queues so a + # newly affected cell waits for the next tick. Without this a single cell + # would re-queue itself and could burn out many times inside one call. + var snapshot := _fire_queue + _fire_queue = [] + _fire_queued.clear() + var index := 0 + while index < snapshot.size() and budget > 0: + var position: Vector3i = snapshot[index] + index += 1 + budget -= 1 + if _loaded_chunk_for(position) == null: + _fire_life.erase(position) + _fire_spread_cooldown.erase(position) + continue + _update_fire_cell(position, changed_chunks) + _requeue_remaining(snapshot, index, _fire_queue, _fire_queued) + budget = FIRE_CELLS_PER_TICK + var burn_snapshot := _burning_queue + _burning_queue = [] + _burning_queued.clear() + index = 0 + while index < burn_snapshot.size() and budget > 0: + var position: Vector3i = burn_snapshot[index] + index += 1 + budget -= 1 + _update_burning_cell(position, changed_chunks) + _requeue_remaining(burn_snapshot, index, _burning_queue, _burning_queued) + _flush_fire_changes(changed_chunks) + if _fire_overlay != null: + _fire_overlay.refresh(_burning, FUEL_BURN_TICKS) + + +func _requeue_remaining(snapshot: Array[Vector3i], start: int, queue: Array[Vector3i], queued: Dictionary) -> void: + for remaining in range(start, snapshot.size()): + var position: Vector3i = snapshot[remaining] + if queued.has(position): + continue + queued[position] = true + queue.append(position) + + +func _update_fire_cell(position: Vector3i, changed_chunks: Dictionary) -> void: + if get_block_world(position) != BlockRegistry.BLOCK_FIRE: + _fire_life.erase(position) + _fire_spread_cooldown.erase(position) + return + var cooldown := _decrement_fire_cooldown(position) + # Catch adjacent fuel on fire and chain-detonate exposed explosives. A blast + # carves and records its own edits, so the fire simply stops here. + var fueled := false + for offset in FIRE_OFFSETS: + var neighbor := position + offset + var neighbor_id := get_block_world(neighbor) + if neighbor_id == BlockRegistry.BLOCK_TNT or neighbor_id == BlockRegistry.BLOCK_NUKE: + trigger_explosive(neighbor) + continue + if not _blocks.is_flammable(neighbor_id): + continue + fueled = true + if _fire_spread_budget <= 0 or cooldown > 0 or _burning.has(neighbor): + continue + if _start_burning(neighbor, changed_chunks): + _fire_spread_budget -= 1 + cooldown = FIRE_SPREAD_INTERVAL_TICKS + _fire_spread_cooldown[position] = cooldown + var life := int(_fire_life.get(position, FIRE_LIFETIME_TICKS)) - 1 + if fueled: + life = FIRE_LIFETIME_TICKS + if life <= 0 or not _fire_supported(position): + _extinguish_fire(position, changed_chunks) + else: + _fire_life[position] = life + _queue_fire(position) + + +func _update_burning_cell(position: Vector3i, changed_chunks: Dictionary) -> void: + if _loaded_chunk_for(position) == null: + _burning.erase(position) + _fire_spread_cooldown.erase(position) + return + # The player broke or replaced the block while it was burning. + if not _blocks.is_flammable(get_block_world(position)): + _burning.erase(position) + _fire_spread_cooldown.erase(position) + return + var cooldown := _decrement_fire_cooldown(position) + for offset in FIRE_OFFSETS: + var neighbor := position + offset + var neighbor_id := get_block_world(neighbor) + if neighbor_id == BlockRegistry.BLOCK_TNT or neighbor_id == BlockRegistry.BLOCK_NUKE: + trigger_explosive(neighbor) + continue + if not _blocks.is_flammable(neighbor_id) or _burning.has(neighbor): + continue + if _fire_spread_budget <= 0: + break + if cooldown > 0: + continue + if _start_burning(neighbor, changed_chunks): + _fire_spread_budget -= 1 + cooldown = FIRE_SPREAD_INTERVAL_TICKS + _fire_spread_cooldown[position] = cooldown + var ticks := int(_burning[position]) - 1 + if ticks <= 0: + _burning.erase(position) + _fire_spread_cooldown.erase(position) + _destroy_burnt_block(position, changed_chunks) + return + _burning[position] = ticks + _queue_burning(position) + + +func _decrement_fire_cooldown(position: Vector3i) -> int: + var cooldown := int(_fire_spread_cooldown.get(position, 0)) + if cooldown > 0: + cooldown -= 1 + _fire_spread_cooldown[position] = cooldown + return cooldown + + +## Fire needs an opaque or flammable neighbor to keep burning; otherwise it +## floats in air and is extinguished. +func _fire_supported(position: Vector3i) -> bool: + for offset in FIRE_OFFSETS: + var neighbor_id := get_block_world(position + offset) + if _blocks.is_opaque(neighbor_id) or _blocks.is_flammable(neighbor_id): + return true + return false + + +func _extinguish_fire(position: Vector3i, changed_chunks: Dictionary) -> void: + _fire_life.erase(position) + _fire_spread_cooldown.erase(position) + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod: + return + if get_block_world(position) != BlockRegistry.BLOCK_FIRE: + return + chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR + _record_edit(position, BlockRegistry.BLOCK_AIR) + changed_chunks[_chunk_for_block(position)] = true + + +## Final state of a burnt block: it crumbles to ash and disappears. This is the +## only voxel change in the burning lifecycle, so it is the one persisted. +func _destroy_burnt_block(position: Vector3i, changed_chunks: Dictionary) -> void: + var chunk := _loaded_chunk_for(position) + if chunk == null or chunk.lod: + return + chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR + _record_edit(position, BlockRegistry.BLOCK_AIR) + changed_chunks[_chunk_for_block(position)] = true + + +## 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: + if changed_chunks.is_empty(): + return + var rebuild_chunks := {} + for chunk_position in changed_chunks: + _chunk_edit_version[chunk_position] = _chunk_edit_version.get(chunk_position, 0) + 1 + rebuild_chunks[chunk_position] = true + _add_loaded_light_ring(rebuild_chunks, chunk_position) + for chunk_position in rebuild_chunks: + _queue_rebuild(chunk_position, true) + + func _record_edit(block_position: Vector3i, block_id: int) -> void: _record_edit_in_chunk(block_position, block_id, _chunk_for_block(block_position)) @@ -1028,6 +1344,21 @@ func _chunk_edits_for(pos: Vector2i) -> Dictionary: return bucket.duplicate() +## A regenerated chunk can bring persisted fire back from `_edited_blocks`. +## Re-arm those cells so a still-burning edit resumes its live simulation +## instead of sitting as an immortal flame after an unload/reload cycle. +func _seed_fire_edits(pos: Vector2i) -> void: + var bucket = _edits_by_chunk.get(pos) + if bucket == null: + return + for key in bucket: + if int(bucket[key]) != BlockRegistry.BLOCK_FIRE: + continue + if not _fire_life.has(key): + _fire_life[key] = FIRE_LIFETIME_TICKS + _queue_fire(key) + + func _seed_water(block_position: Vector3i) -> void: for offset in WATER_NEIGHBOR_OFFSETS: _queue_water(block_position + offset) From 4704e2a2335e50f2e478885557a5ab65fe5aa7cf Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 15 Sep 2026 10:48:36 +0100 Subject: [PATCH 2/3] Address fire review feedback - Refuse to ignite a flooded cell and seed adjacent water when a fire or a burnt block leaves air, matching break_block()/carve_sphere(). - Keep the burning cells nearest the camera when the overlay hits its cap. - Report a submerged/already-burning block instead of the wrong face hint, and treat any non-air, non-water neighbour as fire support. - Bail if a chain blast carved a burning block mid-tick. --- assets/placeholders/fire/README.md | 15 +++++++---- game/main.gd | 3 +++ tools/fire_verify.gd | 40 ++++++++++++++++++++++++++++++ world/fire_overlay.gd | 17 +++++++++---- world/voxel_world.gd | 32 ++++++++++++++++++++---- 5 files changed, 92 insertions(+), 15 deletions(-) diff --git a/assets/placeholders/fire/README.md b/assets/placeholders/fire/README.md index 4c4c1da..02b5bc6 100644 --- a/assets/placeholders/fire/README.md +++ b/assets/placeholders/fire/README.md @@ -1,6 +1,11 @@ -# Fire placeholder texture +# Fire placeholder textures -`fire.png` is a 64 px transparent flame sheet generated specifically for -RedotCraft by `tools/gen_fire_texture.gd`. It contains no third-party artwork. -The block shader scrolls and warps this texture on the fire layer, so one deterministic -sheet is enough for the animated cross-block flame. +`fire.png` (a 64 px transparent flame sheet) and `smoke.png` (a soft grey puff) +are generated specifically for RedotCraft by `tools/gen_fire_texture.gd` from a +fixed RNG seed. They contain no third-party artwork and are safe to redistribute +with the repository. + +`fire.png` is the standalone `BLOCK_FIRE` cross flame, which `world/block.gdshader` +scrolls and warps on the fire layer. `smoke.png` is sampled by +`world/smoke_overlay.gdshader` for the burning-block smoke billboards; the flame +billboards use `fire.png` through `world/fire_overlay.gdshader`. diff --git a/game/main.gd b/game/main.gd index 802aa05..0226738 100644 --- a/game/main.gd +++ b/game/main.gd @@ -728,6 +728,9 @@ func _on_item_used(item_id: int, block_position: Vector3i) -> void: if result.is_empty(): set_status("Flint and steel needs a solid face to light") return + if result.has("blocked"): + set_status("Too wet to light" if result["blocked"] == "wet" else "Already burning") + return if int(result.get("ignited", 0)) > 0: AudioManager.play_block_place(BlockRegistry.BLOCK_FIRE, Vector3(block_position) + Vector3(0.5, 0.5, 0.5)) set_status("Lit fire") diff --git a/tools/fire_verify.gd b/tools/fire_verify.gd index 4be0699..cf19c82 100644 --- a/tools/fire_verify.gd +++ b/tools/fire_verify.gd @@ -13,6 +13,7 @@ func _ready() -> void: _check_decay() _check_explosive_chain() _check_flint_and_steel() + _check_submerged_and_support() _check_persistence_reseed() _check_shader_wiring() if _failures == 0: @@ -135,6 +136,45 @@ func _check_flint_and_steel() -> void: world.free() +func _check_submerged_and_support() -> void: + var world := _make_world(8) + # A log with water directly above is flooded and cannot be lit. + var wet_log := Vector3i(2, 9, 2) + _set_block(world._chunks[Vector2i.ZERO].data, wet_log, BlockRegistry.BLOCK_LOG) + _set_block(world._chunks[Vector2i.ZERO].data, wet_log + Vector3i.UP, BlockRegistry.BLOCK_WATER) + var wet_lit := world.use_flint_and_steel(wet_log, Vector3i.UP) + _expect(wet_lit.get("blocked", "") == "wet", "flint and steel lit a submerged log") + _expect(not world._burning.has(wet_log), "submerged log started burning") + + # Re-clicking a burning block reports already-burning rather than a face hint. + var log_position := Vector3i(4, 9, 4) + _set_block(world._chunks[Vector2i.ZERO].data, log_position, BlockRegistry.BLOCK_LOG) + world.use_flint_and_steel(log_position, Vector3i.UP) + var again := world.use_flint_and_steel(log_position, Vector3i.UP) + _expect(again.get("blocked", "") == "burning", "re-lighting a burning block was not reported") + + # Fire keeps burning next to transparent-but-solid glass (broadened support). + var fire_position := Vector3i(6, 9, 6) + _set_block(world._chunks[Vector2i.ZERO].data, fire_position, BlockRegistry.BLOCK_GLASS) + _set_block(world._chunks[Vector2i.ZERO].data, fire_position + Vector3i.UP, BlockRegistry.BLOCK_AIR) + _expect(world.ignite_fire(fire_position + Vector3i.UP), "failed to place fire on glass") + world._fire_tick() + _expect(world.get_block_world(fire_position + Vector3i.UP) == BlockRegistry.BLOCK_FIRE, + "fire on a glass face was extinguished") + + # Burn-out queues adjacent water so a flooded cell refills, like break_block(). + var burn_position := Vector3i(10, 9, 10) + _set_block(world._chunks[Vector2i.ZERO].data, burn_position, BlockRegistry.BLOCK_LOG) + world._start_burning(burn_position, {}) + world._burning[burn_position] = 1 + world._water_queued.clear() + world._fire_tick() + _expect(world.get_block_world(burn_position) == BlockRegistry.BLOCK_AIR, + "burning block did not crumble away") + _expect(not world._water_queued.is_empty(), "burnt block did not seed adjacent water") + world.free() + + func _check_persistence_reseed() -> void: var world := _make_world(8) var fire_position := Vector3i(2, 9, 2) diff --git a/world/fire_overlay.gd b/world/fire_overlay.gd index 33b914c..a85fe2f 100644 --- a/world/fire_overlay.gd +++ b/world/fire_overlay.gd @@ -71,13 +71,20 @@ func _make_instance(layer_name: String, multimesh: MultiMesh) -> MultiMeshInstan func refresh(cells: Dictionary, burn_ticks: int) -> void: if _flame_multimesh == null: return + var keys := cells.keys() + # Over the cap, keep the cells nearest the camera: those are the active fire + # front the player is watching, not the stale cells that ignited first. + if keys.size() > MAX_CELLS: + var origin := Vector3.ZERO + var camera := get_viewport().get_camera_3d() if is_inside_tree() else null + if camera != null: + origin = camera.global_position + keys.sort_custom(func(a: Vector3i, b: Vector3i) -> bool: + return (Vector3(a) - origin).length_squared() < (Vector3(b) - origin).length_squared()) + keys.resize(MAX_CELLS) var flame_count := 0 var smoke_count := 0 - var cells_left := MAX_CELLS - for key in cells: - if cells_left <= 0: - break - cells_left -= 1 + for key in keys: var position: Vector3i = key var center := Vector3(position) + Vector3(0.5, 0.5, 0.5) var progress := 1.0 - float(cells[key]) / float(maxi(burn_ticks, 1)) diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 48fb8c5..190ffc5 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -1061,6 +1061,10 @@ func use_flint_and_steel(block_position: Vector3i, normal: Vector3i) -> Dictiona # A flammable block catches fire itself; anything else lights the air cell in # front of the clicked face (so flint and steel can still start a ground fire). if _blocks.is_flammable(block_id): + if _is_submerged(block_position): + return {"ignited": 0, "blocked": "wet"} + if _burning.has(block_position): + return {"ignited": 0, "blocked": "burning"} if not ignite_fire(block_position): return {} elif normal != Vector3i.ZERO and ignite_fire(block_position + normal): @@ -1070,6 +1074,12 @@ func use_flint_and_steel(block_position: Vector3i, normal: Vector3i) -> Dictiona return {"name": "FIRE", "radius": 0, "removed": 0, "ignited": 1} +## Fire cannot start in a flooded cell: the cell above counts as submerged, which +## stops flint-and-steel lighting a mangrove log under the waterline. +func _is_submerged(block_position: Vector3i) -> bool: + return _blocks.is_water_id(get_block_world(block_position + Vector3i(0, 1, 0))) + + ## Public ignition entry point. A flammable target starts burning in place (its ## block stays put; `FireOverlay` draws the flames/smoke); an air target gets a ## standalone `BLOCK_FIRE` flame. Burning state is transient, so the overlay is @@ -1119,6 +1129,8 @@ func _ignite_fire_cell(block_position: Vector3i, life: int, changed_chunks: Dict func _start_burning(block_position: Vector3i, _changed_chunks: Dictionary) -> bool: if not _blocks.is_flammable(get_block_world(block_position)): return false + if _is_submerged(block_position): + return false if _burning.has(block_position): return false _burning[block_position] = FUEL_BURN_TICKS @@ -1258,6 +1270,12 @@ func _update_burning_cell(position: Vector3i, changed_chunks: Dictionary) -> voi _fire_spread_budget -= 1 cooldown = FIRE_SPREAD_INTERVAL_TICKS _fire_spread_cooldown[position] = cooldown + # A chain blast in the loop above may have carved this very block; bail before + # re-recording an AIR edit that `carve_sphere` already handled. + if not _blocks.is_flammable(get_block_world(position)): + _burning.erase(position) + _fire_spread_cooldown.erase(position) + return var ticks := int(_burning[position]) - 1 if ticks <= 0: _burning.erase(position) @@ -1276,13 +1294,14 @@ func _decrement_fire_cooldown(position: Vector3i) -> int: return cooldown -## Fire needs an opaque or flammable neighbor to keep burning; otherwise it -## floats in air and is extinguished. +## Fire needs any non-air, non-water neighbor to keep burning (solid blocks, +## glass, foliage, even other fire); otherwise it floats in air and dies. func _fire_supported(position: Vector3i) -> bool: for offset in FIRE_OFFSETS: var neighbor_id := get_block_world(position + offset) - if _blocks.is_opaque(neighbor_id) or _blocks.is_flammable(neighbor_id): - return true + if neighbor_id == BlockRegistry.BLOCK_AIR or _blocks.is_water_id(neighbor_id): + continue + return true return false @@ -1296,17 +1315,20 @@ func _extinguish_fire(position: Vector3i, changed_chunks: Dictionary) -> void: return chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR _record_edit(position, BlockRegistry.BLOCK_AIR) + _seed_water(position) changed_chunks[_chunk_for_block(position)] = true ## Final state of a burnt block: it crumbles to ash and disappears. This is the -## only voxel change in the burning lifecycle, so it is the one persisted. +## 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: var chunk := _loaded_chunk_for(position) if chunk == null or chunk.lod: return chunk.data[_data_index(position)] = BlockRegistry.BLOCK_AIR _record_edit(position, BlockRegistry.BLOCK_AIR) + _seed_water(position) changed_chunks[_chunk_for_block(position)] = true From 16000117f465a44da551f6fc9a601de524f256ad Mon Sep 17 00:00:00 2001 From: MichaelFisher1997 Date: Tue, 15 Sep 2026 10:57:40 +0100 Subject: [PATCH 3/3] Treat any water-touching block as impossible to ignite --- tools/fire_verify.gd | 8 ++++++++ world/voxel_world.gd | 10 +++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/tools/fire_verify.gd b/tools/fire_verify.gd index cf19c82..e078fa2 100644 --- a/tools/fire_verify.gd +++ b/tools/fire_verify.gd @@ -146,6 +146,14 @@ func _check_submerged_and_support() -> void: _expect(wet_lit.get("blocked", "") == "wet", "flint and steel lit a submerged log") _expect(not world._burning.has(wet_log), "submerged log started burning") + # Water on a lateral face (a log at the waterline) also counts as flooded. + var shoreline_log := Vector3i(13, 9, 13) + _set_block(world._chunks[Vector2i.ZERO].data, shoreline_log, BlockRegistry.BLOCK_LOG) + _set_block(world._chunks[Vector2i.ZERO].data, shoreline_log + Vector3i(0, 0, 1), BlockRegistry.BLOCK_WATER) + _expect(world.use_flint_and_steel(shoreline_log, Vector3i.UP).get("blocked", "") == "wet", + "flint and steel lit a log touching water on the side") + _expect(not world._burning.has(shoreline_log), "shoreline log started burning") + # Re-clicking a burning block reports already-burning rather than a face hint. var log_position := Vector3i(4, 9, 4) _set_block(world._chunks[Vector2i.ZERO].data, log_position, BlockRegistry.BLOCK_LOG) diff --git a/world/voxel_world.gd b/world/voxel_world.gd index 190ffc5..419d800 100644 --- a/world/voxel_world.gd +++ b/world/voxel_world.gd @@ -1074,10 +1074,14 @@ func use_flint_and_steel(block_position: Vector3i, normal: Vector3i) -> Dictiona return {"name": "FIRE", "radius": 0, "removed": 0, "ignited": 1} -## Fire cannot start in a flooded cell: the cell above counts as submerged, which -## stops flint-and-steel lighting a mangrove log under the waterline. +## Fire cannot start in a flooded cell: any face touching water counts as +## submerged, which stops flint-and-steel lighting a mangrove log at the +## waterline and keeps the flame out of the water it later seeds back. func _is_submerged(block_position: Vector3i) -> bool: - return _blocks.is_water_id(get_block_world(block_position + Vector3i(0, 1, 0))) + for offset in FIRE_OFFSETS: + if _blocks.is_water_id(get_block_world(block_position + offset)): + return true + return false ## Public ignition entry point. A flammable target starts burning in place (its