From d8025a2d43e3ce9c143b5096e1963cd914c1fa48 Mon Sep 17 00:00:00 2001 From: Dario Maselli <117168592+Dario-Maselli@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:13:30 +0800 Subject: [PATCH 1/3] Add meteor features: entities, assets, config Introduce a meteor event system: new entity and meteor-related classes, items and blocks, renderers, and networking. Adds generated assets (models, blockstates, item models, textures, language entries), loot table and tags for meteor blocks and alien materials, and updates mineable tag. Adds configurable meteor settings in Config (spawn pacing, distances, warning window, crater radius, etc.). Also includes data-gen and tooling updates for models/textures. --- art/blockbench/block/meteor_core.bbmodel | 136 +++++++++++ art/blockbench/block/meteor_rock.bbmodel | 136 +++++++++++ art/blockbench/item/alien_core.bbmodel | 136 +++++++++++ art/blockbench/item/alien_fragment.bbmodel | 136 +++++++++++ art/blockbench/item/alien_tech_scrap.bbmodel | 136 +++++++++++ art/blockbench/item/meteor_caller.bbmodel | 136 +++++++++++ art/blockbench/item/meteor_tracker.bbmodel | 136 +++++++++++ .../nerospace/blockstates/meteor_core.json | 7 + .../nerospace/blockstates/meteor_rock.json | 7 + .../assets/nerospace/items/alien_core.json | 6 + .../nerospace/items/alien_fragment.json | 6 + .../nerospace/items/alien_tech_scrap.json | 6 + .../assets/nerospace/items/meteor_caller.json | 6 + .../assets/nerospace/items/meteor_core.json | 6 + .../assets/nerospace/items/meteor_rock.json | 6 + .../nerospace/items/meteor_tracker.json | 6 + .../assets/nerospace/lang/en_us.json | 14 ++ .../nerospace/models/block/meteor_core.json | 6 + .../nerospace/models/block/meteor_rock.json | 6 + .../nerospace/models/item/alien_core.json | 6 + .../nerospace/models/item/alien_fragment.json | 6 + .../models/item/alien_tech_scrap.json | 6 + .../nerospace/models/item/meteor_caller.json | 6 + .../nerospace/models/item/meteor_tracker.json | 6 + .../tags/block/mineable/pickaxe.json | 4 +- .../loot_table/blocks/meteor_rock.json | 21 ++ .../nerospace/tags/item/alien_materials.json | 7 + .../java/za/co/neroland/nerospace/Config.java | 42 ++++ .../neroland/nerospace/NerospaceClient.java | 48 ++++ .../nerospace/client/ClientMeteorTracker.java | 41 ++++ .../nerospace/client/FallingMeteorModel.java | 44 ++++ .../client/FallingMeteorRenderState.java | 10 + .../client/FallingMeteorRenderer.java | 64 +++++ .../datagen/ModBlockLootSubProvider.java | 4 + .../datagen/ModBlockTagProvider.java | 4 +- .../nerospace/datagen/ModItemTagProvider.java | 4 + .../datagen/ModLanguageProvider.java | 16 ++ .../nerospace/datagen/ModModelProvider.java | 11 + .../nerospace/meteor/FallingMeteorEntity.java | 226 ++++++++++++++++++ .../nerospace/meteor/MeteorCallerItem.java | 39 +++ .../nerospace/meteor/MeteorCoreBlock.java | 39 +++ .../meteor/MeteorCoreBlockEntity.java | 79 ++++++ .../nerospace/meteor/MeteorEventManager.java | 203 ++++++++++++++++ .../nerospace/meteor/MeteorEvents.java | 69 ++++++ .../neroland/nerospace/meteor/MeteorLoot.java | 71 ++++++ .../neroland/nerospace/meteor/MeteorSite.java | 41 ++++ .../nerospace/network/MeteorSyncPayload.java | 34 +++ .../nerospace/network/ModNetwork.java | 7 +- .../nerospace/registry/ModBlockEntities.java | 8 + .../nerospace/registry/ModBlocks.java | 30 +++ .../registry/ModCreativeModeTabs.java | 9 + .../nerospace/registry/ModEntities.java | 12 + .../neroland/nerospace/registry/ModItems.java | 23 ++ .../neroland/nerospace/registry/ModTags.java | 4 + .../nerospace/textures/block/meteor_core.png | Bin 0 -> 509 bytes .../nerospace/textures/block/meteor_rock.png | Bin 0 -> 515 bytes .../textures/entity/falling_meteor.png | Bin 0 -> 4078 bytes .../nerospace/textures/item/alien_core.png | Bin 0 -> 226 bytes .../textures/item/alien_fragment.png | Bin 0 -> 141 bytes .../textures/item/alien_tech_scrap.png | Bin 0 -> 160 bytes .../nerospace/textures/item/meteor_caller.png | Bin 0 -> 142 bytes .../textures/item/meteor_tracker.png | Bin 0 -> 208 bytes tools/gen_bbmodels.py | 6 +- tools/gen_textures.py | 148 +++++++++++- 64 files changed, 2426 insertions(+), 6 deletions(-) create mode 100644 art/blockbench/block/meteor_core.bbmodel create mode 100644 art/blockbench/block/meteor_rock.bbmodel create mode 100644 art/blockbench/item/alien_core.bbmodel create mode 100644 art/blockbench/item/alien_fragment.bbmodel create mode 100644 art/blockbench/item/alien_tech_scrap.bbmodel create mode 100644 art/blockbench/item/meteor_caller.bbmodel create mode 100644 art/blockbench/item/meteor_tracker.bbmodel create mode 100644 src/generated/resources/assets/nerospace/blockstates/meteor_core.json create mode 100644 src/generated/resources/assets/nerospace/blockstates/meteor_rock.json create mode 100644 src/generated/resources/assets/nerospace/items/alien_core.json create mode 100644 src/generated/resources/assets/nerospace/items/alien_fragment.json create mode 100644 src/generated/resources/assets/nerospace/items/alien_tech_scrap.json create mode 100644 src/generated/resources/assets/nerospace/items/meteor_caller.json create mode 100644 src/generated/resources/assets/nerospace/items/meteor_core.json create mode 100644 src/generated/resources/assets/nerospace/items/meteor_rock.json create mode 100644 src/generated/resources/assets/nerospace/items/meteor_tracker.json create mode 100644 src/generated/resources/assets/nerospace/models/block/meteor_core.json create mode 100644 src/generated/resources/assets/nerospace/models/block/meteor_rock.json create mode 100644 src/generated/resources/assets/nerospace/models/item/alien_core.json create mode 100644 src/generated/resources/assets/nerospace/models/item/alien_fragment.json create mode 100644 src/generated/resources/assets/nerospace/models/item/alien_tech_scrap.json create mode 100644 src/generated/resources/assets/nerospace/models/item/meteor_caller.json create mode 100644 src/generated/resources/assets/nerospace/models/item/meteor_tracker.json create mode 100644 src/generated/resources/data/nerospace/loot_table/blocks/meteor_rock.json create mode 100644 src/generated/resources/data/nerospace/tags/item/alien_materials.json create mode 100644 src/main/java/za/co/neroland/nerospace/client/ClientMeteorTracker.java create mode 100644 src/main/java/za/co/neroland/nerospace/client/FallingMeteorModel.java create mode 100644 src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderState.java create mode 100644 src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderer.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/FallingMeteorEntity.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/MeteorCallerItem.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlock.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlockEntity.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/MeteorEventManager.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/MeteorEvents.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/MeteorLoot.java create mode 100644 src/main/java/za/co/neroland/nerospace/meteor/MeteorSite.java create mode 100644 src/main/java/za/co/neroland/nerospace/network/MeteorSyncPayload.java create mode 100644 src/main/resources/assets/nerospace/textures/block/meteor_core.png create mode 100644 src/main/resources/assets/nerospace/textures/block/meteor_rock.png create mode 100644 src/main/resources/assets/nerospace/textures/entity/falling_meteor.png create mode 100644 src/main/resources/assets/nerospace/textures/item/alien_core.png create mode 100644 src/main/resources/assets/nerospace/textures/item/alien_fragment.png create mode 100644 src/main/resources/assets/nerospace/textures/item/alien_tech_scrap.png create mode 100644 src/main/resources/assets/nerospace/textures/item/meteor_caller.png create mode 100644 src/main/resources/assets/nerospace/textures/item/meteor_tracker.png diff --git a/art/blockbench/block/meteor_core.bbmodel b/art/blockbench/block/meteor_core.bbmodel new file mode 100644 index 0000000..1227cf5 --- /dev/null +++ b/art/blockbench/block/meteor_core.bbmodel @@ -0,0 +1,136 @@ +{ + "meta": { + "format_version": "4.10", + "model_format": "java_block", + "box_uv": false + }, + "name": "meteor_core", + "model_identifier": "", + "visible_box": [ + 1, + 1, + 0 + ], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": { + "width": 16, + "height": 16 + }, + "elements": [ + { + "name": "meteor_core", + "box_uv": false, + "rescale": false, + "locked": false, + "render_order": "default", + "allow_mirror_modeling": true, + "from": [ + 0, + 0, + 0 + ], + "to": [ + 16, + 16, + 16 + ], + "autouv": 0, + "color": 0, + "origin": [ + 8, + 8, + 8 + ], + "uv_offset": [ + 0, + 0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "east": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "south": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "west": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "up": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "down": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + } + }, + "type": "cube", + "uuid": "a8025f2e-56a9-4b8a-8e12-3cd4a1f22250" + } + ], + "outliner": [ + "a8025f2e-56a9-4b8a-8e12-3cd4a1f22250" + ], + "textures": [ + { + "path": "/sessions/serene-clever-noether/mnt/nerospace/src/main/resources/assets/nerospace/textures/block/meteor_core.png", + "name": "meteor_core.png", + "folder": "block", + "namespace": "nerospace", + "id": "0", + "particle": true, + "render_mode": "default", + "render_sides": "auto", + "frame_time": 1, + "frame_order_type": "loop", + "frame_order": "", + "frame_interpolate": false, + "visible": true, + "mode": "bitmap", + "saved": true, + "uuid": "63753f54-3fc0-46c8-abd6-1e0f12a10ba4", + "relative_path": "../../../src/main/resources/assets/nerospace/textures/block/meteor_core.png", + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAABxElEQVR4nG2TvYrbQBSFPwtDygxiEBMXWyxDkGEK1WHSqEhtQqoUYas8wrKPsPgR9gHSpMkTuNIDqBBYhQipvMIMYlKmcgrnDtKSU12ke879mXNX5dvqAuBcRdMc8L6m61q0LhiGHmtLhqFHqZwYJ6wtCeFMjBPe16w+fby78A/jeALAmA1Nc8DaUn4xDD0ASuXpm9YFWdMc6Lo2EYXsfZ1Ic6LWBVoXOFcBXDsYxxMhnJFRgCQa4wSA9zXjeFqIAqwBQjjjXEXXtXRdi3MVx72S2gC8358SScRCOLPuupYYpzR/jBPfv/xiax4XlY73D2zvIy+RaV1gbYkxG2KceH66TeRdWbErryNtzSPHvcL7mqY5pGKZMRuGoU8zC3ZlxefXr1IskE5DOGNtSTaOJ5TKkU7m+Pb7TxIRiDckzoahT5s2ZpMSf/RtEpEYrs8p4wKsTHFzka2KyH+XOD7w5utPgIU7V/7dh4tYc97a89PtQmB7HxdWFuuvTHFz4QXmuwjhjNZFElYqT2RrSzLva7yvUSpPRLG0VBqGfrGfufUzeZoYp5QkCVJJqXxhNOeqdLVrUZp7XOsinbT39WK8+bE5V/EX3If3A13KBkAAAAAASUVORK5CYII=" + } + ] +} \ No newline at end of file diff --git a/art/blockbench/block/meteor_rock.bbmodel b/art/blockbench/block/meteor_rock.bbmodel new file mode 100644 index 0000000..2df7a77 --- /dev/null +++ b/art/blockbench/block/meteor_rock.bbmodel @@ -0,0 +1,136 @@ +{ + "meta": { + "format_version": "4.10", + "model_format": "java_block", + "box_uv": false + }, + "name": "meteor_rock", + "model_identifier": "", + "visible_box": [ + 1, + 1, + 0 + ], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": { + "width": 16, + "height": 16 + }, + "elements": [ + { + "name": "meteor_rock", + "box_uv": false, + "rescale": false, + "locked": false, + "render_order": "default", + "allow_mirror_modeling": true, + "from": [ + 0, + 0, + 0 + ], + "to": [ + 16, + 16, + 16 + ], + "autouv": 0, + "color": 0, + "origin": [ + 8, + 8, + 8 + ], + "uv_offset": [ + 0, + 0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "east": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "south": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "west": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "up": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "down": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + } + }, + "type": "cube", + "uuid": "46bc8506-936d-4d65-951f-38e973732dee" + } + ], + "outliner": [ + "46bc8506-936d-4d65-951f-38e973732dee" + ], + "textures": [ + { + "path": "/sessions/serene-clever-noether/mnt/nerospace/src/main/resources/assets/nerospace/textures/block/meteor_rock.png", + "name": "meteor_rock.png", + "folder": "block", + "namespace": "nerospace", + "id": "0", + "particle": true, + "render_mode": "default", + "render_sides": "auto", + "frame_time": 1, + "frame_order_type": "loop", + "frame_order": "", + "frame_interpolate": false, + "visible": true, + "mode": "bitmap", + "saved": true, + "uuid": "c8e34ca8-318e-49f1-9a96-2099cb051722", + "relative_path": "../../../src/main/resources/assets/nerospace/textures/block/meteor_rock.png", + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAByklEQVR4nHWSIYscQRCFvxtCdLM0Q2fkMhwTaDE/YGNORoQjRB8hIqyOWBkRuSL6WBVi4yIiR7U4Tq0YyIohRM0OQzP0P7gTu1W3G0ipprpe1av36uLd2/cPw9AT4whAShNlWeFcQdtu4fst3Cw5i2PO+5oMoOt2eF8DsFhcARBCA4D99Bnva6zNsTYnpQm/vsXanLbdkrXtFmNmtO32kFwtca44gG2uzbpuR4zjoXa1VMYX1WX9AOB9TQgNxsz4vTa8+PgHY2a6koSs5n3NMPRkQqttt1ok4NOIX78Q40gIDSlNhNDgXPHE4JTyv9NO884VyjSliUzEszan63aIqFLofU3X7fRvGHp1y5jZQcSUprMJ4kRZVoTQUJYVZVmpcKKJtTnPBBzjqEBpasxMgVIn64hGmUyWVYah13dKE4tfP7E2V4BzBTGOWJvjXEEmUwV8SrMsK7oPT55LE2EzDD2ZXNyPm796QGrdEagXeGS238y1Rm0USoAK979m8gbIUprUwhAa9Rg4s0+mO1dwfX+nrLKyrLRov5mzWFyp0vvNHGNmdN2OYei5vr+jbbeE12+URXa696v1cz0UgJerpJNjHPl2eanMxJlH8IoCIxFyA1kAAAAASUVORK5CYII=" + } + ] +} \ No newline at end of file diff --git a/art/blockbench/item/alien_core.bbmodel b/art/blockbench/item/alien_core.bbmodel new file mode 100644 index 0000000..d16d40e --- /dev/null +++ b/art/blockbench/item/alien_core.bbmodel @@ -0,0 +1,136 @@ +{ + "meta": { + "format_version": "4.10", + "model_format": "java_block", + "box_uv": false + }, + "name": "alien_core", + "model_identifier": "", + "visible_box": [ + 1, + 1, + 0 + ], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": { + "width": 16, + "height": 16 + }, + "elements": [ + { + "name": "alien_core", + "box_uv": false, + "rescale": false, + "locked": false, + "render_order": "default", + "allow_mirror_modeling": true, + "from": [ + 0, + 0, + 7.5 + ], + "to": [ + 16, + 16, + 8.5 + ], + "autouv": 0, + "color": 0, + "origin": [ + 8, + 8, + 8 + ], + "uv_offset": [ + 0, + 0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "south": { + "uv": [ + 16, + 0, + 0, + 16 + ], + "texture": 0 + }, + "east": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "west": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "up": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + }, + "down": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + } + }, + "type": "cube", + "uuid": "cca8d6e6-717f-4122-9442-1df0fc7a5060" + } + ], + "outliner": [ + "cca8d6e6-717f-4122-9442-1df0fc7a5060" + ], + "textures": [ + { + "path": "/sessions/serene-clever-noether/mnt/nerospace/src/main/resources/assets/nerospace/textures/item/alien_core.png", + "name": "alien_core.png", + "folder": "item", + "namespace": "nerospace", + "id": "0", + "particle": true, + "render_mode": "default", + "render_sides": "auto", + "frame_time": 1, + "frame_order_type": "loop", + "frame_order": "", + "frame_interpolate": false, + "visible": true, + "mode": "bitmap", + "saved": true, + "uuid": "b6677917-5311-46ee-b73a-d61e47ff5760", + "relative_path": "../../../src/main/resources/assets/nerospace/textures/item/alien_core.png", + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAqUlEQVR4nGNgoBAwYhOUEJP7j038xatHGOoxBGCan89SQhGXTLuH1RAmYjQji6G7DsU0CTG5/zCFlQ47UAxoP+ABdwmyK1AM+L/B4T82zeiGMAYcgOtjQXc+DETxs6NoXvbxJ4pLYa5ACQNywMAbAA8DiJ+U4OGA7Gd0gBwLKC6AJRZYaCMD5GhEBhjpgIEBe0JC1ozTBTAJdFtwacZwAbpL0AG2zEQxAAACHExZBxfQawAAAABJRU5ErkJggg==" + } + ] +} \ No newline at end of file diff --git a/art/blockbench/item/alien_fragment.bbmodel b/art/blockbench/item/alien_fragment.bbmodel new file mode 100644 index 0000000..4e38df1 --- /dev/null +++ b/art/blockbench/item/alien_fragment.bbmodel @@ -0,0 +1,136 @@ +{ + "meta": { + "format_version": "4.10", + "model_format": "java_block", + "box_uv": false + }, + "name": "alien_fragment", + "model_identifier": "", + "visible_box": [ + 1, + 1, + 0 + ], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": { + "width": 16, + "height": 16 + }, + "elements": [ + { + "name": "alien_fragment", + "box_uv": false, + "rescale": false, + "locked": false, + "render_order": "default", + "allow_mirror_modeling": true, + "from": [ + 0, + 0, + 7.5 + ], + "to": [ + 16, + 16, + 8.5 + ], + "autouv": 0, + "color": 0, + "origin": [ + 8, + 8, + 8 + ], + "uv_offset": [ + 0, + 0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "south": { + "uv": [ + 16, + 0, + 0, + 16 + ], + "texture": 0 + }, + "east": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "west": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "up": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + }, + "down": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + } + }, + "type": "cube", + "uuid": "25365464-6ce5-48a1-bcb1-a17deb5023de" + } + ], + "outliner": [ + "25365464-6ce5-48a1-bcb1-a17deb5023de" + ], + "textures": [ + { + "path": "/sessions/serene-clever-noether/mnt/nerospace/src/main/resources/assets/nerospace/textures/item/alien_fragment.png", + "name": "alien_fragment.png", + "folder": "item", + "namespace": "nerospace", + "id": "0", + "particle": true, + "render_mode": "default", + "render_sides": "auto", + "frame_time": 1, + "frame_order_type": "loop", + "frame_order": "", + "frame_interpolate": false, + "visible": true, + "mode": "bitmap", + "saved": true, + "uuid": "e4f13f59-d48b-41ee-b968-b48d6338c152", + "relative_path": "../../../src/main/resources/assets/nerospace/textures/item/alien_fragment.png", + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAVElEQVR4nGNgGAUUA0ZcEhonTvxH5t+wsMCqlgmX5gANA7wG4jWAgYGBYcONCwzohhBtAMy5G25cYGBgYGC4/qICpwE4w4CBAdXZJIUBLheNAhoBAISQGEL85dGaAAAAAElFTkSuQmCC" + } + ] +} \ No newline at end of file diff --git a/art/blockbench/item/alien_tech_scrap.bbmodel b/art/blockbench/item/alien_tech_scrap.bbmodel new file mode 100644 index 0000000..1b8e775 --- /dev/null +++ b/art/blockbench/item/alien_tech_scrap.bbmodel @@ -0,0 +1,136 @@ +{ + "meta": { + "format_version": "4.10", + "model_format": "java_block", + "box_uv": false + }, + "name": "alien_tech_scrap", + "model_identifier": "", + "visible_box": [ + 1, + 1, + 0 + ], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": { + "width": 16, + "height": 16 + }, + "elements": [ + { + "name": "alien_tech_scrap", + "box_uv": false, + "rescale": false, + "locked": false, + "render_order": "default", + "allow_mirror_modeling": true, + "from": [ + 0, + 0, + 7.5 + ], + "to": [ + 16, + 16, + 8.5 + ], + "autouv": 0, + "color": 0, + "origin": [ + 8, + 8, + 8 + ], + "uv_offset": [ + 0, + 0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "south": { + "uv": [ + 16, + 0, + 0, + 16 + ], + "texture": 0 + }, + "east": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "west": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "up": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + }, + "down": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + } + }, + "type": "cube", + "uuid": "709ba01c-5178-4070-b641-0bbb27d45b85" + } + ], + "outliner": [ + "709ba01c-5178-4070-b641-0bbb27d45b85" + ], + "textures": [ + { + "path": "/sessions/serene-clever-noether/mnt/nerospace/src/main/resources/assets/nerospace/textures/item/alien_tech_scrap.png", + "name": "alien_tech_scrap.png", + "folder": "item", + "namespace": "nerospace", + "id": "0", + "particle": true, + "render_mode": "default", + "render_sides": "auto", + "frame_time": 1, + "frame_order_type": "loop", + "frame_order": "", + "frame_interpolate": false, + "visible": true, + "mode": "bitmap", + "saved": true, + "uuid": "173a9c99-a0a6-4392-9182-87529a017fe6", + "relative_path": "../../../src/main/resources/assets/nerospace/textures/item/alien_tech_scrap.png", + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAZ0lEQVR4nGNgGAUUA0Z0ARsrt/8SElIML148Y8BGr1m3AEUPE7oBMMU22zZhNYQoF7zpa8LqXJ2uGRguYMHmAoaiOqzOx+YCDC9gc/7hsl9wPkEDJCSkGI54+aHYbNvFhtMFo4AKAAC/s0Oh8evw/gAAAABJRU5ErkJggg==" + } + ] +} \ No newline at end of file diff --git a/art/blockbench/item/meteor_caller.bbmodel b/art/blockbench/item/meteor_caller.bbmodel new file mode 100644 index 0000000..ad7a268 --- /dev/null +++ b/art/blockbench/item/meteor_caller.bbmodel @@ -0,0 +1,136 @@ +{ + "meta": { + "format_version": "4.10", + "model_format": "java_block", + "box_uv": false + }, + "name": "meteor_caller", + "model_identifier": "", + "visible_box": [ + 1, + 1, + 0 + ], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": { + "width": 16, + "height": 16 + }, + "elements": [ + { + "name": "meteor_caller", + "box_uv": false, + "rescale": false, + "locked": false, + "render_order": "default", + "allow_mirror_modeling": true, + "from": [ + 0, + 0, + 7.5 + ], + "to": [ + 16, + 16, + 8.5 + ], + "autouv": 0, + "color": 0, + "origin": [ + 8, + 8, + 8 + ], + "uv_offset": [ + 0, + 0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "south": { + "uv": [ + 16, + 0, + 0, + 16 + ], + "texture": 0 + }, + "east": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "west": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "up": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + }, + "down": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + } + }, + "type": "cube", + "uuid": "d9e6b61e-bc9e-435f-8fa9-fb080da83e17" + } + ], + "outliner": [ + "d9e6b61e-bc9e-435f-8fa9-fb080da83e17" + ], + "textures": [ + { + "path": "/sessions/serene-clever-noether/mnt/nerospace/src/main/resources/assets/nerospace/textures/item/meteor_caller.png", + "name": "meteor_caller.png", + "folder": "item", + "namespace": "nerospace", + "id": "0", + "particle": true, + "render_mode": "default", + "render_sides": "auto", + "frame_time": 1, + "frame_order_type": "loop", + "frame_order": "", + "frame_interpolate": false, + "visible": true, + "mode": "bitmap", + "saved": true, + "uuid": "aa690ee7-74f3-4047-a78f-46c47b9046b3", + "relative_path": "../../../src/main/resources/assets/nerospace/textures/item/meteor_caller.png", + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAVUlEQVR4nGNgGJ5ARkHn/4INl/6j00QbANP0f4PD//8bHOCGkOwCmAFkuaDiww8Mb5DsAhgNM4wkF8go6Pyv+PCDchcgG0KyC6gSBhonTgyAC4YWAADNDo6b2xad3wAAAABJRU5ErkJggg==" + } + ] +} \ No newline at end of file diff --git a/art/blockbench/item/meteor_tracker.bbmodel b/art/blockbench/item/meteor_tracker.bbmodel new file mode 100644 index 0000000..d38d8aa --- /dev/null +++ b/art/blockbench/item/meteor_tracker.bbmodel @@ -0,0 +1,136 @@ +{ + "meta": { + "format_version": "4.10", + "model_format": "java_block", + "box_uv": false + }, + "name": "meteor_tracker", + "model_identifier": "", + "visible_box": [ + 1, + 1, + 0 + ], + "variable_placeholders": "", + "variable_placeholder_buttons": [], + "timeline_setups": [], + "unhandled_root_fields": {}, + "resolution": { + "width": 16, + "height": 16 + }, + "elements": [ + { + "name": "meteor_tracker", + "box_uv": false, + "rescale": false, + "locked": false, + "render_order": "default", + "allow_mirror_modeling": true, + "from": [ + 0, + 0, + 7.5 + ], + "to": [ + 16, + 16, + 8.5 + ], + "autouv": 0, + "color": 0, + "origin": [ + 8, + 8, + 8 + ], + "uv_offset": [ + 0, + 0 + ], + "faces": { + "north": { + "uv": [ + 0, + 0, + 16, + 16 + ], + "texture": 0 + }, + "south": { + "uv": [ + 16, + 0, + 0, + 16 + ], + "texture": 0 + }, + "east": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "west": { + "uv": [ + 0, + 0, + 0, + 16 + ], + "texture": null + }, + "up": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + }, + "down": { + "uv": [ + 0, + 0, + 16, + 0 + ], + "texture": null + } + }, + "type": "cube", + "uuid": "e7b648d1-a8ed-43c7-9114-9d42e47e70de" + } + ], + "outliner": [ + "e7b648d1-a8ed-43c7-9114-9d42e47e70de" + ], + "textures": [ + { + "path": "/sessions/serene-clever-noether/mnt/nerospace/src/main/resources/assets/nerospace/textures/item/meteor_tracker.png", + "name": "meteor_tracker.png", + "folder": "item", + "namespace": "nerospace", + "id": "0", + "particle": true, + "render_mode": "default", + "render_sides": "auto", + "frame_time": 1, + "frame_order_type": "loop", + "frame_order": "", + "frame_interpolate": false, + "visible": true, + "mode": "bitmap", + "saved": true, + "uuid": "a877d92b-b204-4209-98f9-9479686c313a", + "relative_path": "../../../src/main/resources/assets/nerospace/textures/item/meteor_tracker.png", + "source": "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAYAAAAf8/9hAAAAl0lEQVR4nGNgoBAwYhOUUdD5j038yYMrGOoxBGCaWyYsQxGvKYjCaQiK5gUbLv1fsOHSfwk5FRQME8flOrhmdI0VH35gGIRsCBM+PydcuoJCY/MqXtuxuQDdFUw4AwQKFujp4JUnaMCuh7cpM0B3oyNxBtQURDF0TFpHyDyGjknr4GkCbgDexIEDYNVDTkKiOClTnJkoBgDWcISiLh/RmwAAAABJRU5ErkJggg==" + } + ] +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/blockstates/meteor_core.json b/src/generated/resources/assets/nerospace/blockstates/meteor_core.json new file mode 100644 index 0000000..45319af --- /dev/null +++ b/src/generated/resources/assets/nerospace/blockstates/meteor_core.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "nerospace:block/meteor_core" + } + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/blockstates/meteor_rock.json b/src/generated/resources/assets/nerospace/blockstates/meteor_rock.json new file mode 100644 index 0000000..d27dcf2 --- /dev/null +++ b/src/generated/resources/assets/nerospace/blockstates/meteor_rock.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "nerospace:block/meteor_rock" + } + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/items/alien_core.json b/src/generated/resources/assets/nerospace/items/alien_core.json new file mode 100644 index 0000000..90d9d61 --- /dev/null +++ b/src/generated/resources/assets/nerospace/items/alien_core.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "nerospace:item/alien_core" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/items/alien_fragment.json b/src/generated/resources/assets/nerospace/items/alien_fragment.json new file mode 100644 index 0000000..4bacabf --- /dev/null +++ b/src/generated/resources/assets/nerospace/items/alien_fragment.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "nerospace:item/alien_fragment" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/items/alien_tech_scrap.json b/src/generated/resources/assets/nerospace/items/alien_tech_scrap.json new file mode 100644 index 0000000..b315c60 --- /dev/null +++ b/src/generated/resources/assets/nerospace/items/alien_tech_scrap.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "nerospace:item/alien_tech_scrap" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/items/meteor_caller.json b/src/generated/resources/assets/nerospace/items/meteor_caller.json new file mode 100644 index 0000000..75db1c4 --- /dev/null +++ b/src/generated/resources/assets/nerospace/items/meteor_caller.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "nerospace:item/meteor_caller" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/items/meteor_core.json b/src/generated/resources/assets/nerospace/items/meteor_core.json new file mode 100644 index 0000000..16b7b2c --- /dev/null +++ b/src/generated/resources/assets/nerospace/items/meteor_core.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "nerospace:block/meteor_core" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/items/meteor_rock.json b/src/generated/resources/assets/nerospace/items/meteor_rock.json new file mode 100644 index 0000000..fb59abb --- /dev/null +++ b/src/generated/resources/assets/nerospace/items/meteor_rock.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "nerospace:block/meteor_rock" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/items/meteor_tracker.json b/src/generated/resources/assets/nerospace/items/meteor_tracker.json new file mode 100644 index 0000000..5629eb7 --- /dev/null +++ b/src/generated/resources/assets/nerospace/items/meteor_tracker.json @@ -0,0 +1,6 @@ +{ + "model": { + "type": "minecraft:model", + "model": "nerospace:item/meteor_tracker" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/lang/en_us.json b/src/generated/resources/assets/nerospace/lang/en_us.json index 791225e..dcee035 100644 --- a/src/generated/resources/assets/nerospace/lang/en_us.json +++ b/src/generated/resources/assets/nerospace/lang/en_us.json @@ -30,6 +30,8 @@ "block.nerospace.launch_gantry": "Launch Gantry", "block.nerospace.launch_gantry.boarded": "Boarded the rocket — strap in", "block.nerospace.launch_gantry.no_rocket": "No rocket on the pad to board", + "block.nerospace.meteor_core": "Meteor Core", + "block.nerospace.meteor_rock": "Meteor Rock", "block.nerospace.nerosium_block": "Block of Nerosium", "block.nerospace.nerosium_grinder": "Nerosium Grinder", "block.nerospace.nerosium_ore": "Nerosium Ore", @@ -89,6 +91,7 @@ "container.nerospace.terraformer": "Terraformer", "entity.nerospace.cinder_stalker": "Cinder Stalker", "entity.nerospace.ember_strutter": "Ember Strutter", + "entity.nerospace.falling_meteor": "Meteor", "entity.nerospace.frost_strider": "Frost Strider", "entity.nerospace.greenling": "Greenling", "entity.nerospace.meadow_loper": "Meadow Loper", @@ -236,6 +239,9 @@ "gui.nerospace.terraformer.stages": "Radii: %s / %s / %s", "gui.nerospace.terraformer.tier": "Tier %s", "gui.nerospace.terraformer.working": "Terraforming", + "item.nerospace.alien_core": "Alien Core", + "item.nerospace.alien_fragment": "Alien Fragment", + "item.nerospace.alien_tech_scrap": "Alien Tech Scrap", "item.nerospace.capacity_upgrade": "Capacity Upgrade", "item.nerospace.cindara_compass": "Cindara Compass", "item.nerospace.cinder_stalker_spawn_egg": "Cinder Stalker Spawn Egg", @@ -259,6 +265,14 @@ "item.nerospace.greenxertz_navigator.travel": "Transported to Greenxertz", "item.nerospace.loper_haunch": "Loper Haunch", "item.nerospace.meadow_loper_spawn_egg": "Meadow Loper Spawn Egg", + "item.nerospace.meteor_caller": "Meteor Caller", + "item.nerospace.meteor_caller.called": "A meteor streaks down from the sky...", + "item.nerospace.meteor_caller.creative_only": "The Meteor Caller only works in Creative mode", + "item.nerospace.meteor_tracker": "Meteor Tracker", + "item.nerospace.meteor_tracker.incoming": "Incoming", + "item.nerospace.meteor_tracker.landed": "Landed", + "item.nerospace.meteor_tracker.none": "Meteor Tracker: no meteors detected", + "item.nerospace.meteor_tracker.readout": "☄ Meteor %s — %s, %sm", "item.nerospace.nerosium_dust": "Nerosium Dust", "item.nerospace.nerosium_ingot": "Nerosium Ingot", "item.nerospace.nerosium_pickaxe": "Nerosium Pickaxe", diff --git a/src/generated/resources/assets/nerospace/models/block/meteor_core.json b/src/generated/resources/assets/nerospace/models/block/meteor_core.json new file mode 100644 index 0000000..84bfb33 --- /dev/null +++ b/src/generated/resources/assets/nerospace/models/block/meteor_core.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "nerospace:block/meteor_core" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/models/block/meteor_rock.json b/src/generated/resources/assets/nerospace/models/block/meteor_rock.json new file mode 100644 index 0000000..a9bbcc8 --- /dev/null +++ b/src/generated/resources/assets/nerospace/models/block/meteor_rock.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "nerospace:block/meteor_rock" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/models/item/alien_core.json b/src/generated/resources/assets/nerospace/models/item/alien_core.json new file mode 100644 index 0000000..2257cf0 --- /dev/null +++ b/src/generated/resources/assets/nerospace/models/item/alien_core.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "nerospace:item/alien_core" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/models/item/alien_fragment.json b/src/generated/resources/assets/nerospace/models/item/alien_fragment.json new file mode 100644 index 0000000..5442743 --- /dev/null +++ b/src/generated/resources/assets/nerospace/models/item/alien_fragment.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "nerospace:item/alien_fragment" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/models/item/alien_tech_scrap.json b/src/generated/resources/assets/nerospace/models/item/alien_tech_scrap.json new file mode 100644 index 0000000..894d2e5 --- /dev/null +++ b/src/generated/resources/assets/nerospace/models/item/alien_tech_scrap.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "nerospace:item/alien_tech_scrap" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/models/item/meteor_caller.json b/src/generated/resources/assets/nerospace/models/item/meteor_caller.json new file mode 100644 index 0000000..fb2e87e --- /dev/null +++ b/src/generated/resources/assets/nerospace/models/item/meteor_caller.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "nerospace:item/meteor_caller" + } +} \ No newline at end of file diff --git a/src/generated/resources/assets/nerospace/models/item/meteor_tracker.json b/src/generated/resources/assets/nerospace/models/item/meteor_tracker.json new file mode 100644 index 0000000..33d38e4 --- /dev/null +++ b/src/generated/resources/assets/nerospace/models/item/meteor_tracker.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "nerospace:item/meteor_tracker" + } +} \ No newline at end of file diff --git a/src/generated/resources/data/minecraft/tags/block/mineable/pickaxe.json b/src/generated/resources/data/minecraft/tags/block/mineable/pickaxe.json index 9018ee7..c803bf4 100644 --- a/src/generated/resources/data/minecraft/tags/block/mineable/pickaxe.json +++ b/src/generated/resources/data/minecraft/tags/block/mineable/pickaxe.json @@ -35,6 +35,8 @@ "nerospace:quarry_controller", "nerospace:quarry_landmark", "nerospace:quarry_frame", - "nerospace:trash_can" + "nerospace:trash_can", + "nerospace:meteor_rock", + "nerospace:meteor_core" ] } \ No newline at end of file diff --git a/src/generated/resources/data/nerospace/loot_table/blocks/meteor_rock.json b/src/generated/resources/data/nerospace/loot_table/blocks/meteor_rock.json new file mode 100644 index 0000000..c637909 --- /dev/null +++ b/src/generated/resources/data/nerospace/loot_table/blocks/meteor_rock.json @@ -0,0 +1,21 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "bonus_rolls": 0.0, + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ], + "entries": [ + { + "type": "minecraft:item", + "name": "nerospace:meteor_rock" + } + ], + "rolls": 1.0 + } + ], + "random_sequence": "nerospace:blocks/meteor_rock" +} \ No newline at end of file diff --git a/src/generated/resources/data/nerospace/tags/item/alien_materials.json b/src/generated/resources/data/nerospace/tags/item/alien_materials.json new file mode 100644 index 0000000..6574dee --- /dev/null +++ b/src/generated/resources/data/nerospace/tags/item/alien_materials.json @@ -0,0 +1,7 @@ +{ + "values": [ + "nerospace:alien_fragment", + "nerospace:alien_tech_scrap", + "nerospace:alien_core" + ] +} \ No newline at end of file diff --git a/src/main/java/za/co/neroland/nerospace/Config.java b/src/main/java/za/co/neroland/nerospace/Config.java index 89ea83d..fb8ed49 100644 --- a/src/main/java/za/co/neroland/nerospace/Config.java +++ b/src/main/java/za/co/neroland/nerospace/Config.java @@ -206,6 +206,48 @@ public class Config { .comment("Guard on how many chunks active terraforming may force-load at once.") .defineInRange("terraformMaxForcedChunks", 16, 0, 256); + // --- Meteor events (meteor-events-design.md) ----------------------------- + // Spawn pacing + loot tunables. Defaults give roughly one natural meteor every ~2-3 play-hours + // per active level; tune for busier or quieter skies. + + public static final ModConfigSpec.BooleanValue METEOR_NATURAL_SPAWN = BUILDER + .comment("Whether meteors fall naturally near players (the creative Meteor Caller works either way).") + .define("meteorNaturalSpawn", true); + + public static final ModConfigSpec.IntValue METEOR_AVG_INTERVAL_SECONDS = BUILDER + .comment("Average seconds between natural meteor impacts on an eligible dimension with players online.", + "Default 9000 (~2.5 hours). Each interval is randomised 0.66x..1.33x so impacts feel irregular.") + .defineInRange("meteorAvgIntervalSeconds", 9000, 60, 1_000_000); + + public static final ModConfigSpec.IntValue METEOR_WARNING_SECONDS = BUILDER + .comment("Warning window: seconds a meteor is tracked as 'incoming' before it actually falls.") + .defineInRange("meteorWarningSeconds", 30, 0, 600); + + public static final ModConfigSpec.IntValue METEOR_MIN_DISTANCE = BUILDER + .comment("Minimum horizontal distance (blocks) from the anchor player a meteor targets.") + .defineInRange("meteorMinDistance", 200, 0, 2000); + + public static final ModConfigSpec.IntValue METEOR_MAX_DISTANCE = BUILDER + .comment("Maximum horizontal distance (blocks) from the anchor player a meteor targets.") + .defineInRange("meteorMaxDistance", 500, 16, 4000); + + public static final ModConfigSpec.IntValue METEOR_CRATER_RADIUS = BUILDER + .comment("Radius (blocks) of the small crater a meteor carves. Kept modest to avoid griefing builds.") + .defineInRange("meteorCraterRadius", 3, 1, 8); + + public static final ModConfigSpec.IntValue METEOR_MAX_ACTIVE_SITES = BUILDER + .comment("Max simultaneous scheduled/falling meteors tracked per dimension.") + .defineInRange("meteorMaxActiveSites", 4, 1, 64); + + public static final ModConfigSpec.IntValue METEOR_LOOT_BONUS_ROLLS = BUILDER + .comment("Weighted bonus loot rolls in a meteor core, on top of its guaranteed alien fragments.") + .defineInRange("meteorLootBonusRolls", 3, 0, 32); + + public static final ModConfigSpec.BooleanValue METEOR_DEBUG_LOG = BUILDER + .comment("Verbose, NON-personal meteor logging (dimension + coordinates only, never player", + "identifiers). Off by shipped default (POPIA/GDPR).") + .define("meteorDebugLog", false); + static final ModConfigSpec SPEC = BUILDER.build(); /** Client oxygen-visual quality tiers. */ diff --git a/src/main/java/za/co/neroland/nerospace/NerospaceClient.java b/src/main/java/za/co/neroland/nerospace/NerospaceClient.java index 31a875a..b3b9431 100644 --- a/src/main/java/za/co/neroland/nerospace/NerospaceClient.java +++ b/src/main/java/za/co/neroland/nerospace/NerospaceClient.java @@ -9,7 +9,9 @@ import net.minecraft.client.multiplayer.ClientLevel; import net.minecraft.core.BlockPos; import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.network.chat.Component; import net.minecraft.resources.Identifier; +import net.minecraft.world.phys.Vec3; import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundSource; import net.minecraft.util.RandomSource; @@ -41,8 +43,13 @@ import za.co.neroland.nerospace.client.OxygenGeneratorScreen; import za.co.neroland.nerospace.client.CombustionGeneratorScreen; import za.co.neroland.nerospace.client.PassiveGeneratorScreen; +import za.co.neroland.nerospace.client.ClientMeteorTracker; +import za.co.neroland.nerospace.client.FallingMeteorModel; +import za.co.neroland.nerospace.client.FallingMeteorRenderer; import za.co.neroland.nerospace.client.RocketModel; import za.co.neroland.nerospace.client.RocketRenderer; +import za.co.neroland.nerospace.meteor.MeteorSite; +import za.co.neroland.nerospace.registry.ModItems; import za.co.neroland.nerospace.client.UniversalPipeRenderer; import za.co.neroland.nerospace.client.RocketScreen; import za.co.neroland.nerospace.client.TerraformerScreen; @@ -131,6 +138,8 @@ static void onRenderGuiLayer(RenderGuiLayerEvent.Pre event) { @SubscribeEvent static void onRegisterEntityRenderers(EntityRenderersEvent.RegisterRenderers event) { event.registerEntityRenderer(ModEntities.ROCKET.get(), RocketRenderer::new); + // Meteor events (meteor-events-design.md): the tumbling, trailing falling meteor. + event.registerEntityRenderer(ModEntities.FALLING_METEOR.get(), FallingMeteorRenderer::new); // Each creature now has its own model geometry; the scale just fine-tunes size. event.registerEntityRenderer(ModEntities.XERTZ_STALKER.get(), context -> new GreenxertzCreatureRenderer(context, @@ -204,6 +213,7 @@ static void onRegisterLayerDefinitions(EntityRenderersEvent.RegisterLayerDefinit event.registerLayerDefinition(za.co.neroland.nerospace.client.WoollyDriftModel.LAYER, za.co.neroland.nerospace.client.WoollyDriftModel::createBodyLayer); event.registerLayerDefinition(RocketModel.LAYER, RocketModel::createBodyLayer); + event.registerLayerDefinition(FallingMeteorModel.LAYER, FallingMeteorModel::createBodyLayer); // Per-tier rocket geometry (ART_OVERHAUL_DESIGN.md §4.2). event.registerLayerDefinition(za.co.neroland.nerospace.client.RocketT2Model.LAYER, za.co.neroland.nerospace.client.RocketT2Model::createBodyLayer); @@ -326,6 +336,44 @@ private static float lerp(float from, float to, float t) { return from + (to - from) * t; } + private static final String[] COMPASS_8 = {"N", "NE", "E", "SE", "S", "SW", "W", "NW"}; + + /** + * Meteor Tracker readout (meteor-events design §6): while the player holds a tracker, show the + * nearest meteor's state (incoming / landed), compass heading and distance in the action bar. + * Server-authoritative — the data arrives via {@link ClientMeteorTracker}; this only presents it. + */ + @SubscribeEvent + static void onMeteorTrackerTick(ClientTickEvent.Post event) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.level == null || mc.isPaused()) { + return; + } + boolean holding = mc.player.getMainHandItem().is(ModItems.METEOR_TRACKER.get()) + || mc.player.getOffhandItem().is(ModItems.METEOR_TRACKER.get()); + if (!holding) { + return; + } + if (!ClientMeteorTracker.isPresent()) { + mc.gui.setOverlayMessage( + Component.translatable("item.nerospace.meteor_tracker.none"), false); + return; + } + BlockPos target = ClientMeteorTracker.pos(); + Vec3 p = mc.player.position(); + double dx = target.getX() + 0.5D - p.x; + double dz = target.getZ() + 0.5D - p.z; + int dist = (int) Math.round(Math.sqrt(dx * dx + dz * dz)); + // Bearing where North = -Z, East = +X (Minecraft convention). + double deg = (Math.toDegrees(Math.atan2(dx, -dz)) + 360.0D) % 360.0D; + String heading = COMPASS_8[(int) Math.round(deg / 45.0D) & 7]; + Component state = Component.translatable(ClientMeteorTracker.state() == MeteorSite.LANDED + ? "item.nerospace.meteor_tracker.landed" + : "item.nerospace.meteor_tracker.incoming"); + mc.gui.setOverlayMessage( + Component.translatable("item.nerospace.meteor_tracker.readout", state, heading, dist), false); + } + private static Identifier entityTexture(String name) { return Identifier.fromNamespaceAndPath(Nerospace.MODID, "textures/entity/" + name + ".png"); } diff --git a/src/main/java/za/co/neroland/nerospace/client/ClientMeteorTracker.java b/src/main/java/za/co/neroland/nerospace/client/ClientMeteorTracker.java new file mode 100644 index 0000000..2b6b912 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/client/ClientMeteorTracker.java @@ -0,0 +1,41 @@ +package za.co.neroland.nerospace.client; + +import javax.annotation.Nullable; + +import net.minecraft.core.BlockPos; + +import za.co.neroland.nerospace.network.MeteorSyncPayload; + +/** + * Client-side holder for the latest nearest-meteor snapshot (meteor-events design §6). Fed by + * {@link MeteorSyncPayload}; read by the tracker readout in {@code NerospaceClient.onClientTick}. + */ +public final class ClientMeteorTracker { + + private static boolean present; + @Nullable + private static BlockPos pos; + private static int state; + + private ClientMeteorTracker() { + } + + public static void accept(MeteorSyncPayload payload) { + present = payload.present(); + pos = payload.present() ? BlockPos.of(payload.pos()) : null; + state = payload.state(); + } + + public static boolean isPresent() { + return present && pos != null; + } + + @Nullable + public static BlockPos pos() { + return pos; + } + + public static int state() { + return state; + } +} diff --git a/src/main/java/za/co/neroland/nerospace/client/FallingMeteorModel.java b/src/main/java/za/co/neroland/nerospace/client/FallingMeteorModel.java new file mode 100644 index 0000000..2956caf --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/client/FallingMeteorModel.java @@ -0,0 +1,44 @@ +package za.co.neroland.nerospace.client; + +import net.minecraft.client.model.EntityModel; +import net.minecraft.client.model.geom.ModelLayerLocation; +import net.minecraft.client.model.geom.ModelPart; +import net.minecraft.client.model.geom.PartPose; +import net.minecraft.client.model.geom.builders.CubeListBuilder; +import net.minecraft.client.model.geom.builders.LayerDefinition; +import net.minecraft.client.model.geom.builders.MeshDefinition; +import net.minecraft.client.model.geom.builders.PartDefinition; +import net.minecraft.client.renderer.entity.state.EntityRenderState; +import net.minecraft.resources.Identifier; + +import za.co.neroland.nerospace.Nerospace; + +/** + * A lumpy meteor: a chunky charred core with a couple of bumps for an irregular silhouette. Built + * with the 26.1 {@code LayerDefinition} mesh API; the renderer tumbles it and the entity trails fire. + * Authored purely in Java (not registered with {@code model_sync.py}), so the build never depends on + * a Blockbench source for it. + */ +public class FallingMeteorModel extends EntityModel { + + public static final ModelLayerLocation LAYER = new ModelLayerLocation( + Identifier.fromNamespaceAndPath(Nerospace.MODID, "falling_meteor"), "main"); + + public FallingMeteorModel(ModelPart root) { + super(root); + } + + public static LayerDefinition createBodyLayer() { + MeshDefinition mesh = new MeshDefinition(); + PartDefinition root = mesh.getRoot(); + + root.addOrReplaceChild("core", + CubeListBuilder.create().texOffs(0, 0).addBox(-6F, -6F, -6F, 12F, 12F, 12F), + PartPose.offset(0.0F, 0.0F, 0.0F)); + root.addOrReplaceChild("bump", + CubeListBuilder.create().texOffs(0, 28).addBox(3F, -8F, -2F, 6F, 6F, 6F), + PartPose.offset(0.0F, 0.0F, 0.0F)); + + return LayerDefinition.create(mesh, 64, 64); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderState.java b/src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderState.java new file mode 100644 index 0000000..41cc3f5 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderState.java @@ -0,0 +1,10 @@ +package za.co.neroland.nerospace.client; + +import net.minecraft.client.renderer.entity.state.EntityRenderState; + +/** Render state for the falling meteor: an age value used to spin the rock. */ +public class FallingMeteorRenderState extends EntityRenderState { + + /** Entity age (ticks + partial) — drives the tumble rotation. */ + public float ticks; +} diff --git a/src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderer.java b/src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderer.java new file mode 100644 index 0000000..aa980ea --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/client/FallingMeteorRenderer.java @@ -0,0 +1,64 @@ +package za.co.neroland.nerospace.client; + +import com.mojang.blaze3d.vertex.PoseStack; +import com.mojang.math.Axis; + +import net.minecraft.client.renderer.SubmitNodeCollector; +import net.minecraft.client.renderer.entity.EntityRenderer; +import net.minecraft.client.renderer.entity.EntityRendererProvider; +import net.minecraft.client.renderer.rendertype.RenderType; +import net.minecraft.client.renderer.state.level.CameraRenderState; +import net.minecraft.client.renderer.texture.OverlayTexture; +import net.minecraft.resources.Identifier; + +import za.co.neroland.nerospace.Nerospace; +import za.co.neroland.nerospace.meteor.FallingMeteorEntity; + +/** + * Entity renderer for the falling meteor (meteor-events design §4). Draws {@link FallingMeteorModel} + * via the 26.1 submit pipeline, tumbling it on its age, at full brightness so the molten rock glows + * against the sky. The flame/smoke trail is spawned by the entity itself. + */ +public class FallingMeteorRenderer extends EntityRenderer { + + private static final Identifier TEXTURE = + Identifier.fromNamespaceAndPath(Nerospace.MODID, "textures/entity/falling_meteor.png"); + private static final int FULL_BRIGHT = 0x00F000F0; + + private final FallingMeteorModel model; + + public FallingMeteorRenderer(EntityRendererProvider.Context context) { + super(context); + this.model = new FallingMeteorModel(context.bakeLayer(FallingMeteorModel.LAYER)); + } + + @Override + public FallingMeteorRenderState createRenderState() { + return new FallingMeteorRenderState(); + } + + @Override + public void extractRenderState(FallingMeteorEntity meteor, FallingMeteorRenderState state, float partialTick) { + super.extractRenderState(meteor, state, partialTick); + state.ticks = meteor.tickCount + partialTick; + } + + @Override + public void submit(FallingMeteorRenderState state, PoseStack poseStack, SubmitNodeCollector collector, + CameraRenderState cameraState) { + poseStack.pushPose(); + // Standard entity-model orientation (flip into model space), then tumble on the age so the + // rock spins as it falls. + poseStack.scale(-1.0F, -1.0F, 1.0F); + poseStack.translate(0.0F, -0.7F, 0.0F); + poseStack.mulPose(Axis.YP.rotationDegrees(state.ticks * 7.0F)); + poseStack.mulPose(Axis.XP.rotationDegrees(state.ticks * 5.0F)); + + RenderType renderType = this.model.renderType(TEXTURE); + collector.order(0).submitModel(this.model, state, poseStack, renderType, + FULL_BRIGHT, OverlayTexture.NO_OVERLAY, -1, null, 0, null); + + poseStack.popPose(); + super.submit(state, poseStack, collector, cameraState); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/datagen/ModBlockLootSubProvider.java b/src/main/java/za/co/neroland/nerospace/datagen/ModBlockLootSubProvider.java index fa7d170..dd87be8 100644 --- a/src/main/java/za/co/neroland/nerospace/datagen/ModBlockLootSubProvider.java +++ b/src/main/java/za/co/neroland/nerospace/datagen/ModBlockLootSubProvider.java @@ -102,6 +102,10 @@ protected void generate() { dropSelf(ModBlocks.STATION_FLOOR.get()); dropSelf(ModBlocks.STATION_WALL.get()); + // Meteor events (meteor-events-design.md): rock drops itself; the core has noLootTable and + // spills its stored loot from the block entity on break (so it is intentionally omitted). + dropSelf(ModBlocks.METEOR_ROCK.get()); + // Developer diagnostics — Sentry test block (drops itself so it is reusable). dropSelf(ModBlocks.SENTRY_TEST.get()); } diff --git a/src/main/java/za/co/neroland/nerospace/datagen/ModBlockTagProvider.java b/src/main/java/za/co/neroland/nerospace/datagen/ModBlockTagProvider.java index 56dd8cd..743b8c4 100644 --- a/src/main/java/za/co/neroland/nerospace/datagen/ModBlockTagProvider.java +++ b/src/main/java/za/co/neroland/nerospace/datagen/ModBlockTagProvider.java @@ -63,7 +63,9 @@ protected void addTags(HolderLookup.Provider provider) { ModBlocks.QUARRY_CONTROLLER.get(), ModBlocks.QUARRY_LANDMARK.get(), ModBlocks.QUARRY_FRAME.get(), - ModBlocks.TRASH_CAN.get()); + ModBlocks.TRASH_CAN.get(), + ModBlocks.METEOR_ROCK.get(), + ModBlocks.METEOR_CORE.get()); this.tag(BlockTags.NEEDS_IRON_TOOL) .add(ModBlocks.NEROSIUM_ORE.get(), diff --git a/src/main/java/za/co/neroland/nerospace/datagen/ModItemTagProvider.java b/src/main/java/za/co/neroland/nerospace/datagen/ModItemTagProvider.java index a2977f7..9dd154b 100644 --- a/src/main/java/za/co/neroland/nerospace/datagen/ModItemTagProvider.java +++ b/src/main/java/za/co/neroland/nerospace/datagen/ModItemTagProvider.java @@ -93,5 +93,9 @@ protected void addTags(HolderLookup.Provider provider) { // vanilla ice would bypass the Glacira gate; packs can widen the tag. this.tag(ModTags.Items.HYDRATION_INPUT) .add(ModItems.GLACITE.get(), ModItems.GLACITE_BLOCK_ITEM.get()); + + // Meteor events (meteor-events-design.md): the alien loot family for the future scanner. + this.tag(ModTags.Items.ALIEN_MATERIALS) + .add(ModItems.ALIEN_FRAGMENT.get(), ModItems.ALIEN_TECH_SCRAP.get(), ModItems.ALIEN_CORE.get()); } } diff --git a/src/main/java/za/co/neroland/nerospace/datagen/ModLanguageProvider.java b/src/main/java/za/co/neroland/nerospace/datagen/ModLanguageProvider.java index 4a02fdd..fdc1ff5 100644 --- a/src/main/java/za/co/neroland/nerospace/datagen/ModLanguageProvider.java +++ b/src/main/java/za/co/neroland/nerospace/datagen/ModLanguageProvider.java @@ -252,6 +252,22 @@ protected void addTranslations() { add(ModItems.STRUTTER_DRUMSTICK.get(), "Strutter Drumstick"); add(ModItems.DRIFT_FLEECE.get(), "Drift Fleece"); + // Meteor events (meteor-events-design.md). + add(ModBlocks.METEOR_ROCK.get(), "Meteor Rock"); + add(ModBlocks.METEOR_CORE.get(), "Meteor Core"); + add(ModItems.ALIEN_FRAGMENT.get(), "Alien Fragment"); + add(ModItems.ALIEN_TECH_SCRAP.get(), "Alien Tech Scrap"); + add(ModItems.ALIEN_CORE.get(), "Alien Core"); + add(ModItems.METEOR_TRACKER.get(), "Meteor Tracker"); + add(ModItems.METEOR_CALLER.get(), "Meteor Caller"); + add("entity.nerospace.falling_meteor", "Meteor"); + add("item.nerospace.meteor_caller.called", "A meteor streaks down from the sky..."); + add("item.nerospace.meteor_caller.creative_only", "The Meteor Caller only works in Creative mode"); + add("item.nerospace.meteor_tracker.none", "Meteor Tracker: no meteors detected"); + add("item.nerospace.meteor_tracker.incoming", "Incoming"); + add("item.nerospace.meteor_tracker.landed", "Landed"); + add("item.nerospace.meteor_tracker.readout", "☄ Meteor %s — %s, %sm"); + // Entities. add("entity.nerospace.rocket", "Rocket"); add("entity.nerospace.xertz_stalker", "Xertz Stalker"); diff --git a/src/main/java/za/co/neroland/nerospace/datagen/ModModelProvider.java b/src/main/java/za/co/neroland/nerospace/datagen/ModModelProvider.java index a939f58..0c5c3dd 100644 --- a/src/main/java/za/co/neroland/nerospace/datagen/ModModelProvider.java +++ b/src/main/java/za/co/neroland/nerospace/datagen/ModModelProvider.java @@ -130,6 +130,10 @@ protected void registerModels(BlockModelGenerators blockModels, ItemModelGenerat blockModels.createTrivialCube(ModBlocks.GLACITE_ORE.get()); blockModels.createTrivialCube(ModBlocks.GLACITE_BLOCK.get()); + // Meteor events (meteor-events-design.md): crater rock + loot core (full cubes). + blockModels.createTrivialCube(ModBlocks.METEOR_ROCK.get()); + blockModels.createTrivialCube(ModBlocks.METEOR_CORE.get()); + // Phase 7c — station building blocks. blockModels.createTrivialCube(ModBlocks.STATION_FLOOR.get()); blockModels.createTrivialCube(ModBlocks.STATION_WALL.get()); @@ -177,6 +181,13 @@ protected void registerModels(BlockModelGenerators blockModels, ItemModelGenerat // Glacira (NEW_DESTINATION_DESIGN.md). itemModels.generateFlatItem(ModItems.GLACITE.get(), ModelTemplates.FLAT_ITEM); + // Meteor events (meteor-events-design.md): alien loot + tracker/caller. + itemModels.generateFlatItem(ModItems.ALIEN_FRAGMENT.get(), ModelTemplates.FLAT_ITEM); + itemModels.generateFlatItem(ModItems.ALIEN_TECH_SCRAP.get(), ModelTemplates.FLAT_ITEM); + itemModels.generateFlatItem(ModItems.ALIEN_CORE.get(), ModelTemplates.FLAT_ITEM); + itemModels.generateFlatItem(ModItems.METEOR_TRACKER.get(), ModelTemplates.FLAT_ITEM); + itemModels.generateFlatItem(ModItems.METEOR_CALLER.get(), ModelTemplates.FLAT_ITEM); + // Phase 7b — rocket fuel bucket. itemModels.generateFlatItem(ModItems.ROCKET_FUEL_BUCKET.get(), ModelTemplates.FLAT_ITEM); diff --git a/src/main/java/za/co/neroland/nerospace/meteor/FallingMeteorEntity.java b/src/main/java/za/co/neroland/nerospace/meteor/FallingMeteorEntity.java new file mode 100644 index 0000000..5dace10 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/FallingMeteorEntity.java @@ -0,0 +1,226 @@ +package za.co.neroland.nerospace.meteor; + +import net.minecraft.core.BlockPos; +import net.minecraft.core.particles.ParticleTypes; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.InterpolationHandler; +import net.minecraft.world.level.Level; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; +import net.minecraft.world.phys.Vec3; + +import za.co.neroland.nerospace.Config; +import za.co.neroland.nerospace.Nerospace; +import za.co.neroland.nerospace.registry.ModBlocks; +import za.co.neroland.nerospace.registry.ModEntities; + +/** + * A meteor falling from the sky (meteor-events design §4). A non-living, AI-less {@link Entity} + * (like the rocket) that descends on a diagonal arc toward a fixed crater centre, trailing flame and + * smoke, and on contact carves a small crater of {@code meteor_rock} around a loot-bearing + * {@code meteor_core}. All gameplay is server-authoritative; the client only renders the synced + * position + spins the rock and draws the trail. + * + *

Motion is recomputed each tick by aiming at the stored target at a fixed speed, so the meteor + * always lands exactly where it was scheduled and the descent survives a mid-flight save/reload + * without persisting velocity.

+ */ +public class FallingMeteorEntity extends Entity { + + /** Blocks above the target the meteor spawns at. */ + public static final int FALL_HEIGHT = 150; + /** Blocks travelled per tick (fast + dramatic). */ + private static final double SPEED = 1.7D; + + private int targetX; + private int targetY; + private int targetZ; + private long lootSeed; + + private final InterpolationHandler interpolation = new InterpolationHandler(this); + + @SuppressWarnings("this-escape") + public FallingMeteorEntity(EntityType type, Level level) { + super(type, level); + this.setNoGravity(true); + this.noPhysics = true; // we step the position manually; no vanilla collision pushback + } + + /** + * Spawns a meteor aimed at {@code target} (a surface block position) with RNG loot from + * {@code seed}. The spawn point is high above the target with a random horizontal offset so the + * descent reads as a diagonal arc. Server-side. + */ + public static FallingMeteorEntity spawn(ServerLevel level, BlockPos target, long seed) { + FallingMeteorEntity meteor = new FallingMeteorEntity(ModEntities.FALLING_METEOR.get(), level); + meteor.targetX = target.getX(); + meteor.targetY = target.getY(); + meteor.targetZ = target.getZ(); + meteor.lootSeed = seed; + + double angle = level.getRandom().nextDouble() * Math.PI * 2.0D; + double offset = FALL_HEIGHT * 0.45D; + double sx = target.getX() + 0.5D + Math.cos(angle) * offset; + double sz = target.getZ() + 0.5D + Math.sin(angle) * offset; + meteor.setPos(sx, target.getY() + FALL_HEIGHT, sz); + level.addFreshEntity(meteor); + level.playSound(null, target, SoundEvents.FIREWORK_ROCKET_LARGE_BLAST_FAR, SoundSource.AMBIENT, 4.0F, 0.6F); + return meteor; + } + + @Override + protected void defineSynchedData(net.minecraft.network.syncher.SynchedEntityData.Builder builder) { + // No synced data: the client renders from the tracked position + spins on tickCount. + } + + @Override + public InterpolationHandler getInterpolation() { + return this.interpolation; + } + + private Vec3 targetVec() { + return new Vec3(this.targetX + 0.5D, this.targetY + 0.5D, this.targetZ + 0.5D); + } + + @Override + public void tick() { + super.tick(); + + if (level().isClientSide()) { + if (this.interpolation.hasActiveInterpolation()) { + this.interpolation.interpolate(); + } + spawnTrail(); + return; + } + + Vec3 pos = position(); + Vec3 target = targetVec(); + Vec3 delta = target.subtract(pos); + double dist = delta.length(); + + // Impact when we reach the target column or drop to/below the crater surface. + if (dist <= SPEED || pos.y <= this.targetY + 0.5D) { + resolveImpact((ServerLevel) level()); + return; + } + + Vec3 step = delta.scale(SPEED / dist); + this.setDeltaMovement(step); // for client interpolation / rotation cues + this.setPos(pos.x + step.x, pos.y + step.y, pos.z + step.z); + } + + /** Flame + smoke trail, denser as the meteor nears the ground (client-side, per the design §4). */ + private void spawnTrail() { + double proximity = 1.0D - Math.min(1.0D, (getY() - this.targetY) / (double) FALL_HEIGHT); + int puffs = 2 + (int) (proximity * 4); + for (int i = 0; i < puffs; i++) { + double ox = (this.random.nextDouble() - 0.5D) * 0.8D; + double oy = (this.random.nextDouble() - 0.5D) * 0.8D; + double oz = (this.random.nextDouble() - 0.5D) * 0.8D; + level().addParticle(ParticleTypes.FLAME, getX() + ox, getY() + oy, getZ() + oz, 0.0D, 0.02D, 0.0D); + level().addParticle(ParticleTypes.LARGE_SMOKE, getX() + ox, getY() + 0.4D + oy, getZ() + oz, + 0.0D, 0.04D, 0.0D); + } + } + + /** + * Carve a small bowl crater (meteor-events design §4): clears the bowl interior to air, lines the + * floor with {@code meteor_rock}, and seats a loot-bearing {@code meteor_core} at the deepest + * point. Non-destructive beyond the radius, never touches bedrock, no fire or wide explosion. + */ + private void resolveImpact(ServerLevel level) { + int radius = Math.max(1, Config.METEOR_CRATER_RADIUS.get()); + BlockPos center = new BlockPos(this.targetX, this.targetY, this.targetZ); + BlockState rock = ModBlocks.METEOR_ROCK.get().defaultBlockState(); + + for (int dx = -radius - 1; dx <= radius + 1; dx++) { + for (int dz = -radius - 1; dz <= radius + 1; dz++) { + double horiz = Math.sqrt(dx * dx + dz * dz); + if (horiz > radius + 0.6D) { + continue; + } + int depth = Math.max(1, Math.round((float) (radius - horiz) * 0.7F)); + // Clear the open bowl above the floor. + for (int dy = -depth + 1; dy <= radius; dy++) { + if (Math.sqrt(dx * dx + dy * dy + dz * dz) > radius + 0.6D) { + continue; + } + BlockPos p = center.offset(dx, dy, dz); + if (!isProtected(level, p)) { + level.setBlock(p, Blocks.AIR.defaultBlockState(), 3); + } + } + // Line the bowl floor with meteor rock. + BlockPos floor = center.offset(dx, -depth, dz); + if (!isProtected(level, floor)) { + level.setBlock(floor, rock, 3); + } + } + } + + // The loot core sits at the deepest centre point. + int centerDepth = Math.max(1, Math.round(radius * 0.7F)); + BlockPos corePos = center.offset(0, -centerDepth, 0); + if (!isProtected(level, corePos)) { + level.setBlock(corePos, ModBlocks.METEOR_CORE.get().defaultBlockState(), 3); + if (level.getBlockEntity(corePos) instanceof MeteorCoreBlockEntity core) { + core.generateLoot(this.lootSeed); + } + } + + // Impact feedback (no terrain-damaging explosion). + level.sendParticles(ParticleTypes.EXPLOSION_EMITTER, center.getX() + 0.5D, center.getY() + 1.0D, + center.getZ() + 0.5D, 1, 0.0D, 0.0D, 0.0D, 0.0D); + level.sendParticles(ParticleTypes.LARGE_SMOKE, center.getX() + 0.5D, center.getY() + 1.0D, + center.getZ() + 0.5D, 40, radius, 1.0D, radius, 0.02D); + level.playSound(null, center.getX() + 0.5D, center.getY() + 0.5D, center.getZ() + 0.5D, + SoundEvents.GENERIC_EXPLODE, SoundSource.BLOCKS, 6.0F, 0.7F); + + MeteorEventManager.get(level).onImpact(center); + + if (Config.METEOR_DEBUG_LOG.get()) { + // POPIA/GDPR: coordinates + dimension only, never player identifiers. + Nerospace.LOGGER.info("[meteor] impact dim={} pos={} radius={}", level.dimension(), center, radius); + } + discard(); + } + + /** Bedrock and other unbreakable blocks (destroy speed < 0) are left untouched. */ + private static boolean isProtected(ServerLevel level, BlockPos pos) { + BlockState state = level.getBlockState(pos); + return state.is(Blocks.BEDROCK) || state.getDestroySpeed(level, pos) < 0.0F; + } + + @Override + public boolean isPickable() { + return false; + } + + @Override + public boolean hurtServer(ServerLevel level, net.minecraft.world.damagesource.DamageSource source, float amount) { + return false; // indestructible in flight + } + + @Override + protected void readAdditionalSaveData(ValueInput input) { + this.targetX = input.getIntOr("TargetX", 0); + this.targetY = input.getIntOr("TargetY", 0); + this.targetZ = input.getIntOr("TargetZ", 0); + this.lootSeed = input.getLongOr("LootSeed", 0L); + } + + @Override + protected void addAdditionalSaveData(ValueOutput output) { + output.putInt("TargetX", this.targetX); + output.putInt("TargetY", this.targetY); + output.putInt("TargetZ", this.targetZ); + output.putLong("LootSeed", this.lootSeed); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/meteor/MeteorCallerItem.java b/src/main/java/za/co/neroland/nerospace/meteor/MeteorCallerItem.java new file mode 100644 index 0000000..bd3c3a5 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/MeteorCallerItem.java @@ -0,0 +1,39 @@ +package za.co.neroland.nerospace.meteor; + +import net.minecraft.core.BlockPos; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.world.InteractionResult; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.context.UseOnContext; + +/** + * Creative-only Meteor Caller (meteor-events design §7): right-click a block to call a meteor down + * onto that spot with freshly rolled RNG loot — the same path natural spawning uses, on demand. + * Functions only for creative-mode players; in survival it does nothing (and says so). + */ +public class MeteorCallerItem extends Item { + + public MeteorCallerItem(Properties properties) { + super(properties); + } + + @Override + public InteractionResult useOn(UseOnContext context) { + Player player = context.getPlayer(); + if (player == null || !player.getAbilities().instabuild) { + if (player != null && !context.getLevel().isClientSide()) { + player.sendSystemMessage(Component.translatable("item.nerospace.meteor_caller.creative_only")); + } + return InteractionResult.PASS; + } + + if (context.getLevel() instanceof ServerLevel level) { + BlockPos target = context.getClickedPos(); + FallingMeteorEntity.spawn(level, target, level.getRandom().nextLong()); + player.sendSystemMessage(Component.translatable("item.nerospace.meteor_caller.called")); + } + return InteractionResult.SUCCESS; + } +} diff --git a/src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlock.java b/src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlock.java new file mode 100644 index 0000000..323ef47 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlock.java @@ -0,0 +1,39 @@ +package za.co.neroland.nerospace.meteor; + +import com.mojang.serialization.MapCodec; + +import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.BaseEntityBlock; +import net.minecraft.world.level.block.RenderShape; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; + +/** + * The Meteor Core (meteor-events design §5): the glowing block at the centre of a crater that holds + * the meteor's RNG loot. Break-to-loot — the stored stacks spill when the core is removed, driven by + * {@link MeteorCoreBlockEntity#preRemoveSideEffects} (the block has no loot table, so the rolled + * contents survive rather than a fresh roll). + */ +public class MeteorCoreBlock extends BaseEntityBlock { + + public static final MapCodec CODEC = simpleCodec(MeteorCoreBlock::new); + + public MeteorCoreBlock(Properties properties) { + super(properties); + } + + @Override + protected MapCodec codec() { + return CODEC; + } + + @Override + protected RenderShape getRenderShape(BlockState state) { + return RenderShape.MODEL; + } + + @Override + public BlockEntity newBlockEntity(BlockPos pos, BlockState state) { + return new MeteorCoreBlockEntity(pos, state); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlockEntity.java b/src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlockEntity.java new file mode 100644 index 0000000..13c12eb --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/MeteorCoreBlockEntity.java @@ -0,0 +1,79 @@ +package za.co.neroland.nerospace.meteor; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.core.BlockPos; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.sounds.SoundEvents; +import net.minecraft.sounds.SoundSource; +import net.minecraft.util.RandomSource; +import net.minecraft.world.Containers; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.block.entity.BlockEntity; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.storage.ValueInput; +import net.minecraft.world.level.storage.ValueOutput; + +import za.co.neroland.nerospace.Config; +import za.co.neroland.nerospace.registry.ModBlockEntities; + +/** + * The "box in the middle" of a meteor (meteor-events design §5): stores the loot rolled once when + * the meteor lands, so contents are fixed per meteor (no re-roll exploit) and identical for every + * player who reaches it. v1 is break-to-loot — {@link MeteorCoreBlock} spills these stacks when the + * core is broken; a clickable container GUI is a noted follow-up. + */ +public class MeteorCoreBlockEntity extends BlockEntity { + + private final List loot = new ArrayList<>(); + + public MeteorCoreBlockEntity(BlockPos pos, BlockState state) { + super(ModBlockEntities.METEOR_CORE.get(), pos, state); + } + + /** Rolls and stores loot from {@code seed} (idempotent — only the first non-empty roll sticks). */ + public void generateLoot(long seed) { + if (!this.loot.isEmpty()) { + return; + } + this.loot.addAll(MeteorLoot.roll(RandomSource.create(seed), Config.METEOR_LOOT_BONUS_ROLLS.get())); + setChanged(); + } + + /** + * Break-to-loot: spill the stored stacks the moment the core is removed (mirrors the Station + * Core's charter pop). The block has no loot table, so this is the only drop path — the rolled + * contents survive rather than a fresh roll. + */ + @Override + public void preRemoveSideEffects(BlockPos pos, BlockState state) { + super.preRemoveSideEffects(pos, state); + if (!(this.level instanceof ServerLevel serverLevel)) { + return; + } + for (ItemStack stack : this.loot) { + if (!stack.isEmpty()) { + Containers.dropItemStack(serverLevel, + pos.getX() + 0.5, pos.getY() + 0.5, pos.getZ() + 0.5, stack.copy()); + } + } + if (!this.loot.isEmpty()) { + serverLevel.playSound(null, pos, SoundEvents.AMETHYST_BLOCK_BREAK, SoundSource.BLOCKS, 1.0F, 0.7F); + } + this.loot.clear(); + } + + @Override + protected void saveAdditional(ValueOutput output) { + super.saveAdditional(output); + output.store("Loot", ItemStack.OPTIONAL_CODEC.listOf(), this.loot); + } + + @Override + protected void loadAdditional(ValueInput input) { + super.loadAdditional(input); + this.loot.clear(); + this.loot.addAll(input.read("Loot", ItemStack.OPTIONAL_CODEC.listOf()).orElse(List.of())); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/meteor/MeteorEventManager.java b/src/main/java/za/co/neroland/nerospace/meteor/MeteorEventManager.java new file mode 100644 index 0000000..367d9ae --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/MeteorEventManager.java @@ -0,0 +1,203 @@ +package za.co.neroland.nerospace.meteor; + +import java.util.ArrayList; +import java.util.Iterator; +import java.util.List; + +import javax.annotation.Nullable; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.core.BlockPos; +import net.minecraft.resources.Identifier; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.level.levelgen.Heightmap; +import net.minecraft.world.level.saveddata.SavedData; +import net.minecraft.world.level.saveddata.SavedDataType; + +import za.co.neroland.nerospace.Config; +import za.co.neroland.nerospace.Nerospace; + +/** + * Per-{@link ServerLevel} driver + persistent state for natural meteor events (meteor-events design + * §3). Holds the live impact sites and a cooldown, schedules a rare meteor near a random online + * player when the cooldown elapses, advances each site (SCHEDULED → spawns the falling entity → + * LANDED), and answers "nearest site" for the tracker compass. + * + *

Modelled on {@link za.co.neroland.nerospace.world.OxygenFieldManager}: only the sites + cooldown + * are persisted via the {@link SavedDataType} codec; everything reconverges from them on load.

+ */ +public final class MeteorEventManager extends SavedData { + + public static final Identifier ID = Identifier.fromNamespaceAndPath(Nerospace.MODID, "meteor_events"); + + public static final SavedDataType TYPE = + new SavedDataType<>(ID, MeteorEventManager::new, codec()); + + /** Ticks a landed site lingers so the tracker can still lead players to a fresh crater (5 min). */ + private static final int LANDED_EXPIRY_TICKS = 6000; + /** Failsafe: drop a FALLING site if its entity never reports impact (e.g. unloaded). */ + private static final int FALLING_TIMEOUT_TICKS = 600; + + private final List sites; + private int cooldown; + + public MeteorEventManager() { + this(new ArrayList<>(), 0); + } + + private MeteorEventManager(List sites, int cooldown) { + this.sites = new ArrayList<>(sites); + this.cooldown = cooldown; + } + + private static Codec codec() { + return RecordCodecBuilder.create(inst -> inst.group( + MeteorSite.CODEC.listOf().fieldOf("sites").forGetter(m -> m.sites), + Codec.INT.fieldOf("cooldown").forGetter(m -> m.cooldown) + ).apply(inst, MeteorEventManager::new)); + } + + public static MeteorEventManager get(ServerLevel level) { + return level.getDataStorage().computeIfAbsent(TYPE); + } + + // --- Tick driver -------------------------------------------------------- + + /** One server tick on an eligible dimension (called from {@link MeteorEvents}). */ + public void tick(ServerLevel level) { + boolean dirty = scheduleIfDue(level); + dirty |= advanceSites(level); + if (dirty) { + setDirty(); + } + } + + private boolean scheduleIfDue(ServerLevel level) { + if (!Config.METEOR_NATURAL_SPAWN.get() || level.players().isEmpty()) { + return false; + } + if (this.cooldown > 0) { + this.cooldown--; + return this.cooldown % 200 == 0; // persist roughly once every 10s of countdown + } + // Cooldown elapsed: schedule one meteor near a random online player (rarity is global per level). + this.cooldown = nextInterval(level); + if (countByState(MeteorSite.SCHEDULED, MeteorSite.FALLING) >= Config.METEOR_MAX_ACTIVE_SITES.get()) { + return true; + } + ServerPlayer anchor = level.players().get(level.getRandom().nextInt(level.players().size())); + BlockPos target = pickTarget(level, anchor); + if (target != null) { + this.sites.add(new MeteorSite(target.asLong(), MeteorSite.SCHEDULED, + Math.max(1, Config.METEOR_WARNING_SECONDS.get() * 20))); + if (Config.METEOR_DEBUG_LOG.get()) { + Nerospace.LOGGER.info("[meteor] scheduled dim={} target={}", level.dimension(), target); + } + } + return true; + } + + private boolean advanceSites(ServerLevel level) { + boolean dirty = false; + Iterator it = this.sites.iterator(); + while (it.hasNext()) { + MeteorSite site = it.next(); + switch (site.state) { + case MeteorSite.SCHEDULED -> { + if (--site.timer <= 0) { + FallingMeteorEntity.spawn(level, site.blockPos(), level.getRandom().nextLong()); + site.state = MeteorSite.FALLING; + site.timer = FALLING_TIMEOUT_TICKS; + dirty = true; + } + } + case MeteorSite.FALLING -> { + if (--site.timer <= 0) { + it.remove(); // failsafe: entity never impacted + dirty = true; + } + } + case MeteorSite.LANDED -> { + if (--site.timer <= 0) { + it.remove(); + dirty = true; + } + } + default -> it.remove(); + } + } + return dirty; + } + + /** Called by {@link FallingMeteorEntity} on impact: flip the matching site to LANDED (or add one). */ + public void onImpact(BlockPos pos) { + for (MeteorSite site : this.sites) { + if (site.state != MeteorSite.LANDED && site.blockPos().closerThan(pos, 8.0D)) { + site.state = MeteorSite.LANDED; + site.timer = LANDED_EXPIRY_TICKS; + site.pos = pos.asLong(); + setDirty(); + return; + } + } + // Creative-spawned (or unscheduled) meteor: add a transient landed site for the tracker. + this.sites.add(new MeteorSite(pos.asLong(), MeteorSite.LANDED, LANDED_EXPIRY_TICKS)); + setDirty(); + } + + /** Nearest tracked site to {@code from} (any state), or {@code null} if none. For the tracker. */ + @Nullable + public MeteorSite nearestSite(BlockPos from) { + MeteorSite best = null; + double bestSq = Double.MAX_VALUE; + for (MeteorSite site : this.sites) { + double sq = site.blockPos().distSqr(from); + if (sq < bestSq) { + bestSq = sq; + best = site; + } + } + return best; + } + + // --- Helpers ------------------------------------------------------------ + + private int countByState(int... states) { + int n = 0; + for (MeteorSite site : this.sites) { + for (int s : states) { + if (site.state == s) { + n++; + break; + } + } + } + return n; + } + + private static int nextInterval(ServerLevel level) { + int avg = Math.max(1, Config.METEOR_AVG_INTERVAL_SECONDS.get()) * 20; + // Spread 0.66x .. 1.33x of the average so impacts feel irregular. + return (int) (avg * 0.66D) + level.getRandom().nextInt(Math.max(1, (int) (avg * 0.67D))); + } + + @Nullable + private static BlockPos pickTarget(ServerLevel level, ServerPlayer anchor) { + int min = Math.max(0, Config.METEOR_MIN_DISTANCE.get()); + int max = Math.max(min + 1, Config.METEOR_MAX_DISTANCE.get()); + double angle = level.getRandom().nextDouble() * Math.PI * 2.0D; + double d = min + level.getRandom().nextDouble() * (max - min); + int x = (int) Math.floor(anchor.getX() + Math.cos(angle) * d); + int z = (int) Math.floor(anchor.getZ() + Math.sin(angle) * d); + // getHeight loads/generates the target chunk — acceptable for a rare event. + int surfaceAir = level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, x, z); + int groundY = surfaceAir - 1; + if (groundY <= level.getMinY() + 1) { + return null; // void / no terrain + } + return new BlockPos(x, groundY, z); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/meteor/MeteorEvents.java b/src/main/java/za/co/neroland/nerospace/meteor/MeteorEvents.java new file mode 100644 index 0000000..6382ba4 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/MeteorEvents.java @@ -0,0 +1,69 @@ +package za.co.neroland.nerospace.meteor; + +import java.util.Set; + +import net.minecraft.resources.ResourceKey; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.Level; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.tick.LevelTickEvent; +import net.neoforged.neoforge.network.PacketDistributor; + +import za.co.neroland.nerospace.Nerospace; +import za.co.neroland.nerospace.network.MeteorSyncPayload; +import za.co.neroland.nerospace.registry.ModDimensions; +import za.co.neroland.nerospace.registry.ModItems; + +/** + * Server-side driver for meteor events (meteor-events design §3/§6): ticks the per-level + * {@link MeteorEventManager} on eligible surface dimensions and pushes the nearest-site snapshot to + * any player holding a Meteor Tracker. Cheap when idle — the manager short-circuits with no players. + */ +@EventBusSubscriber(modid = Nerospace.MODID) +public final class MeteorEvents { + + /** Surface worlds meteors fall on (the void station is excluded — nothing to crater). */ + public static final Set> METEOR_DIMENSIONS = Set.of( + Level.OVERWORLD, + ModDimensions.GREENXERTZ_LEVEL, + ModDimensions.CINDARA_LEVEL, + ModDimensions.GLACIRA_LEVEL); + + /** How often the tracker snapshot is pushed to holders. */ + private static final int SYNC_INTERVAL_TICKS = 10; + + private MeteorEvents() { + } + + @SubscribeEvent + public static void onLevelTick(LevelTickEvent.Post event) { + if (!(event.getLevel() instanceof ServerLevel level) || !METEOR_DIMENSIONS.contains(level.dimension())) { + return; + } + MeteorEventManager manager = MeteorEventManager.get(level); + manager.tick(level); + + if (level.getGameTime() % SYNC_INTERVAL_TICKS == 0) { + for (ServerPlayer player : level.players()) { + if (!holdsTracker(player)) { + continue; + } + MeteorSite nearest = manager.nearestSite(player.blockPosition()); + PacketDistributor.sendToPlayer(player, nearest == null + ? MeteorSyncPayload.ABSENT + : new MeteorSyncPayload(true, nearest.pos, nearest.state)); + } + } + } + + private static boolean holdsTracker(ServerPlayer player) { + return isTracker(player.getMainHandItem()) || isTracker(player.getOffhandItem()); + } + + private static boolean isTracker(ItemStack stack) { + return stack.is(ModItems.METEOR_TRACKER.get()); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/meteor/MeteorLoot.java b/src/main/java/za/co/neroland/nerospace/meteor/MeteorLoot.java new file mode 100644 index 0000000..2d76f1b --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/MeteorLoot.java @@ -0,0 +1,71 @@ +package za.co.neroland.nerospace.meteor; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.util.RandomSource; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.level.ItemLike; + +import za.co.neroland.nerospace.registry.ModItems; + +/** + * RNG contents of a meteor core (meteor-events design §5). Rolling is deterministic for a given + * seed, so the {@link MeteorCoreBlockEntity} can roll once on placement and store the result — all + * players who reach the meteor see identical loot and there is no re-roll exploit. + * + *

v1 keeps the loot table in code (sensible defaults) with a few config knobs in {@link + * za.co.neroland.nerospace.Config}; a data-driven loot table is a clean follow-up. Every meteor + * guarantees a handful of {@code alien_fragment} (the future scanner feedstock) plus a number of + * weighted bonus rolls drawn from existing raw ores and the rarer alien items.

+ */ +public final class MeteorLoot { + + /** A single weighted entry: an item, how many to give, and its selection weight. */ + private record Entry(ItemLike item, int min, int max, int weight) { + int roll(RandomSource rng) { + return this.min >= this.max ? this.min : this.min + rng.nextInt(this.max - this.min + 1); + } + } + + private MeteorLoot() { + } + + /** The weighted bonus pool (existing ores are common; alien tech/core are the rare prizes). */ + private static List pool() { + List pool = new ArrayList<>(); + pool.add(new Entry(ModItems.RAW_NEROSIUM.get(), 2, 5, 30)); + pool.add(new Entry(ModItems.RAW_NEROSTEEL.get(), 2, 5, 24)); + pool.add(new Entry(ModItems.XERTZ_QUARTZ.get(), 1, 4, 18)); + pool.add(new Entry(ModItems.ALIEN_FRAGMENT.get(), 2, 4, 16)); + pool.add(new Entry(ModItems.ALIEN_TECH_SCRAP.get(), 1, 2, 9)); + pool.add(new Entry(ModItems.ALIEN_CORE.get(), 1, 1, 3)); + return pool; + } + + /** + * Rolls a fresh set of stacks for a meteor core. + * + * @param rng seeded source (use {@code RandomSource.create(seed)} for reproducibility) + * @param bonusRolls number of weighted bonus rolls on top of the guaranteed fragments + */ + public static List roll(RandomSource rng, int bonusRolls) { + List out = new ArrayList<>(); + // Guaranteed: a handful of alien fragments — every meteor seeds the scanner economy. + out.add(new ItemStack(ModItems.ALIEN_FRAGMENT.get(), 3 + rng.nextInt(4))); + + List pool = pool(); + int totalWeight = pool.stream().mapToInt(Entry::weight).sum(); + for (int i = 0; i < bonusRolls; i++) { + int pick = rng.nextInt(totalWeight); + for (Entry e : pool) { + pick -= e.weight(); + if (pick < 0) { + out.add(new ItemStack(e.item(), e.roll(rng))); + break; + } + } + } + return out; + } +} diff --git a/src/main/java/za/co/neroland/nerospace/meteor/MeteorSite.java b/src/main/java/za/co/neroland/nerospace/meteor/MeteorSite.java new file mode 100644 index 0000000..3526533 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/meteor/MeteorSite.java @@ -0,0 +1,41 @@ +package za.co.neroland.nerospace.meteor; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.minecraft.core.BlockPos; + +/** + * One tracked meteor impact site (meteor-events design §3). Mutable so the {@link MeteorEventManager} + * can advance its state/timer in place each tick. {@code timer} means "ticks until the fall" while + * {@link #SCHEDULED}, and "ticks until this record expires" once {@link #LANDED}. + */ +public final class MeteorSite { + + /** Scheduled near a player; the tracker shows it as incoming during the warning window. */ + public static final int SCHEDULED = 0; + /** The meteor entity is descending. */ + public static final int FALLING = 1; + /** Landed — the crater + loot core exist; kept briefly so the tracker leads players in. */ + public static final int LANDED = 2; + + public static final Codec CODEC = RecordCodecBuilder.create(inst -> inst.group( + Codec.LONG.fieldOf("pos").forGetter(s -> s.pos), + Codec.INT.fieldOf("state").forGetter(s -> s.state), + Codec.INT.fieldOf("timer").forGetter(s -> s.timer) + ).apply(inst, MeteorSite::new)); + + public long pos; + public int state; + public int timer; + + public MeteorSite(long pos, int state, int timer) { + this.pos = pos; + this.state = state; + this.timer = timer; + } + + public BlockPos blockPos() { + return BlockPos.of(this.pos); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/network/MeteorSyncPayload.java b/src/main/java/za/co/neroland/nerospace/network/MeteorSyncPayload.java new file mode 100644 index 0000000..13307d0 --- /dev/null +++ b/src/main/java/za/co/neroland/nerospace/network/MeteorSyncPayload.java @@ -0,0 +1,34 @@ +package za.co.neroland.nerospace.network; + +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.ByteBufCodecs; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.protocol.common.custom.CustomPacketPayload; +import net.minecraft.resources.Identifier; + +import za.co.neroland.nerospace.Nerospace; + +/** + * Server → client nearest-meteor snapshot for the Meteor Tracker (meteor-events design §6). Pushed + * only to players holding a tracker. {@code present} false means "no tracked meteor" (the needle + * idles); otherwise the packed position + {@link za.co.neroland.nerospace.meteor.MeteorSite} state + * let the client draw direction, distance and incoming/landed status. + */ +public record MeteorSyncPayload(boolean present, long pos, int state) implements CustomPacketPayload { + + public static final Type TYPE = + new Type<>(Identifier.fromNamespaceAndPath(Nerospace.MODID, "meteor_sync")); + + public static final StreamCodec STREAM_CODEC = StreamCodec.composite( + ByteBufCodecs.BOOL, MeteorSyncPayload::present, + ByteBufCodecs.VAR_LONG, MeteorSyncPayload::pos, + ByteBufCodecs.VAR_INT, MeteorSyncPayload::state, + MeteorSyncPayload::new); + + public static final MeteorSyncPayload ABSENT = new MeteorSyncPayload(false, 0L, 0); + + @Override + public Type type() { + return TYPE; + } +} diff --git a/src/main/java/za/co/neroland/nerospace/network/ModNetwork.java b/src/main/java/za/co/neroland/nerospace/network/ModNetwork.java index 4616710..e0afbbb 100644 --- a/src/main/java/za/co/neroland/nerospace/network/ModNetwork.java +++ b/src/main/java/za/co/neroland/nerospace/network/ModNetwork.java @@ -5,6 +5,7 @@ import net.neoforged.neoforge.network.event.RegisterPayloadHandlersEvent; import za.co.neroland.nerospace.Nerospace; +import za.co.neroland.nerospace.client.ClientMeteorTracker; import za.co.neroland.nerospace.client.ClientOxygenField; /** @@ -25,6 +26,10 @@ public static void register(RegisterPayloadHandlersEvent event) { .playToClient( OxygenFieldSyncPayload.TYPE, OxygenFieldSyncPayload.STREAM_CODEC, - (payload, context) -> context.enqueueWork(() -> ClientOxygenField.accept(payload))); + (payload, context) -> context.enqueueWork(() -> ClientOxygenField.accept(payload))) + .playToClient( + MeteorSyncPayload.TYPE, + MeteorSyncPayload.STREAM_CODEC, + (payload, context) -> context.enqueueWork(() -> ClientMeteorTracker.accept(payload))); } } diff --git a/src/main/java/za/co/neroland/nerospace/registry/ModBlockEntities.java b/src/main/java/za/co/neroland/nerospace/registry/ModBlockEntities.java index 4238e0c..9a61e5f 100644 --- a/src/main/java/za/co/neroland/nerospace/registry/ModBlockEntities.java +++ b/src/main/java/za/co/neroland/nerospace/registry/ModBlockEntities.java @@ -179,6 +179,14 @@ public final class ModBlockEntities { false, ModBlocks.STAR_GUIDE.get())); + // Meteor Core (meteor-events-design.md §5): stores the rolled loot, break-to-loot. + public static final Supplier> METEOR_CORE = + BLOCK_ENTITY_TYPES.register("meteor_core", + () -> new BlockEntityType<>( + za.co.neroland.nerospace.meteor.MeteorCoreBlockEntity::new, + false, + ModBlocks.METEOR_CORE.get())); + private ModBlockEntities() { } diff --git a/src/main/java/za/co/neroland/nerospace/registry/ModBlocks.java b/src/main/java/za/co/neroland/nerospace/registry/ModBlocks.java index 5a054d2..3f92627 100644 --- a/src/main/java/za/co/neroland/nerospace/registry/ModBlocks.java +++ b/src/main/java/za/co/neroland/nerospace/registry/ModBlocks.java @@ -506,6 +506,36 @@ public final class ModBlocks { .strength(100.0F) .noLootTable()); + // --- Meteor events (meteor-events-design.md) ---------------------------- + + /** + * Meteor Rock: the charred crater body left by an impact. A mineable space-rock building block + * (any pickaxe), faintly glowing with alien heat. Drops itself. + */ + public static final DeferredBlock METEOR_ROCK = BLOCKS.registerSimpleBlock( + "meteor_rock", + props -> props + .mapColor(MapColor.COLOR_BLACK) + .strength(3.0F, 4.0F) + .requiresCorrectToolForDrops() + .lightLevel(state -> 3) + .sound(SoundType.STONE)); + + /** + * Meteor Core: the loot-bearing block at a crater's centre (meteor-events design §5). Backed by + * {@link za.co.neroland.nerospace.meteor.MeteorCoreBlockEntity}; break-to-loot, so it has no loot + * table — the rolled contents spill from the block entity on removal. + */ + public static final DeferredBlock METEOR_CORE = + BLOCKS.registerBlock("meteor_core", za.co.neroland.nerospace.meteor.MeteorCoreBlock::new, + props -> props + .mapColor(MapColor.COLOR_CYAN) + .strength(4.0F, 6.0F) + .requiresCorrectToolForDrops() + .lightLevel(state -> 10) + .sound(SoundType.METAL) + .noLootTable()); + // --- Developer diagnostics ---------------------------------------------- /** diff --git a/src/main/java/za/co/neroland/nerospace/registry/ModCreativeModeTabs.java b/src/main/java/za/co/neroland/nerospace/registry/ModCreativeModeTabs.java index 87bdc86..124239b 100644 --- a/src/main/java/za/co/neroland/nerospace/registry/ModCreativeModeTabs.java +++ b/src/main/java/za/co/neroland/nerospace/registry/ModCreativeModeTabs.java @@ -142,6 +142,15 @@ public final class ModCreativeModeTabs { output.accept(ModItems.STRUTTER_DRUMSTICK.get()); output.accept(ModItems.DRIFT_FLEECE.get()); + // Meteor events (meteor-events-design.md). + output.accept(ModItems.ALIEN_FRAGMENT.get()); + output.accept(ModItems.ALIEN_TECH_SCRAP.get()); + output.accept(ModItems.ALIEN_CORE.get()); + output.accept(ModBlocks.METEOR_ROCK.get()); + output.accept(ModBlocks.METEOR_CORE.get()); + output.accept(ModItems.METEOR_TRACKER.get()); + output.accept(ModItems.METEOR_CALLER.get()); + // Creative-only travel devices (no survival recipe). output.accept(ModItems.GREENXERTZ_NAVIGATOR.get()); output.accept(ModItems.STATION_COMPASS.get()); diff --git a/src/main/java/za/co/neroland/nerospace/registry/ModEntities.java b/src/main/java/za/co/neroland/nerospace/registry/ModEntities.java index cbf12a0..9094c93 100644 --- a/src/main/java/za/co/neroland/nerospace/registry/ModEntities.java +++ b/src/main/java/za/co/neroland/nerospace/registry/ModEntities.java @@ -101,6 +101,18 @@ public final class ModEntities { MobCategory.CREATURE, builder -> builder.sized(0.9F, 1.2F).eyeHeight(1.0F).clientTrackingRange(8)); + // --- Meteor events (meteor-events-design.md) ---------------------------- + + /** The falling meteor: a non-living projectile (like the rocket) that craters on impact. */ + public static final Supplier> FALLING_METEOR = + ENTITY_TYPES.registerEntityType( + "falling_meteor", + za.co.neroland.nerospace.meteor.FallingMeteorEntity::new, + MobCategory.MISC, + builder -> builder.sized(1.4F, 1.4F).eyeHeight(0.7F) + // It spawns ~150 blocks up, so a generous tracking range lets players see it fall. + .clientTrackingRange(12).updateInterval(2)); + private ModEntities() { } diff --git a/src/main/java/za/co/neroland/nerospace/registry/ModItems.java b/src/main/java/za/co/neroland/nerospace/registry/ModItems.java index d598244..1f404ff 100644 --- a/src/main/java/za/co/neroland/nerospace/registry/ModItems.java +++ b/src/main/java/za/co/neroland/nerospace/registry/ModItems.java @@ -71,6 +71,23 @@ public final class ModItems { /** Glacite gem — dropped by glacite ore on Glacira; feeds future suit variants/terraforming. */ public static final DeferredItem GLACITE = ITEMS.registerSimpleItem("glacite"); + // --- Meteor events (meteor-events-design.md) ---------------------------- + // Tiered "alien" loot from meteor cores; the fragment is the future scanner feedstock. + /** Common meteor loot; future Scanner input. */ + public static final DeferredItem ALIEN_FRAGMENT = ITEMS.registerSimpleItem("alien_fragment"); + /** Uncommon meteor loot; future upgrade crafting. */ + public static final DeferredItem ALIEN_TECH_SCRAP = ITEMS.registerSimpleItem("alien_tech_scrap"); + /** Rare meteor loot; high-value scanner/upgrade gate. */ + public static final DeferredItem ALIEN_CORE = ITEMS.registerSimpleItem("alien_core"); + + /** Meteor Tracker — points to the nearest tracked meteor (held readout; no survival recipe yet). */ + public static final DeferredItem METEOR_TRACKER = ITEMS.registerItem( + "meteor_tracker", props -> new Item(props.stacksTo(1))); + + /** Creative-only Meteor Caller — right-click a block to call a meteor down onto it. */ + public static final DeferredItem METEOR_CALLER = ITEMS.registerItem( + "meteor_caller", props -> new za.co.neroland.nerospace.meteor.MeteorCallerItem(props.stacksTo(1))); + // --- Tools -------------------------------------------------------------- public static final DeferredItem NEROSIUM_PICKAXE = ITEMS.registerItem( @@ -296,6 +313,12 @@ public final class ModItems { public static final DeferredItem GLACITE_BLOCK_ITEM = ITEMS.registerSimpleBlockItem(ModBlocks.GLACITE_BLOCK); + // Meteor events (meteor-events-design.md) block items. + public static final DeferredItem METEOR_ROCK_ITEM = + ITEMS.registerSimpleBlockItem(ModBlocks.METEOR_ROCK); + public static final DeferredItem METEOR_CORE_ITEM = + ITEMS.registerSimpleBlockItem(ModBlocks.METEOR_CORE); + // Phase 7c — station block items. public static final DeferredItem STATION_FLOOR_ITEM = ITEMS.registerSimpleBlockItem(ModBlocks.STATION_FLOOR); diff --git a/src/main/java/za/co/neroland/nerospace/registry/ModTags.java b/src/main/java/za/co/neroland/nerospace/registry/ModTags.java index 81426db..7bc43bd 100644 --- a/src/main/java/za/co/neroland/nerospace/registry/ModTags.java +++ b/src/main/java/za/co/neroland/nerospace/registry/ModTags.java @@ -106,5 +106,9 @@ private Items() { * widen it. */ public static final TagKey HYDRATION_INPUT = itemTag("nerospace", "hydration_input"); + + // Meteor events (meteor-events-design.md): the tiered "alien" loot, grouped for the future + // scanner/upgrade system so recipes can accept the family. + public static final TagKey ALIEN_MATERIALS = itemTag("nerospace", "alien_materials"); } } diff --git a/src/main/resources/assets/nerospace/textures/block/meteor_core.png b/src/main/resources/assets/nerospace/textures/block/meteor_core.png new file mode 100644 index 0000000000000000000000000000000000000000..19d2727f5fef6cdb238b84e264651ba8ef718a32 GIT binary patch literal 509 zcmVsuW?XJi@Y%u=o-70n((&6S&d?s1R6% z2&ufn3}RBIath}T(vnkMq{N&1zFP^=(;u$h3tKuNOPjr z1zHz4R{>l*7{?PKoRLw0BpTxcfcG`ycv^@8tN}te<6K48wRByJa}~$ElG*^g-=7jm z#6mc;?z)y3r!;?z6W_o5Vrw23m5%ohZ0{p4k!f9EZHX}(Vw`w>x=rL=S+OgxBW=wi z$GyV)nx;9VVwo`;hM`Y03wCA2=hv@NERaq^IAd)|HjXEh%8^dy`1box5)p_uB+iDR zPn$MolM;XQZ4!|fuFoms0<27NbCIoz$bXkOKJe!2Pk@CxSN`3Hi&%5s+Vj)xLSTCz z7giSQuS{`s;RWWq2;q#@MG{sjPh@LLGVg1=uTd&bM6t9~8iszcpSkrjd72oflq3mK zNvV8cH0P>xwQEtDdtB=xowWD$V!ZjWIal!)+=urAUCIVP00000NkvXXu0mjfr{Uwl literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/nerospace/textures/block/meteor_rock.png b/src/main/resources/assets/nerospace/textures/block/meteor_rock.png new file mode 100644 index 0000000000000000000000000000000000000000..0df9be9802d3906b04cc7a11844289dd02696386 GIT binary patch literal 515 zcmV+e0{s1nP)l6=LHeB1g42POTWo ziXlYJ4ns5aKe!XSRks^RsiwMK)vNdF;@#W#55v$C;|M@X6J=T9TtVAz;rlJzEIEr~ z&ik4SpzC&cUjwjMTmcY51AzVX8SiVX&9OEor3t^j#oC;<-IBHK7Gq|#?e>sdEpe_m zfNieLx^72|gj#!)HTtOyN1$;`gEGxPXPo7H?VKWRpnOxkDo4wHFth8vqnX_12snYw+9Ajp5-Hu`C zxxT-nZMTHiZMc}m;p(BnwMh|z%eVa?nq8O+w=PHUp?4c~@>UOtdJ)WuSyVy@LJ&E;~%NzB2 zJ4(;U?>gmVtg5<=O>;0NWZ~KDLW{*^OqOL^8V>hjv0iUSlJxD()A)I@xQxY$VxVq! zJ2rr@y4`KfW*3^xF4XOAtFG_V>+R^vG}CfoNlP1f-dDG~tvv6m*V_rrvvCB(n9VL!Rktc9V->|f z&!>-6RkyJ?qb%nkq;7XR)~Kpm4TpQ704(M4>#wmV*EkmDRyZ8)#WRb=Wh2|E|q0ldhz*Fyw^z&)9dYo_GYt-mI)!{s=AE@5lp>*sO9n|%rZ4M<}b^69E@k# zxSMqPZkTws)jIsy=sNa|a1bJgMi6}eZg)54?YPwPydRp&^S*||z1C(iSvDgn%#_K| zHvcb|H_G$Aiek{x6!R^YH{oW?Ti17Sz+P`h)$x&}l^f-6PeK_7r%zfjhk+u8!@Uq% zQ4GReBq@J;q6h!}^W_gV%P$c|Ro&{v=TDmbv9DflM`bzJi_f3*;c~8e|1hM=ducFZ z(^!$4th*9!_HpuE&!>;#0JGUeXp#M3Aqa|f(=~YhXI6yUchI6gg!y&5+giM8g5+#= zp`sY*-R^Egeb*?FnmOwFE;h^iS+=D-?}v#KNTtRK4^E$`s&2#m_+FBR|C~!&#UNjo z!8wM*y@)qorkU#cPG6>3%SxG}|oxhI^g0OH0Ec*HMk+N(nEDMVv&OPYw4t}_4AQ=wk7Ln#)*F5jK zMtFyL7?|}YS4hp?&EMC1d3B}PANw&6c){1?c~67H9?tY&_}lM4B&`7+mnKo)H{}}k z6!h)QQ$6ebC(;myB32-1K9kZM%>3+`v(dF4zy7M1S6BLQIae_~Sp!e$XMQG=xgg7O z-Ut8}aL|*cAqOEIb@5Txs;5J&#)apY!mZ8`Tb}ou=sz0Af!qZ=NT!EH%Lvk~f&H+* z&SG(?ax!lDcN#3nvmCanZevjbna2YE%%(ZK)HYG6agVCHjet2E?lq}-|1kU&0aev) zSd7#JM^B%N#ifd35NX`gHZg`#mh&bJx*54o@h}=gJLz{HC*O5&`eaRL3@j#fGY3Lg z7}~?fQ|ok#Vjo_koQnHqV4glsYf2aR%A5bJ>&7~OJ!Nz zq(Q#y^>)H+um%uR>i0!4h+<>0xNK?9&HlsXJm4>>v99mJe3#1`6~#c`-aHLdh!EKm z^Lo1L^>&n(*3aKRyF98@`C%jM^Mn>a(NR%Lr@eVEP~0iaZK>> z>#wc#Bty7|5M{7LV2cSE9HVmip+AZW#d z%_et1;=~N%jhiNM!Ux6IyWQP2MChbQYT7_+N3JNt(|JH)2L^8Xo^MkK?C6ihToVY$ z&HeG~uWN4daq?ZydjF9|;}Dv(s^(IY_j!5FV}MtuYx5l+wdlK|7&JjJJ%r~>$NHNB zn+whnnCIV3g^~Ncm$V3e6Hcg(k5um;wt{3?&ZW~671NVexsBG-+Qef~l*1|PWy3E% ze~L}P=Q;c0h%eqff3GatS|PQB=#V6xu1HId*dR==P#INqt9t*i;UOJ7ot^|6MK$Yt zB7G7|TJzO+>X2&qCAD*&_k#;?!W3a~u%Z~m#=G5Z)%%BQtP%6V;y|PlKq)5Xkj2d@^p-R(ee4zL0^ z9Up~oorLvh&jCC&r^Qqnm-ybPHaCWLeH*<4DgDUYdgPyssCZKZTSrsa|hq zt(Zb&y zzr(*g%BHcvQ6ZZ4xrr00pnt|HrDGH3kfi6+)p-y&`*3qG2eR`$IDq2$;Xmhkx4YW{cX{4lHAdWL z+?U*x9aTA>$4H+ArD-&7`Mx(f992pUG=BX0>l&8F8eD^Cqia2z)bX<;LAMGor&EE4 z0Fb_k$$fty6%xY4EmEGaC?2JyxmT14{w3h`Hhy; z@BJLO56bQEMcS`-N#TR}+mkS%-~#p!*UDFX5n*DwUb0rlM{5~?!@9(@4 zw7{qZ7>mVauu8baY<8jHKj#sw&qmjh?tzeZem#e$<*5%(oo-qP;B0iQgVQH##g6y< zrFQGx`FyxPhIfclllN5UJ<7B*?dhF&9=7}b*qEZ!CKBm zIBBoJNtM~`LPJTiP89!>eGrxhIc|eg)kAJ&as^gFh|lV-jF`CY^yo@llK zxuI3xa0*n|7{$-xJ}!6$==F9~j>Zx3u^i0P17kKCOSZn7zw?f%Tl%dM_T&mBmbs!j zY1&H(Nw3wbUn=k?Ic<@Z?giTx`!sTZ#RJ@2uF+obYI22DCD_;FE(uPdO zP4MI7dn~-EsY&|t`+sSk^{SFogyz!*2mvs?bOr8k0r}knaDkh$Lt)nNpj{M$7Hr2# zU=bc~I#N@- z(9=U}l${^`^=p6+4)<~Ly#-QIsY=Zi8irqfic+yl0g$BP&vnW+l7MM)I;%Gjlz5Sk%^Nj@FmseLUW$-@? z5Bu}R4n)k^=sG%FDE2{b`A}|5NZBKY z!`=@_!5nLc0Mgv)BxK5}`hi^PDZ}%>qH<9T8h+tE!)#)0TnXugi!5vY_-XNK-12|V z5d5`^q}Ii2=C13zh7{%C9LTYNlN}!IUtV2>S$YJgI$JC*TYo~wduOBT`0N>GHoFL- z!x0@O?g#g4GR%*r>-tXB@lj(<_lK9HVtTU1FAj%$4G==R1RoY~Sj<3B{+Cw-EWh3| z9Pa7aq;A1fsT~U#Ve+GK%ui-wZtpNrV14-4udQY)v4T582yzqE$9F-9czK$ZnVYo2 zJ8tr+nR?*?jqPi=TQNrpb-ns@CHS+>VDUY52riYn5f=W?C-%z^9>6eD1i^xi3e#X1 z@!6U;XyxX)D6EniA*lBQT;Z zo^|u`GBy^z|MNQLM5|~Ejq(55+V@dCMVjB=V;#bDvLJ3< Yfl0zopr01kstasU7T literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/nerospace/textures/item/alien_fragment.png b/src/main/resources/assets/nerospace/textures/item/alien_fragment.png new file mode 100644 index 0000000000000000000000000000000000000000..36c19d131cf437506a2f2d977c9243520fa2ec7f GIT binary patch literal 141 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`A)YRdAr*6y6C_wgnB8K8q}2WX zxIeqUVZ(t{(>SL;W8h`p!}iFup|PXEe7Fi*Ar*6y6C_wgnB7VkrM0*J z7ZM8M(T}r9KH#=Kayx@7zwj>Qa@RF+8eRs39dBj@(fw#%%G8-Om@zu9@=$JZbJ0j*^4boFyt I=akR{0GRkVUH||9 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/nerospace/textures/item/meteor_caller.png b/src/main/resources/assets/nerospace/textures/item/meteor_caller.png new file mode 100644 index 0000000000000000000000000000000000000000..532686722c90c159852174809e7bf3c33dd1c3c6 GIT binary patch literal 142 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`p`I>|Ar*6y6C~z2xH&%m-^4rp z-{Q+I(hQfs)Hfgg|6f|>LED5kOfwi$b}o2yn4c#f$dTYR?okmxq4DC+`X3*fi^LVA poi=f=5Mq;3_v2~cZWCi*ILp^J`?lEJ`#=jAJYD@<);T3K0RW^kF@*pC literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/nerospace/textures/item/meteor_tracker.png b/src/main/resources/assets/nerospace/textures/item/meteor_tracker.png new file mode 100644 index 0000000000000000000000000000000000000000..59371e1ac60d9fbf99c09fdbbf3ac739b87ee48c GIT binary patch literal 208 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`(>+}rLn`JZCoB*!ND`hBc;RQi z@1K*++7hn}SrTSNtLZokuGj21l*!Tcj8$6iZ~TUbm+Cn!MMZdok3Y5Akyg?xDqoi% z(UQX 6: + px[x, y] = METAL_D + elif d > 5: + px[x, y] = METAL_L + else: + px[x, y] = (24, 30, 36, 255) + for (x, y) in [(8, 4), (8, 5), (8, 6), (7, 5), (9, 5)]: + px[x, y] = M_CYAN + for (x, y) in [(8, 9), (8, 10), (8, 11)]: + px[x, y] = M_AMBER + px[8, 8] = M_GLOW + save(img, os.path.join(ITEM_DIR, "meteor_tracker.png")) + + +def gen_meteor_caller(): + img = new_img() + px = img.load() + for y in range(3, 13): + for x in range(5, 11): + px[x, y] = METAL_L if (x + y) % 2 else METAL_D + for (x, y) in [(7, 4), (8, 4), (7, 5), (8, 5)]: + px[x, y] = M_AMBER + for (x, y) in [(6, 6), (9, 7), (7, 8), (8, 9)]: + px[x, y] = M_CYAN + px[8, 11] = M_TEAL + save(img, os.path.join(ITEM_DIR, "meteor_caller.png")) + + +def gen_falling_meteor_entity(): + # 64x64 box-UV sheet: charred rock with glowing cyan/amber cracks (uniform-ish so every face reads). + rng = random.Random(4242) + img = Image.new("RGBA", (ES, ES), (0, 0, 0, 0)) + px = img.load() + for y in range(ES): + for x in range(ES): + px[x, y] = rng.choice(METEOR_STONE) + for _ in range(120): + px[rng.randint(0, ES - 1), rng.randint(0, ES - 1)] = M_TEAL if rng.random() < 0.6 else M_AMBER + for _ in range(40): + px[rng.randint(0, ES - 1), rng.randint(0, ES - 1)] = M_CYAN + save(img, os.path.join(ENTITY_DIR, "falling_meteor.png")) + + if __name__ == "__main__": # Solar panels (SOLAR_PANEL_DESIGN): visibly distinct tier accents — T1 green (Greenxertz/steel), # T2 nerosium magenta, T3 gold. @@ -2982,4 +3119,13 @@ def gen_solar_panel_base(name, accent): gen_station_core() gen_station_charter() # Developer diagnostics — hidden Sentry test block: a steel panel with a red warning glyph. - gen_panel_block("sentry_test", N_RED, SYM_BANG) \ No newline at end of file + gen_panel_block("sentry_test", N_RED, SYM_BANG) + # Meteor events (meteor-events-design.md): crater rock/core, alien loot, tracker/caller, entity. + gen_meteor_rock() + gen_meteor_core() + gen_alien_fragment() + gen_alien_tech_scrap() + gen_alien_core() + gen_meteor_tracker() + gen_meteor_caller() + gen_falling_meteor_entity() \ No newline at end of file From 53dfd186ac2f2666a5e3a3bf251bb46386c96f8d Mon Sep 17 00:00:00 2001 From: Dario Maselli <117168592+Dario-Maselli@users.noreply.github.com> Date: Tue, 16 Jun 2026 16:21:10 +0800 Subject: [PATCH 2/3] Add meteor events and guide entries Introduce Meteor Events support and documentation: add language strings and a new Star Guide chapter for meteor events; create advancement data for alien_fragment, alien_tech_scrap and alien_core; add a gallery meteor showcase shot and a buildMeteorSite helper in NerospaceCommands. Implement a frozen/gallery-only FallingMeteorEntity spawn method so a hovering meteor can be shown without falling. Update mod datagen (language/advancements), generated lang file, configuration docs and multiple wiki pages (Meteor Events, Meteor Rock, Meteor Core) and list new alien items in Items.md. --- .../assets/nerospace/lang/en_us.json | 7 ++ .../advancement/guide/alien_core.json | 28 ++++++++ .../advancement/guide/alien_fragment.json | 28 ++++++++ .../advancement/guide/alien_tech_scrap.json | 28 ++++++++ .../client/GalleryCaptureHarness.java | 4 ++ .../nerospace/command/NerospaceCommands.java | 37 ++++++++++- .../nerospace/datagen/ModAdvancements.java | 12 ++++ .../datagen/ModLanguageProvider.java | 16 +++++ .../nerospace/meteor/FallingMeteorEntity.java | 15 +++++ .../nerospace/progression/StarGuide.java | 8 ++- wiki/Configuration.md | 17 +++++ wiki/Home.md | 2 + wiki/Items.md | 10 +++ wiki/Meteor-Core.md | 26 ++++++++ wiki/Meteor-Events.md | 64 +++++++++++++++++++ wiki/Meteor-Rock.md | 25 ++++++++ wiki/_Sidebar.md | 6 ++ 17 files changed, 331 insertions(+), 2 deletions(-) create mode 100644 src/generated/resources/data/nerospace/advancement/guide/alien_core.json create mode 100644 src/generated/resources/data/nerospace/advancement/guide/alien_fragment.json create mode 100644 src/generated/resources/data/nerospace/advancement/guide/alien_tech_scrap.json create mode 100644 wiki/Meteor-Core.md create mode 100644 wiki/Meteor-Events.md create mode 100644 wiki/Meteor-Rock.md diff --git a/src/generated/resources/assets/nerospace/lang/en_us.json b/src/generated/resources/assets/nerospace/lang/en_us.json index dcee035..b45a256 100644 --- a/src/generated/resources/assets/nerospace/lang/en_us.json +++ b/src/generated/resources/assets/nerospace/lang/en_us.json @@ -138,6 +138,7 @@ "gui.nerospace.rocket.stations_none": "NO STATIONS", "gui.nerospace.rocket.tier": "Tier %s Rocket", "gui.nerospace.star_guide.chapter.machines": "Machines", + "gui.nerospace.star_guide.chapter.meteor_events": "Meteor Events", "gui.nerospace.star_guide.chapter.mining": "Mining", "gui.nerospace.star_guide.chapter.nerosium": "Nerosium", "gui.nerospace.star_guide.chapter.new_worlds": "New Worlds", @@ -146,6 +147,10 @@ "gui.nerospace.star_guide.chapter.terraforming": "Terraforming", "gui.nerospace.star_guide.chapter.vacuum": "Vacuum", "gui.nerospace.star_guide.complete": "COMPLETE", + "gui.nerospace.star_guide.step.alien_core": "Alien Core", + "gui.nerospace.star_guide.step.alien_core.text": "The rarest meteor prize. An intact Alien Core is the key to the deepest alien technology — the heart of a future upgrade tree.", + "gui.nerospace.star_guide.step.alien_tech": "Salvaged Tech", + "gui.nerospace.star_guide.step.alien_tech.text": "Rarer meteors carry Alien Tech Scrap — the raw material of the scanners and upgrades to come. Hoard it.", "gui.nerospace.star_guide.step.battery": "Stored Potential", "gui.nerospace.star_guide.step.battery.text": "Batteries buffer the grid: generators fill them, machines drain them through the pipe network. Build one before your first rocket launch window.", "gui.nerospace.star_guide.step.cindara": "Into the Fire", @@ -172,6 +177,8 @@ "gui.nerospace.star_guide.step.hydration_module.text": "A Hydration Module touching your Terraformer melts glacite into water for the Hydrated stage — basins fill into lakes behind the green frontier.", "gui.nerospace.star_guide.step.living_world": "World Awake", "gui.nerospace.star_guide.step.living_world.text": "Behind the water, the land matures: natural colour, trees, rain — and the first herds. Stand on Living ground and watch a world breathe on its own.", + "gui.nerospace.star_guide.step.meteor_site": "Visitor from Beyond", + "gui.nerospace.star_guide.step.meteor_site.text": "Meteors crash on the Overworld and the planets, leaving a small crater around a glowing Meteor Core. Break the core to claim Alien Fragments and a jump-start of off-world ores — hold a Meteor Tracker to find the way.", "gui.nerospace.star_guide.step.nerosium_dust": "Finely Ground", "gui.nerospace.star_guide.step.nerosium_dust.text": "Grind raw nerosium into dust, then smelt the dust into ingots — two for one.", "gui.nerospace.star_guide.step.nerosium_grinder": "Industrial Revolution", diff --git a/src/generated/resources/data/nerospace/advancement/guide/alien_core.json b/src/generated/resources/data/nerospace/advancement/guide/alien_core.json new file mode 100644 index 0000000..6a8b172 --- /dev/null +++ b/src/generated/resources/data/nerospace/advancement/guide/alien_core.json @@ -0,0 +1,28 @@ +{ + "parent": "nerospace:guide/alien_tech_scrap", + "criteria": { + "has_item": { + "conditions": { + "items": [ + { + "items": "nerospace:alien_core" + } + ] + }, + "trigger": "minecraft:inventory_changed" + } + }, + "display": { + "description": "Find the rare Alien Core inside a meteor", + "icon": { + "id": "nerospace:alien_core" + }, + "title": "Alien Core" + }, + "requirements": [ + [ + "has_item" + ] + ], + "sends_telemetry_event": true +} \ No newline at end of file diff --git a/src/generated/resources/data/nerospace/advancement/guide/alien_fragment.json b/src/generated/resources/data/nerospace/advancement/guide/alien_fragment.json new file mode 100644 index 0000000..95eae67 --- /dev/null +++ b/src/generated/resources/data/nerospace/advancement/guide/alien_fragment.json @@ -0,0 +1,28 @@ +{ + "parent": "nerospace:root", + "criteria": { + "has_item": { + "conditions": { + "items": [ + { + "items": "nerospace:alien_fragment" + } + ] + }, + "trigger": "minecraft:inventory_changed" + } + }, + "display": { + "description": "Loot a fallen meteor for an Alien Fragment", + "icon": { + "id": "nerospace:alien_fragment" + }, + "title": "Visitor from Beyond" + }, + "requirements": [ + [ + "has_item" + ] + ], + "sends_telemetry_event": true +} \ No newline at end of file diff --git a/src/generated/resources/data/nerospace/advancement/guide/alien_tech_scrap.json b/src/generated/resources/data/nerospace/advancement/guide/alien_tech_scrap.json new file mode 100644 index 0000000..e0e9715 --- /dev/null +++ b/src/generated/resources/data/nerospace/advancement/guide/alien_tech_scrap.json @@ -0,0 +1,28 @@ +{ + "parent": "nerospace:guide/alien_fragment", + "criteria": { + "has_item": { + "conditions": { + "items": [ + { + "items": "nerospace:alien_tech_scrap" + } + ] + }, + "trigger": "minecraft:inventory_changed" + } + }, + "display": { + "description": "Recover Alien Tech Scrap from a meteor", + "icon": { + "id": "nerospace:alien_tech_scrap" + }, + "title": "Salvaged Tech" + }, + "requirements": [ + [ + "has_item" + ] + ], + "sends_telemetry_event": true +} \ No newline at end of file diff --git a/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java b/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java index 5019edd..d0afb82 100644 --- a/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java +++ b/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java @@ -329,6 +329,10 @@ private static java.util.List buildShots(int ox, int oy, int oz) { // glowing frame, the moving drill head and the power hookup. shots.add(new Shot("quarry_operating", none, none, 0, new Vec3(ox + 44, oy + 9, oz - 28), new Vec3(ox + 42, oy - 2, oz - 39))); + // Meteor crash site (SW, mirrors buildMeteorSite at ox-28, oz+30): low angle so the crater + + // glowing core read, with the hovering meteor (fy+11) and its trail filling the upper frame. + shots.add(new Shot("meteor_site", none, none, 0, + new Vec3(ox - 20, oy + 4, oz + 24), new Vec3(ox - 28, oy + 4, oz + 30))); return shots; } diff --git a/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java b/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java index f2b079c..38085a6 100644 --- a/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java +++ b/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java @@ -36,6 +36,8 @@ import net.neoforged.neoforge.transfer.item.ItemResource; import za.co.neroland.nerospace.Nerospace; +import za.co.neroland.nerospace.meteor.FallingMeteorEntity; +import za.co.neroland.nerospace.meteor.MeteorCoreBlockEntity; import za.co.neroland.nerospace.machine.CombustionGeneratorBlockEntity; import za.co.neroland.nerospace.machine.FuelRefineryBlockEntity; import za.co.neroland.nerospace.machine.HydrationModuleBlockEntity; @@ -329,6 +331,10 @@ private static int buildGallery(CommandSourceStack source) { spawnShowcase(level, creatures.get(i), new BlockPos(mx + i * 4, fy + 1, mz + 1), true); } + // METEOR SITE (meteor-events-design.md): a small crater of meteor_rock around a loot-bearing + // meteor_core, with a frozen meteor hovering above it (spins + trails for the shot). SW spoke. + buildMeteorSite(level, floor, origin.getX() - 28, origin.getZ() + 30, fy); + // QUARRY (MINER_DESIGN): two NE displays. // 1. Landmark-only — three landmarks in an L (shows the projected marker lasers). // 2. Fully operating — a powered quarry mid-dig: frame ring, drill head, a real pit forming. @@ -355,7 +361,8 @@ private static int buildGallery(CommandSourceStack source) { + "line, oxygen generator + lever, terraformer crew + lever — flip a lever to start " + "those two), 4 live pipe scenarios (energy/fluid/gas/items), all 4 suit variants, " + "a loaded Star Guide pedestal, all 4 rocket tiers on their required pads (3x3, " - + "3x3, walled ring, Heavy Launch Complex), and 8 creatures (frozen for clean shots)."), false); + + "3x3, walled ring, Heavy Launch Complex), 8 creatures (frozen for clean shots), " + + "and a meteor crash site (crater + loot core + hovering meteor)."), false); return Command.SINGLE_SUCCESS; } @@ -463,6 +470,34 @@ private static void buildGalleryQuarry(ServerLevel level, BlockState floor, int } } + /** + * A showcase meteor crash site: a 7x7 floor pad, a 5x5 {@code meteor_rock} crater floor with a + * raised rim, a loot-pre-rolled {@code meteor_core} nestled in the centre, and a frozen + * {@link FallingMeteorEntity} hovering above (spins + trails, but never falls — gallery only). + */ + private static void buildMeteorSite(ServerLevel level, BlockState floor, int cx, int cz, int fy) { + for (int dx = -3; dx <= 3; dx++) { + for (int dz = -3; dz <= 3; dz++) { + level.setBlockAndUpdate(new BlockPos(cx + dx, fy, cz + dz), floor); + } + } + BlockState rock = ModBlocks.METEOR_ROCK.get().defaultBlockState(); + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + level.setBlockAndUpdate(new BlockPos(cx + dx, fy + 1, cz + dz), rock); // crater floor + if (Math.abs(dx) == 2 || Math.abs(dz) == 2) { + level.setBlockAndUpdate(new BlockPos(cx + dx, fy + 2, cz + dz), rock); // raised rim + } + } + } + BlockPos corePos = new BlockPos(cx, fy + 2, cz); + level.setBlockAndUpdate(corePos, ModBlocks.METEOR_CORE.get().defaultBlockState()); + if (level.getBlockEntity(corePos) instanceof MeteorCoreBlockEntity core) { + core.generateLoot(level.getRandom().nextLong()); + } + FallingMeteorEntity.spawnFrozen(level, cx + 0.5D, fy + 11, cz + 0.5D); + } + /** A full {@code size x size} square of launch pads with min-corner {@code corner}. */ private static void fillPad(ServerLevel level, BlockPos corner, int size, BlockState pad) { for (int dx = 0; dx < size; dx++) { diff --git a/src/main/java/za/co/neroland/nerospace/datagen/ModAdvancements.java b/src/main/java/za/co/neroland/nerospace/datagen/ModAdvancements.java index 47f9893..6c558aa 100644 --- a/src/main/java/za/co/neroland/nerospace/datagen/ModAdvancements.java +++ b/src/main/java/za/co/neroland/nerospace/datagen/ModAdvancements.java @@ -286,6 +286,18 @@ public void generate(HolderLookup.Provider registries, Consumer icon, String ad // Deeper terraforming (DEEPER_TERRAFORM_DESIGN.md §11): the staged finale. step("hydration_module", () -> ModBlocks.HYDRATION_MODULE.get(), "guide/hydration_module"), step("living_world", () -> ModItems.MEADOW_LOPER_SPAWN_EGG.get(), "guide/living_world"), - step("new_life", () -> ModItems.LOPER_HAUNCH.get(), "guide/new_life")))); + step("new_life", () -> ModItems.LOPER_HAUNCH.get(), "guide/new_life"))), + // Meteor events (meteor-events-design.md): a parallel branch — meteors seed alien + // materials on the Overworld and the planets, no rocket required for the first taste. + new Chapter("meteor_events", List.of( + step("meteor_site", () -> ModItems.ALIEN_FRAGMENT.get(), "guide/alien_fragment"), + step("alien_tech", () -> ModItems.ALIEN_TECH_SCRAP.get(), "guide/alien_tech_scrap"), + step("alien_core", () -> ModItems.ALIEN_CORE.get(), "guide/alien_core")))); public static final int CHAPTER_COUNT = CHAPTERS.size(); diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 648411f..d20325a 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -93,6 +93,23 @@ at defaults unless debugging server performance. | `terraformForceLoadChunks` | `false` | — | Force-load a bounded arc around the working frontier (TPS footgun — off by default). | | `terraformMaxForcedChunks` | `16` | 0–256 | Guard on force-loaded chunks. | +### Meteor events + +Tunables for the meteor world-event (see **[Meteor Events](Meteor-Events)**). Defaults give roughly +one natural meteor every 2–3 play-hours per active dimension; the Meteor Caller works regardless. + +| Key | Default | Range | Meaning | +|---|---|---|---| +| `meteorNaturalSpawn` | `true` | — | Whether meteors fall naturally near players. | +| `meteorAvgIntervalSeconds` | `9000` | 60–1,000,000 | Average seconds between natural impacts (randomised 0.66×–1.33×). | +| `meteorWarningSeconds` | `30` | 0–600 | Warning window a meteor is tracked as *incoming* before it falls. | +| `meteorMinDistance` | `200` | 0–2,000 | Min horizontal distance from the anchor player a meteor targets. | +| `meteorMaxDistance` | `500` | 16–4,000 | Max horizontal distance a meteor targets. | +| `meteorCraterRadius` | `3` | 1–8 | Radius of the crater carved (kept modest to avoid griefing builds). | +| `meteorMaxActiveSites` | `4` | 1–64 | Max simultaneous scheduled/falling meteors per dimension. | +| `meteorLootBonusRolls` | `3` | 0–32 | Weighted bonus loot rolls on top of the guaranteed alien fragments. | +| `meteorDebugLog` | `false` | — | Verbose, non-personal meteor logging (dimension + coordinates only — POPIA/GDPR). | + ## Removed keys (for modpack authors migrating) Folded into multipliers: `atmosphereDamage`, `oxygenMax`, `oxygenDrainPerTick`, `oxygenSuitDrain`, diff --git a/wiki/Home.md b/wiki/Home.md index be41332..641bb35 100644 --- a/wiki/Home.md +++ b/wiki/Home.md @@ -32,6 +32,8 @@ eventually **terraform** a dead planet into livable, rained-on ground. the Star Guide. - **[Creatures](Creatures)** — the native mobs of Greenxertz, Cindara, and Glacira, plus the terraform livestock. +- **[Meteor Events](Meteor-Events)** — rare meteor crashes that seed the world with alien materials + and off-world ores; track them with the Meteor Tracker. - **[Star Guide](Star-Guide)** — the in-game progression guide. - **[Configuration](Configuration)** — the five multiplier keys for server/modpack tuning. - **[Roadmap](Roadmap)** — what shipped in 1.0 and what's next. diff --git a/wiki/Items.md b/wiki/Items.md index cc8dc0a..0691df9 100644 --- a/wiki/Items.md +++ b/wiki/Items.md @@ -14,6 +14,9 @@ A reference for Nerospace's non-block items. (Blocks have their own pages — se | **Xertz Quartz** | Gem dropped by [Xertz Quartz Ore](Xertz-Quartz-Ore); used in upgrades and the Terraform Monitor. | | **Cindrite** | Gem from [Cindrite Ore](Cindrite-Ore) on Cindara; Tier-3 Terraformer upgrade, Tier 2 + [Thermal Suit](Oxygen-Suit) pieces, Tier 4 rocket. | | **Glacite** | Gem from [Glacite Ore](Glacite-Ore) on Glacira; [Cryo Suit](Oxygen-Suit) pieces and the [Hydration Module](Hydration-Module)'s water cycle. | +| **Alien Fragment** | Common loot from a [Meteor Core](Meteor-Core); future scanner feedstock. | +| **Alien Tech Scrap** | Uncommon meteor loot; future upgrade crafting. | +| **Alien Core** | Rare meteor loot; high-value scanner/upgrade gate. | ## Tools @@ -76,6 +79,13 @@ engage. See **[Oxygen Suit](Oxygen-Suit)** for the full page. - **Drift Fleece** — dropped by the Woolly Drift; crafts into 4 String. See **[Creatures](Creatures)** for the livestock themselves. +## Meteor events + +- **Meteor Tracker** — points to the nearest meteor (held action-bar readout: state, heading, + distance). Creative for now; a survival craft comes with the scanner. +- **Meteor Caller** — creative-only: right-click a block to call a meteor down onto it. + See **[Meteor Events](Meteor-Events)** for the full world-event, loot table and config. + ## Travel devices (creative) - **Greenxertz Navigator** and the **Station / Greenxertz / Cindara / Glacira Compasses** are diff --git a/wiki/Meteor-Core.md b/wiki/Meteor-Core.md new file mode 100644 index 0000000..52b578f --- /dev/null +++ b/wiki/Meteor-Core.md @@ -0,0 +1,26 @@ +# Meteor Core + +The glowing block at the centre of a crater — the meteor's "box" of loot. + +## Overview + +Every fallen meteor seats a Meteor Core at the bottom of its crater (see +**[Meteor Events](Meteor-Events)**). It holds the meteor's RNG loot, rolled **once** when the meteor +lands and then fixed, so the contents are identical for everyone who reaches it. + +## Obtaining + +**Not craftable.** A Meteor Core is placed by a meteor impact. **Break it to claim the loot** +(break-to-loot): the stored stacks spill out where it stood — a handful of **Alien Fragments** plus +weighted bonus rolls of raw ores and the rarer **Alien Tech Scrap** / **Alien Core**. + +## Use + +- Smash it open for the meteor's contents — the jump-start of alien materials and off-world ores. +- The alien items feed a future **scanner / upgrade** system (see [Roadmap](Roadmap)). + +## Details + +- ID: `nerospace:meteor_core` · Tool: pickaxe · No loot table — drops its stored contents on break. +- Emits light (level 10). Loot is configurable via the **Meteor events** keys in + [Configuration](Configuration). diff --git a/wiki/Meteor-Events.md b/wiki/Meteor-Events.md new file mode 100644 index 0000000..2e8599d --- /dev/null +++ b/wiki/Meteor-Events.md @@ -0,0 +1,64 @@ +# Meteor Events + +Falling meteors are a **world event**: rare crashes that seed the Overworld (and the planets) with +alien materials and a jump-start of off-world ores — no rocket required to get your first taste of +what's out there. + +## Overview + +Every so often, near an active player, a meteor is scheduled to fall. After a short warning window it +streaks down from the sky on a fiery arc, craters into the ground, and leaves a small **crater of +[Meteor Rock](Meteor-Rock)** around a glowing **[Meteor Core](Meteor-Core)** — the "box" that holds +the RNG loot. The crash is deliberately modest (a few-block crater, no wide explosion, no fire) so it +won't grief your builds. + +Impacts happen **as you get close** to the site: meteors are scheduled far out and only fall once the +area is loaded, so following the warning toward the site is part of the hunt. + +## The Meteor Tracker + +The **Meteor Tracker** is an early-warning compass. While you hold it, it shows the nearest tracked +meteor in the action bar: + +- **State** — *Incoming* (still falling / not yet landed) or *Landed* (the crater is waiting). +- **Heading** — a compass direction (N, NE, E, …) toward the site. +- **Distance** — how far off it is, in metres. + +It's a creative item for now (no survival recipe yet) — a survival craft is planned alongside the +scanner system below. + +## Loot + +Breaking the [Meteor Core](Meteor-Core) spills the meteor's contents (break-to-loot). The loot is +rolled **once** when the meteor lands and then fixed, so it's the same for everyone who reaches it — +no re-roll exploit. A meteor always carries a handful of **Alien Fragments**, plus weighted bonus +rolls of existing raw ores (Raw Nerosium, Raw Nerosteel, Xertz Quartz) and the rarer +**Alien Tech Scrap** and **Alien Core**. + +| Item | Rarity | Role | +|---|---|---| +| **Alien Fragment** | common (guaranteed) | Future **scanner** feedstock. | +| **Alien Tech Scrap** | uncommon | Future upgrade crafting. | +| **Alien Core** | rare | High-value scanner/upgrade gate. | + +The three are grouped under the `nerospace:alien_materials` item tag for future recipes. The scanner +that turns them into upgrades is on the **[Roadmap](Roadmap)** / **[Future Features](Future-Features)**. + +## Calling a meteor (creative) + +The **Meteor Caller** is a creative-only tool: right-click any block to call a meteor down onto that +spot immediately, with freshly rolled loot — the same path natural spawning uses. It does nothing in +survival. + +## Configuration + +Meteor frequency, the warning window, target distance, crater size and loot generosity are all tunable +— see the **Meteor events** section of **[Configuration](Configuration)**. Natural spawning can be +turned off entirely (the Meteor Caller still works). + +## Details + +- Trigger: rare timer near players (≈ one per 2–3 play-hours by default, configurable). +- Dimensions: Overworld, Greenxertz, Cindara, Glacira (not the void station). +- Blocks: [Meteor Rock](Meteor-Rock), [Meteor Core](Meteor-Core) · Entity: `nerospace:falling_meteor`. +- Items: `alien_fragment`, `alien_tech_scrap`, `alien_core`, `meteor_tracker`, `meteor_caller`. diff --git a/wiki/Meteor-Rock.md b/wiki/Meteor-Rock.md new file mode 100644 index 0000000..725e310 --- /dev/null +++ b/wiki/Meteor-Rock.md @@ -0,0 +1,25 @@ +# Meteor Rock + +The charred, faintly glowing rock left behind by a meteor crash. + +## Overview + +Meteor Rock forms the small crater around a fallen meteor (see **[Meteor Events](Meteor-Events)**). It +is a cosmetic space-rock building block — dark basalt veined with a cyan/teal alien glow — and the +visible marker of a crash site from a distance. + +## Obtaining + +- **Mining:** any pickaxe; requires the correct tool to drop. Drops itself, so you can collect and + build with it. +- **Generation:** placed by meteor impacts — it is not found in normal world generation. + +## Use + +- Decorative building block for space/crater builds. +- The crater body around a **[Meteor Core](Meteor-Core)** — dig in to reach the loot. + +## Details + +- ID: `nerospace:meteor_rock` · Tool: pickaxe · Drops: itself +- Emits a faint light (level 3). diff --git a/wiki/_Sidebar.md b/wiki/_Sidebar.md index f082946..e53e847 100644 --- a/wiki/_Sidebar.md +++ b/wiki/_Sidebar.md @@ -59,6 +59,12 @@ - [Station Wall](Station-Wall) - [Station Core](Station-Core) +**World Events** + +- [Meteor Events](Meteor-Events) +- [Meteor Rock](Meteor-Rock) +- [Meteor Core](Meteor-Core) + **More** - [Items](Items) From b1aadc1b09a94c072d0fa29bf3cc339e5b992c96 Mon Sep 17 00:00:00 2001 From: Dario Maselli <117168592+Dario-Maselli@users.noreply.github.com> Date: Tue, 16 Jun 2026 17:56:16 +0800 Subject: [PATCH 3/3] Normalize trailing newlines in source files MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Normalize end-of-file/newline and minor formatting in GalleryCaptureHarness.java and NerospaceCommands.java. No functional changes — only whitespace/EOL adjustments to ensure consistent file endings and formatting. --- .../client/GalleryCaptureHarness.java | 903 ++++++------ .../nerospace/command/NerospaceCommands.java | 1217 ++++++++--------- 2 files changed, 1059 insertions(+), 1061 deletions(-) diff --git a/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java b/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java index 6b73502..b5b664d 100644 --- a/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java +++ b/src/main/java/za/co/neroland/nerospace/client/GalleryCaptureHarness.java @@ -1,452 +1,451 @@ -package za.co.neroland.nerospace.client; - -import java.io.File; -import java.util.ArrayDeque; -import java.util.Deque; - -import javax.annotation.Nullable; - -import com.mojang.blaze3d.pipeline.RenderTarget; -import com.mojang.brigadier.Command; -import com.mojang.brigadier.arguments.StringArgumentType; - -import net.minecraft.client.CloudStatus; -import net.minecraft.client.Minecraft; -import net.minecraft.client.Screenshot; -import net.minecraft.client.player.LocalPlayer; -import net.minecraft.commands.Commands; -import net.minecraft.network.chat.Component; -import net.minecraft.world.phys.Vec3; -import net.neoforged.api.distmarker.Dist; -import net.neoforged.bus.api.SubscribeEvent; -import net.neoforged.fml.common.EventBusSubscriber; -import net.neoforged.neoforge.client.event.ClientTickEvent; -import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent; - -import za.co.neroland.nerospace.Nerospace; - -/** - * CLIENT, creative-debug: an automated screenshot pass over the {@code /nerospace gallery} scene - * (the §9.4 shot list — see RELEASE_CHECKLIST.md). {@code /nerospace gallery} builds the scene - * server-side; this harness then drives the camera, hides the HUD and writes a PNG per shot via - * {@link Screenshot#grab}, so the gallery can be re-rendered repeatably after texture/model changes - * instead of hand-framing every shot. - * - *

Commands (client-side, so they never reach the server): - *

    - *
  • {@code /nsgallery capture [time]} — one shot: teleports into the flat {@code nerospace:capture} - * dimension at a fixed origin (so the backdrop is identical every run), builds the scene - * ({@code /nerospace gallery}), freezes the daylight/weather cycle at {@code time} (default - * {@code noon}; accepts {@code day}/{@code noon}/{@code night}/{@code midnight} or a tick - * number), disables clouds, waits for the scene to load, then screenshots each cluster to - * {@code .minecraft/screenshots/nerospace/.png}. Run from any creative world.
  • - *
  • {@code /nsgallery planets [time]} — fly through the planet dimensions - * ({@code greenxertz}/{@code cindara}/{@code glacira}/{@code station}) plus a terraform - * before/after pair, summoning frozen signature mobs and shooting a vista in each. Planet - * terrain is seed-dependent, so run this in a FIXED-SEED capture world and tune the coords in - * {@link #buildPlanetShots} to that seed.
  • - *
  • {@code /nsgallery shot } — capture the CURRENT view once (HUD hidden) to - * {@code nerospace/.png}. Use this to grab a hand-framed money shot.
  • - *
- * - *

PROTOTYPE NOTE: the per-shot camera vantages below are deliberate-but-rough starting points - * (each cluster framed from a few blocks "south" looking north). Wide strips like the machine line - * and rocket row won't fully fit in one frame — tune {@link #buildShots} or split them. Camera time - * of day / weather are pinned at the start of a {@code capture} run for consistent lighting. - */ -@EventBusSubscriber(modid = Nerospace.MODID, value = Dist.CLIENT) -public final class GalleryCaptureHarness { - - /** Ticks to let chunks/entities settle and a fresh frame render before grabbing. */ - private static final int SETTLE_TICKS = 12; - - /** Ticks to wait after teleporting + triggering the build for the scene to load and sync. */ - private static final int BUILD_WARMUP_TICKS = 120; - - /** Ticks to wait after teleporting into a planet dimension (cross-dim load + chunk gen + summons). */ - private static final int PLANET_WARMUP_TICKS = 100; - - /** Deterministic flat backdrop (data/nerospace/dimension/capture.json) + the fixed build origin in it. */ - private static final String CAPTURE_DIMENSION = "nerospace:capture"; - private static final int ORIGIN_X = 0; - private static final int ORIGIN_Y = 64; - private static final int ORIGIN_Z = 0; - - /** - * One framed capture. {@code setup} = server commands run before this shot (teleport, build, - * summon…); {@code warmup} = ticks to wait after setup for the scene to load/sync; {@code camera}/ - * {@code target} = the pose ({@code null} poses → "keep current view"). - */ - private record Shot(String name, java.util.List setup, java.util.List build, - int warmup, @Nullable Vec3 camera, @Nullable Vec3 target) { - } - - private enum Phase { WARMUP, MOVE, SETTLE, SHOOT } - - private static final Deque QUEUE = new ArrayDeque<>(); - private static boolean running; - private static boolean hudWasHidden; - @Nullable - private static CloudStatus cloudsWere; - private static Phase phase = Phase.MOVE; - private static int warmup; - private static int settle; - @Nullable - private static Shot current; - - private GalleryCaptureHarness() { - } - - @SubscribeEvent - public static void onRegisterClientCommands(RegisterClientCommandsEvent event) { - event.getDispatcher().register( - Commands.literal("nsgallery") - .then(Commands.literal("capture") - .executes(ctx -> startCapture("noon")) - .then(Commands.argument("time", StringArgumentType.word()) - .executes(ctx -> startCapture(StringArgumentType.getString(ctx, "time"))))) - .then(Commands.literal("planets") - .executes(ctx -> startPlanets("noon")) - .then(Commands.argument("time", StringArgumentType.word()) - .executes(ctx -> startPlanets(StringArgumentType.getString(ctx, "time"))))) - .then(Commands.literal("shot") - .then(Commands.argument("name", StringArgumentType.word()) - .executes(ctx -> shotHere(StringArgumentType.getString(ctx, "name")))))); - } - - private static int startCapture(String time) { - Minecraft mc = Minecraft.getInstance(); - if (mc.player == null || mc.level == null) { - return 0; - } - if (running) { - mc.player.sendSystemMessage(Component.literal("Gallery capture already running.")); - return 0; - } - java.util.List shots = new java.util.ArrayList<>(buildShots(ORIGIN_X, ORIGIN_Y, ORIGIN_Z)); - // The first shot carries the one-time setup: teleport into the flat capture dimension, (re)build - // the gallery there, and freeze the environment so every rerun is framed + lit identically. - // These are server commands (they need cheats — the creative gallery world has them). - java.util.List setup = java.util.List.of( - "execute in " + CAPTURE_DIMENSION + " run tp @s " - + (ORIGIN_X + 0.5) + " " + ORIGIN_Y + " " + (ORIGIN_Z + 0.5), - "nerospace gallery clear", // no-op first run; stops reruns stacking - "nerospace gallery", - "gamerule advance_time false", // 26.1 renamed doDaylightCycle → advance_time - "gamerule advance_weather false", // …and doWeatherCycle → advance_weather - "time set " + time, // capture dim is overworld-type → has a clock - "weather clear"); - Shot first = shots.get(0); - shots.set(0, new Shot(first.name(), setup, java.util.List.of(), BUILD_WARMUP_TICKS, - first.camera(), first.target())); - - QUEUE.clear(); - QUEUE.addAll(shots); - begin(mc); - mc.player.sendSystemMessage(Component.literal("Gallery capture: building scene, time=" + time + ", " - + shots.size() + " shots → screenshots/nerospace/ (HUD hidden).")); - return Command.SINGLE_SUCCESS; - } - - private static int startPlanets(String time) { - Minecraft mc = Minecraft.getInstance(); - if (mc.player == null || mc.level == null) { - return 0; - } - if (running) { - mc.player.sendSystemMessage(Component.literal("Capture already running.")); - return 0; - } - QUEUE.clear(); - QUEUE.addAll(buildPlanetShots(time)); - begin(mc); - mc.player.sendSystemMessage(Component.literal("Planet capture: " + QUEUE.size() - + " shots across dimensions, time=" + time + " → screenshots/nerospace/ (HUD hidden).")); - return Command.SINGLE_SUCCESS; - } - - private static int shotHere(String name) { - Minecraft mc = Minecraft.getInstance(); - if (mc.player == null || mc.level == null || running) { - return 0; - } - QUEUE.clear(); - QUEUE.add(new Shot(name, java.util.List.of(), java.util.List.of(), 0, null, null)); // grab the current view as-is - begin(mc); - return Command.SINGLE_SUCCESS; - } - - private static void begin(Minecraft mc) { - // screenshots/ is created by Screenshot.grab; the nerospace/ subfolder is not, so make it. - new File(mc.gameDirectory, "screenshots/nerospace").mkdirs(); - hudWasHidden = mc.options.hideGui; - mc.options.hideGui = true; - cloudsWere = mc.options.cloudStatus().get(); - mc.options.cloudStatus().set(CloudStatus.OFF); // clouds scroll with game time → freeze them out of frame - running = true; - phase = Phase.MOVE; // the first shot's setup carries any teleport/build; warmup is per-shot now - current = null; - } - - @SubscribeEvent - public static void onClientTick(ClientTickEvent.Post event) { - if (!running) { - return; - } - Minecraft mc = Minecraft.getInstance(); - LocalPlayer player = mc.player; - if (player == null) { - finish(mc); - return; - } - - switch (phase) { - case MOVE -> { - final Shot shot = QUEUE.poll(); - current = shot; - if (shot == null) { - finish(mc); - return; - } - for (String cmd : shot.setup()) { // pre-warmup: teleport / gamerules / time / summon - player.connection.sendCommand(cmd); - } - warmup = shot.warmup(); - phase = Phase.WARMUP; - } - case WARMUP -> { - final Shot shot = current; - if (shot == null) { - phase = Phase.MOVE; - return; - } - if (--warmup <= 0) { // teleport + chunks now loaded - for (String cmd : shot.build()) { // block placement needs loaded chunks (else "not loaded") - player.connection.sendCommand(cmd); - } - applyPose(player, shot); - settle = SETTLE_TICKS; - phase = Phase.SETTLE; - } - } - case SETTLE -> { - final Shot shot = current; - if (shot == null) { - phase = Phase.MOVE; - return; - } - applyPose(player, shot); // re-pin every tick so gravity/AI can't drift the camera - if (--settle <= 0) { - phase = Phase.SHOOT; - } - } - case SHOOT -> { - final Shot shot = current; - if (shot == null) { - phase = Phase.MOVE; - return; - } - grab(mc, shot.name()); - phase = Phase.MOVE; - } - } - } - - /** Snap the player (the render camera) to the shot's pose, holding it still. */ - private static void applyPose(LocalPlayer player, @Nullable Shot shot) { - if (shot == null) { - return; - } - Vec3 cam = shot.camera(); - Vec3 tgt = shot.target(); - if (cam == null || tgt == null) { - return; // "keep current view" shot - } - double dx = tgt.x - cam.x; - double dy = tgt.y - cam.y; - double dz = tgt.z - cam.z; - double horiz = Math.sqrt(dx * dx + dz * dz); - float yaw = (float) (Math.toDegrees(Math.atan2(dz, dx)) - 90.0); - float pitch = (float) (-Math.toDegrees(Math.atan2(dy, horiz))); - player.snapTo(cam.x, cam.y, cam.z, yaw, pitch); - player.setDeltaMovement(Vec3.ZERO); - } - - private static void grab(Minecraft mc, String name) { - RenderTarget target = mc.getMainRenderTarget(); - Screenshot.grab(mc.gameDirectory, "nerospace/" + name + ".png", target, 1, - msg -> { - if (mc.player != null) { - mc.player.sendSystemMessage(msg); - } - }); - } - - private static void finish(Minecraft mc) { - mc.options.hideGui = hudWasHidden; - if (cloudsWere != null) { - mc.options.cloudStatus().set(cloudsWere); - } - running = false; - current = null; - QUEUE.clear(); - if (mc.player != null) { - mc.player.sendSystemMessage(Component.literal("Gallery capture done — see screenshots/nerospace/.")); - } - } - - /** - * Explicit per-cluster vantages. Clusters sit on a ring ~48 blocks out (mirror of the rotunda in - * NerospaceCommands): rockets N, machines S, blocks E, pipes W, creatures SE, suits NW. Each camera - * is hand-shaped for its subject (tall vs thin-strip vs row), so they're not uniform. Coordinates - * mirror the cluster bases in NerospaceCommands — tune the two together. - */ - private static java.util.List buildShots(int ox, int oy, int oz) { - java.util.List none = java.util.List.of(); - java.util.List shots = new java.util.ArrayList<>(); - // Rockets (N, tall): low camera looking slightly up. - shots.add(new Shot("rockets", none, none, 0, - new Vec3(ox - 0.5, oy + 3, oz - 26), new Vec3(ox - 0.5, oy + 5, oz - 48))); - // Machines (S, thin strip): 45° top-down, pulled ~10 closer (camera 12 out + 12 up over the strip). - shots.add(new Shot("machines", none, none, 0, - new Vec3(ox, oy + 13, oz + 36), new Vec3(ox, oy + 1, oz + 48))); - // Blocks (E, grid): raised, looking down. - shots.add(new Shot("blocks", none, none, 0, - new Vec3(ox + 30, oy + 9, oz), new Vec3(ox + 48, oy + 2, oz))); - // Pipes (W, rows): raised, looking down so the 4 resource rows read. - shots.add(new Shot("pipes", none, none, 0, - new Vec3(ox - 34, oy + 10, oz), new Vec3(ox - 48, oy + 1, oz))); - // Creatures (SE, row along +X): over-the-shoulder — low, near the west end, looking E down the line. - shots.add(new Shot("creatures", none, none, 0, - new Vec3(ox + 14, oy + 4, oz + 33), new Vec3(ox + 44, oy + 2, oz + 34))); - // Suits (NW, row along +X, stands face ~south): close, head-on from the south. - shots.add(new Shot("suits", none, none, 0, - new Vec3(ox - 33, oy + 3, oz - 26), new Vec3(ox - 34, oy + 2.5, oz - 34))); - // Quarry landmark-only display (NE): the L of three landmarks + their projected lasers. - shots.add(new Shot("quarry_landmarks", none, none, 0, - new Vec3(ox + 31, oy + 4, oz - 30), new Vec3(ox + 31, oy + 2, oz - 38))); - // Operating quarry (NE, further out): raised + angled to look INTO the pit and read the - // glowing frame, the moving drill head and the power hookup. - shots.add(new Shot("quarry_operating", none, none, 0, - new Vec3(ox + 44, oy + 9, oz - 28), new Vec3(ox + 42, oy - 2, oz - 39))); - // Meteor crash site (SW, mirrors buildMeteorSite at ox-28, oz+30): low angle so the crater + - // glowing core read, with the hovering meteor (fy+11) and its trail filling the upper frame. - shots.add(new Shot("meteor_site", none, none, 0, - new Vec3(ox - 20, oy + 4, oz + 24), new Vec3(ox - 28, oy + 4, oz + 30))); - // Solar arrays (SW): raised, looking south down the cluster so the front-row single units AND - // the seam-joined multi-unit fields behind them (plus the cabled connector) read together. - shots.add(new Shot("solar", none, none, 0, - new Vec3(ox - 42, oy + 9, oz + 28), new Vec3(ox - 42, oy + 1, oz + 42))); - return shots; - } - - /** - * Planet/dimension vistas (§9.4). Each shot teleports into a planet dimension, pins time/weather, - * summons frozen signature mobs, then frames a vista; the terraform pair shows one patch barren - * then greened. Reproducible ONLY in a fixed-seed capture world — planet terrain is seed-dependent. - * - *

WARNING: the coordinates here are PLACEHOLDERS. Terrain height/features depend on the world - * seed, so tune cam/target/summon coords to your chosen capture seed (same run→look→nudge loop as - * the gallery). Bad coords just mean a poorly-framed shot, not a crash. - */ - private static java.util.List buildPlanetShots(String time) { - java.util.List shots = new java.util.ArrayList<>(); - // Greenxertz has a clock (overworld-type) → time set works. Cindara/Glacira/Station use the - // "space" dimension type (END starfield, NO clock), so time set must be skipped there — they're - // dark by design. The signature mobs are summoned pre-warmup so they fall to the surface. - // Each mob shot kills its prior summons first (NoAI mobs are persistent → they'd stack on rerun). - shots.add(planetShot("greenxertz_vista", "nerospace:greenxertz", time, true, - new Vec3(8.5, 96, 8.5), new Vec3(28, 88, 28), java.util.List.of( - "kill @e[type=nerospace:xertz_stalker]", - "kill @e[type=nerospace:quartz_crawler]", - "summon nerospace:xertz_stalker 26 90 26 {NoAI:1b,PersistenceRequired:1b}", - "summon nerospace:quartz_crawler 30 90 30 {NoAI:1b,PersistenceRequired:1b}"), - java.util.List.of())); - shots.add(planetShot("cindara_basin", "nerospace:cindara", time, false, - new Vec3(8.5, 96, 8.5), new Vec3(28, 88, 28), java.util.List.of( - "kill @e[type=nerospace:cinder_stalker]", - "kill @e[type=nerospace:ember_strutter]", - "summon nerospace:cinder_stalker 26 90 26 {NoAI:1b,PersistenceRequired:1b}", - "summon nerospace:ember_strutter 30 90 30 {NoAI:1b,PersistenceRequired:1b}"), - java.util.List.of())); - shots.add(planetShot("glacira_frost", "nerospace:glacira", time, false, - new Vec3(8.5, 96, 8.5), new Vec3(28, 88, 28), java.util.List.of( - "kill @e[type=nerospace:frost_strider]", - "kill @e[type=nerospace:woolly_drift]", - "summon nerospace:frost_strider 26 90 26 {NoAI:1b,PersistenceRequired:1b}", - "summon nerospace:woolly_drift 30 90 30 {NoAI:1b,PersistenceRequired:1b}"), - java.util.List.of())); - // Orbital station is an empty space dimension until something is built — place a small platform - // (floor + two walls + a Station Core) at a fixed spot and shoot it against the starfield. The - // placement runs POST-warmup (build list) so the chunks are loaded ("position not loaded" else). - shots.add(planetShot("orbital_station", "nerospace:station", time, false, - new Vec3(-6.5, 71, -6.5), new Vec3(3, 65, 3), java.util.List.of(), - java.util.List.of( - "fill -1 64 -1 7 67 7 minecraft:air", // clear any prior platform before rebuilding - "fill 0 64 0 6 64 6 nerospace:station_floor", - "fill 0 65 0 6 65 0 nerospace:station_wall", - "fill 0 65 0 0 65 6 nerospace:station_wall", - "setblock 3 65 3 nerospace:station_core"))); - // Terraform before/after on CINDARA (the barren volcanic-ash world). AFTER converts a patch to - // the nerospace:terraformed_meadow biome (the neon-emerald "living" palette) + grass surface, - // two trees and a Meadow Loper. Block ops run post-warmup. NOTE: Cindara is a no-sun space dim, - // so the patch will be dim; if you want a bright "living" shot say so and I'll stage it on - // sunlit Greenxertz instead. by is a guess at the surface — tune to the seed. - int bx = 60; - int by = 70; - int bz = 60; - Vec3 tcam = new Vec3(bx - 6, by + 7, bz - 6); - Vec3 ttgt = new Vec3(bx + 6, by, bz + 6); - // A controlled flat patch built fresh each run (so reruns don't leave a dug pit / stale biome / - // floating mismatch from guessing Cindara's surface): clear the column to air, set the BIOME, - // then lay a 2-deep base topped with the surface block. BEFORE = barren coarse-dirt on the native - // (cindara) biome; AFTER = grass on the neon terraformed_meadow biome, with trees + a Meadow Loper. - int x2 = bx + 12; - int z2 = bz + 12; - String clearPatch = "fill " + (bx - 2) + " " + by + " " + (bz - 2) + " " + (bx + 14) + " " - + (by + 12) + " " + (bz + 14) + " minecraft:air"; - String base = "fill " + bx + " " + (by - 1) + " " + bz + " " + x2 + " " + (by - 1) + " " + z2 - + " minecraft:dirt"; - shots.add(planetShot("terraform_before", "nerospace:cindara", time, false, tcam, ttgt, - java.util.List.of(), java.util.List.of( - clearPatch, - "kill @e[type=nerospace:meadow_loper]", - "fillbiome " + bx + " " + (by - 1) + " " + bz + " " + x2 + " " + (by + 10) + " " + z2 - + " nerospace:cindara", // reset biome → barren (else the prior green lingers) - base, - "fill " + bx + " " + by + " " + bz + " " + x2 + " " + by + " " + z2 + " minecraft:coarse_dirt"))); - shots.add(planetShot("terraform_after", "nerospace:cindara", time, false, tcam, ttgt, - java.util.List.of(), java.util.List.of( - clearPatch, - "kill @e[type=nerospace:meadow_loper]", - "fillbiome " + bx + " " + (by - 1) + " " + bz + " " + x2 + " " + (by + 10) + " " + z2 - + " nerospace:terraformed_meadow", - base, - "fill " + bx + " " + by + " " + bz + " " + x2 + " " + by + " " + z2 + " minecraft:grass_block", - "place feature minecraft:oak " + (bx + 3) + " " + (by + 1) + " " + (bz + 3), - "place feature minecraft:oak " + (bx + 9) + " " + (by + 1) + " " + (bz + 8), - "summon nerospace:meadow_loper " + (bx + 6) + " " + (by + 1) + " " + (bz + 6) - + " {NoAI:1b,PersistenceRequired:1b}"))); - return shots; - } - - /** - * Build a planet shot. {@code summons} run pre-warmup (so mobs fall to the surface during the wait); - * {@code builds} run post-warmup (block placement needs the chunks loaded). {@code hasClock} gates - * {@code time set} — space dimensions have no clock and reject it. - */ - private static Shot planetShot(String name, String dim, String time, boolean hasClock, Vec3 cam, Vec3 tgt, - java.util.List summons, java.util.List builds) { - java.util.List setup = new java.util.ArrayList<>(); - setup.add("execute in " + dim + " run tp @s " + cam.x + " " + cam.y + " " + cam.z); - setup.add("gamerule advance_time false"); // 26.1: replaces doDaylightCycle - setup.add("gamerule advance_weather false"); // 26.1: replaces doWeatherCycle - setup.add("weather clear"); - if (hasClock) { - setup.add("time set " + time); // only dims with a clock accept this - } - setup.addAll(summons); - return new Shot(name, setup, builds, PLANET_WARMUP_TICKS, cam, tgt); - } -} - \ No newline at end of file +package za.co.neroland.nerospace.client; + +import java.io.File; +import java.util.ArrayDeque; +import java.util.Deque; + +import javax.annotation.Nullable; + +import com.mojang.blaze3d.pipeline.RenderTarget; +import com.mojang.brigadier.Command; +import com.mojang.brigadier.arguments.StringArgumentType; + +import net.minecraft.client.CloudStatus; +import net.minecraft.client.Minecraft; +import net.minecraft.client.Screenshot; +import net.minecraft.client.player.LocalPlayer; +import net.minecraft.commands.Commands; +import net.minecraft.network.chat.Component; +import net.minecraft.world.phys.Vec3; +import net.neoforged.api.distmarker.Dist; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.client.event.ClientTickEvent; +import net.neoforged.neoforge.client.event.RegisterClientCommandsEvent; + +import za.co.neroland.nerospace.Nerospace; + +/** + * CLIENT, creative-debug: an automated screenshot pass over the {@code /nerospace gallery} scene + * (the §9.4 shot list — see RELEASE_CHECKLIST.md). {@code /nerospace gallery} builds the scene + * server-side; this harness then drives the camera, hides the HUD and writes a PNG per shot via + * {@link Screenshot#grab}, so the gallery can be re-rendered repeatably after texture/model changes + * instead of hand-framing every shot. + * + *

Commands (client-side, so they never reach the server): + *

    + *
  • {@code /nsgallery capture [time]} — one shot: teleports into the flat {@code nerospace:capture} + * dimension at a fixed origin (so the backdrop is identical every run), builds the scene + * ({@code /nerospace gallery}), freezes the daylight/weather cycle at {@code time} (default + * {@code noon}; accepts {@code day}/{@code noon}/{@code night}/{@code midnight} or a tick + * number), disables clouds, waits for the scene to load, then screenshots each cluster to + * {@code .minecraft/screenshots/nerospace/.png}. Run from any creative world.
  • + *
  • {@code /nsgallery planets [time]} — fly through the planet dimensions + * ({@code greenxertz}/{@code cindara}/{@code glacira}/{@code station}) plus a terraform + * before/after pair, summoning frozen signature mobs and shooting a vista in each. Planet + * terrain is seed-dependent, so run this in a FIXED-SEED capture world and tune the coords in + * {@link #buildPlanetShots} to that seed.
  • + *
  • {@code /nsgallery shot } — capture the CURRENT view once (HUD hidden) to + * {@code nerospace/.png}. Use this to grab a hand-framed money shot.
  • + *
+ * + *

PROTOTYPE NOTE: the per-shot camera vantages below are deliberate-but-rough starting points + * (each cluster framed from a few blocks "south" looking north). Wide strips like the machine line + * and rocket row won't fully fit in one frame — tune {@link #buildShots} or split them. Camera time + * of day / weather are pinned at the start of a {@code capture} run for consistent lighting. + */ +@EventBusSubscriber(modid = Nerospace.MODID, value = Dist.CLIENT) +public final class GalleryCaptureHarness { + + /** Ticks to let chunks/entities settle and a fresh frame render before grabbing. */ + private static final int SETTLE_TICKS = 12; + + /** Ticks to wait after teleporting + triggering the build for the scene to load and sync. */ + private static final int BUILD_WARMUP_TICKS = 120; + + /** Ticks to wait after teleporting into a planet dimension (cross-dim load + chunk gen + summons). */ + private static final int PLANET_WARMUP_TICKS = 100; + + /** Deterministic flat backdrop (data/nerospace/dimension/capture.json) + the fixed build origin in it. */ + private static final String CAPTURE_DIMENSION = "nerospace:capture"; + private static final int ORIGIN_X = 0; + private static final int ORIGIN_Y = 64; + private static final int ORIGIN_Z = 0; + + /** + * One framed capture. {@code setup} = server commands run before this shot (teleport, build, + * summon…); {@code warmup} = ticks to wait after setup for the scene to load/sync; {@code camera}/ + * {@code target} = the pose ({@code null} poses → "keep current view"). + */ + private record Shot(String name, java.util.List setup, java.util.List build, + int warmup, @Nullable Vec3 camera, @Nullable Vec3 target) { + } + + private enum Phase { WARMUP, MOVE, SETTLE, SHOOT } + + private static final Deque QUEUE = new ArrayDeque<>(); + private static boolean running; + private static boolean hudWasHidden; + @Nullable + private static CloudStatus cloudsWere; + private static Phase phase = Phase.MOVE; + private static int warmup; + private static int settle; + @Nullable + private static Shot current; + + private GalleryCaptureHarness() { + } + + @SubscribeEvent + public static void onRegisterClientCommands(RegisterClientCommandsEvent event) { + event.getDispatcher().register( + Commands.literal("nsgallery") + .then(Commands.literal("capture") + .executes(ctx -> startCapture("noon")) + .then(Commands.argument("time", StringArgumentType.word()) + .executes(ctx -> startCapture(StringArgumentType.getString(ctx, "time"))))) + .then(Commands.literal("planets") + .executes(ctx -> startPlanets("noon")) + .then(Commands.argument("time", StringArgumentType.word()) + .executes(ctx -> startPlanets(StringArgumentType.getString(ctx, "time"))))) + .then(Commands.literal("shot") + .then(Commands.argument("name", StringArgumentType.word()) + .executes(ctx -> shotHere(StringArgumentType.getString(ctx, "name")))))); + } + + private static int startCapture(String time) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.level == null) { + return 0; + } + if (running) { + mc.player.sendSystemMessage(Component.literal("Gallery capture already running.")); + return 0; + } + java.util.List shots = new java.util.ArrayList<>(buildShots(ORIGIN_X, ORIGIN_Y, ORIGIN_Z)); + // The first shot carries the one-time setup: teleport into the flat capture dimension, (re)build + // the gallery there, and freeze the environment so every rerun is framed + lit identically. + // These are server commands (they need cheats — the creative gallery world has them). + java.util.List setup = java.util.List.of( + "execute in " + CAPTURE_DIMENSION + " run tp @s " + + (ORIGIN_X + 0.5) + " " + ORIGIN_Y + " " + (ORIGIN_Z + 0.5), + "nerospace gallery clear", // no-op first run; stops reruns stacking + "nerospace gallery", + "gamerule advance_time false", // 26.1 renamed doDaylightCycle → advance_time + "gamerule advance_weather false", // …and doWeatherCycle → advance_weather + "time set " + time, // capture dim is overworld-type → has a clock + "weather clear"); + Shot first = shots.get(0); + shots.set(0, new Shot(first.name(), setup, java.util.List.of(), BUILD_WARMUP_TICKS, + first.camera(), first.target())); + + QUEUE.clear(); + QUEUE.addAll(shots); + begin(mc); + mc.player.sendSystemMessage(Component.literal("Gallery capture: building scene, time=" + time + ", " + + shots.size() + " shots → screenshots/nerospace/ (HUD hidden).")); + return Command.SINGLE_SUCCESS; + } + + private static int startPlanets(String time) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.level == null) { + return 0; + } + if (running) { + mc.player.sendSystemMessage(Component.literal("Capture already running.")); + return 0; + } + QUEUE.clear(); + QUEUE.addAll(buildPlanetShots(time)); + begin(mc); + mc.player.sendSystemMessage(Component.literal("Planet capture: " + QUEUE.size() + + " shots across dimensions, time=" + time + " → screenshots/nerospace/ (HUD hidden).")); + return Command.SINGLE_SUCCESS; + } + + private static int shotHere(String name) { + Minecraft mc = Minecraft.getInstance(); + if (mc.player == null || mc.level == null || running) { + return 0; + } + QUEUE.clear(); + QUEUE.add(new Shot(name, java.util.List.of(), java.util.List.of(), 0, null, null)); // grab the current view as-is + begin(mc); + return Command.SINGLE_SUCCESS; + } + + private static void begin(Minecraft mc) { + // screenshots/ is created by Screenshot.grab; the nerospace/ subfolder is not, so make it. + new File(mc.gameDirectory, "screenshots/nerospace").mkdirs(); + hudWasHidden = mc.options.hideGui; + mc.options.hideGui = true; + cloudsWere = mc.options.cloudStatus().get(); + mc.options.cloudStatus().set(CloudStatus.OFF); // clouds scroll with game time → freeze them out of frame + running = true; + phase = Phase.MOVE; // the first shot's setup carries any teleport/build; warmup is per-shot now + current = null; + } + + @SubscribeEvent + public static void onClientTick(ClientTickEvent.Post event) { + if (!running) { + return; + } + Minecraft mc = Minecraft.getInstance(); + LocalPlayer player = mc.player; + if (player == null) { + finish(mc); + return; + } + + switch (phase) { + case MOVE -> { + final Shot shot = QUEUE.poll(); + current = shot; + if (shot == null) { + finish(mc); + return; + } + for (String cmd : shot.setup()) { // pre-warmup: teleport / gamerules / time / summon + player.connection.sendCommand(cmd); + } + warmup = shot.warmup(); + phase = Phase.WARMUP; + } + case WARMUP -> { + final Shot shot = current; + if (shot == null) { + phase = Phase.MOVE; + return; + } + if (--warmup <= 0) { // teleport + chunks now loaded + for (String cmd : shot.build()) { // block placement needs loaded chunks (else "not loaded") + player.connection.sendCommand(cmd); + } + applyPose(player, shot); + settle = SETTLE_TICKS; + phase = Phase.SETTLE; + } + } + case SETTLE -> { + final Shot shot = current; + if (shot == null) { + phase = Phase.MOVE; + return; + } + applyPose(player, shot); // re-pin every tick so gravity/AI can't drift the camera + if (--settle <= 0) { + phase = Phase.SHOOT; + } + } + case SHOOT -> { + final Shot shot = current; + if (shot == null) { + phase = Phase.MOVE; + return; + } + grab(mc, shot.name()); + phase = Phase.MOVE; + } + } + } + + /** Snap the player (the render camera) to the shot's pose, holding it still. */ + private static void applyPose(LocalPlayer player, @Nullable Shot shot) { + if (shot == null) { + return; + } + Vec3 cam = shot.camera(); + Vec3 tgt = shot.target(); + if (cam == null || tgt == null) { + return; // "keep current view" shot + } + double dx = tgt.x - cam.x; + double dy = tgt.y - cam.y; + double dz = tgt.z - cam.z; + double horiz = Math.sqrt(dx * dx + dz * dz); + float yaw = (float) (Math.toDegrees(Math.atan2(dz, dx)) - 90.0); + float pitch = (float) (-Math.toDegrees(Math.atan2(dy, horiz))); + player.snapTo(cam.x, cam.y, cam.z, yaw, pitch); + player.setDeltaMovement(Vec3.ZERO); + } + + private static void grab(Minecraft mc, String name) { + RenderTarget target = mc.getMainRenderTarget(); + Screenshot.grab(mc.gameDirectory, "nerospace/" + name + ".png", target, 1, + msg -> { + if (mc.player != null) { + mc.player.sendSystemMessage(msg); + } + }); + } + + private static void finish(Minecraft mc) { + mc.options.hideGui = hudWasHidden; + if (cloudsWere != null) { + mc.options.cloudStatus().set(cloudsWere); + } + running = false; + current = null; + QUEUE.clear(); + if (mc.player != null) { + mc.player.sendSystemMessage(Component.literal("Gallery capture done — see screenshots/nerospace/.")); + } + } + + /** + * Explicit per-cluster vantages. Clusters sit on a ring ~48 blocks out (mirror of the rotunda in + * NerospaceCommands): rockets N, machines S, blocks E, pipes W, creatures SE, suits NW. Each camera + * is hand-shaped for its subject (tall vs thin-strip vs row), so they're not uniform. Coordinates + * mirror the cluster bases in NerospaceCommands — tune the two together. + */ + private static java.util.List buildShots(int ox, int oy, int oz) { + java.util.List none = java.util.List.of(); + java.util.List shots = new java.util.ArrayList<>(); + // Rockets (N, tall): low camera looking slightly up. + shots.add(new Shot("rockets", none, none, 0, + new Vec3(ox - 0.5, oy + 3, oz - 26), new Vec3(ox - 0.5, oy + 5, oz - 48))); + // Machines (S, thin strip): 45° top-down, pulled ~10 closer (camera 12 out + 12 up over the strip). + shots.add(new Shot("machines", none, none, 0, + new Vec3(ox, oy + 13, oz + 36), new Vec3(ox, oy + 1, oz + 48))); + // Blocks (E, grid): raised, looking down. + shots.add(new Shot("blocks", none, none, 0, + new Vec3(ox + 30, oy + 9, oz), new Vec3(ox + 48, oy + 2, oz))); + // Pipes (W, rows): raised, looking down so the 4 resource rows read. + shots.add(new Shot("pipes", none, none, 0, + new Vec3(ox - 34, oy + 10, oz), new Vec3(ox - 48, oy + 1, oz))); + // Creatures (SE, row along +X): over-the-shoulder — low, near the west end, looking E down the line. + shots.add(new Shot("creatures", none, none, 0, + new Vec3(ox + 14, oy + 4, oz + 33), new Vec3(ox + 44, oy + 2, oz + 34))); + // Suits (NW, row along +X, stands face ~south): close, head-on from the south. + shots.add(new Shot("suits", none, none, 0, + new Vec3(ox - 33, oy + 3, oz - 26), new Vec3(ox - 34, oy + 2.5, oz - 34))); + // Quarry landmark-only display (NE): the L of three landmarks + their projected lasers. + shots.add(new Shot("quarry_landmarks", none, none, 0, + new Vec3(ox + 31, oy + 4, oz - 30), new Vec3(ox + 31, oy + 2, oz - 38))); + // Operating quarry (NE, further out): raised + angled to look INTO the pit and read the + // glowing frame, the moving drill head and the power hookup. + shots.add(new Shot("quarry_operating", none, none, 0, + new Vec3(ox + 44, oy + 9, oz - 28), new Vec3(ox + 42, oy - 2, oz - 39))); + // Meteor crash site (SW, mirrors buildMeteorSite at ox-28, oz+30): low angle so the crater + + // glowing core read, with the hovering meteor (fy+11) and its trail filling the upper frame. + shots.add(new Shot("meteor_site", none, none, 0, + new Vec3(ox - 20, oy + 4, oz + 24), new Vec3(ox - 28, oy + 4, oz + 30))); + // Solar arrays (SW): raised, looking south down the cluster so the front-row single units AND + // the seam-joined multi-unit fields behind them (plus the cabled connector) read together. + shots.add(new Shot("solar", none, none, 0, + new Vec3(ox - 42, oy + 9, oz + 28), new Vec3(ox - 42, oy + 1, oz + 42))); + return shots; + } + + /** + * Planet/dimension vistas (§9.4). Each shot teleports into a planet dimension, pins time/weather, + * summons frozen signature mobs, then frames a vista; the terraform pair shows one patch barren + * then greened. Reproducible ONLY in a fixed-seed capture world — planet terrain is seed-dependent. + * + *

WARNING: the coordinates here are PLACEHOLDERS. Terrain height/features depend on the world + * seed, so tune cam/target/summon coords to your chosen capture seed (same run→look→nudge loop as + * the gallery). Bad coords just mean a poorly-framed shot, not a crash. + */ + private static java.util.List buildPlanetShots(String time) { + java.util.List shots = new java.util.ArrayList<>(); + // Greenxertz has a clock (overworld-type) → time set works. Cindara/Glacira/Station use the + // "space" dimension type (END starfield, NO clock), so time set must be skipped there — they're + // dark by design. The signature mobs are summoned pre-warmup so they fall to the surface. + // Each mob shot kills its prior summons first (NoAI mobs are persistent → they'd stack on rerun). + shots.add(planetShot("greenxertz_vista", "nerospace:greenxertz", time, true, + new Vec3(8.5, 96, 8.5), new Vec3(28, 88, 28), java.util.List.of( + "kill @e[type=nerospace:xertz_stalker]", + "kill @e[type=nerospace:quartz_crawler]", + "summon nerospace:xertz_stalker 26 90 26 {NoAI:1b,PersistenceRequired:1b}", + "summon nerospace:quartz_crawler 30 90 30 {NoAI:1b,PersistenceRequired:1b}"), + java.util.List.of())); + shots.add(planetShot("cindara_basin", "nerospace:cindara", time, false, + new Vec3(8.5, 96, 8.5), new Vec3(28, 88, 28), java.util.List.of( + "kill @e[type=nerospace:cinder_stalker]", + "kill @e[type=nerospace:ember_strutter]", + "summon nerospace:cinder_stalker 26 90 26 {NoAI:1b,PersistenceRequired:1b}", + "summon nerospace:ember_strutter 30 90 30 {NoAI:1b,PersistenceRequired:1b}"), + java.util.List.of())); + shots.add(planetShot("glacira_frost", "nerospace:glacira", time, false, + new Vec3(8.5, 96, 8.5), new Vec3(28, 88, 28), java.util.List.of( + "kill @e[type=nerospace:frost_strider]", + "kill @e[type=nerospace:woolly_drift]", + "summon nerospace:frost_strider 26 90 26 {NoAI:1b,PersistenceRequired:1b}", + "summon nerospace:woolly_drift 30 90 30 {NoAI:1b,PersistenceRequired:1b}"), + java.util.List.of())); + // Orbital station is an empty space dimension until something is built — place a small platform + // (floor + two walls + a Station Core) at a fixed spot and shoot it against the starfield. The + // placement runs POST-warmup (build list) so the chunks are loaded ("position not loaded" else). + shots.add(planetShot("orbital_station", "nerospace:station", time, false, + new Vec3(-6.5, 71, -6.5), new Vec3(3, 65, 3), java.util.List.of(), + java.util.List.of( + "fill -1 64 -1 7 67 7 minecraft:air", // clear any prior platform before rebuilding + "fill 0 64 0 6 64 6 nerospace:station_floor", + "fill 0 65 0 6 65 0 nerospace:station_wall", + "fill 0 65 0 0 65 6 nerospace:station_wall", + "setblock 3 65 3 nerospace:station_core"))); + // Terraform before/after on CINDARA (the barren volcanic-ash world). AFTER converts a patch to + // the nerospace:terraformed_meadow biome (the neon-emerald "living" palette) + grass surface, + // two trees and a Meadow Loper. Block ops run post-warmup. NOTE: Cindara is a no-sun space dim, + // so the patch will be dim; if you want a bright "living" shot say so and I'll stage it on + // sunlit Greenxertz instead. by is a guess at the surface — tune to the seed. + int bx = 60; + int by = 70; + int bz = 60; + Vec3 tcam = new Vec3(bx - 6, by + 7, bz - 6); + Vec3 ttgt = new Vec3(bx + 6, by, bz + 6); + // A controlled flat patch built fresh each run (so reruns don't leave a dug pit / stale biome / + // floating mismatch from guessing Cindara's surface): clear the column to air, set the BIOME, + // then lay a 2-deep base topped with the surface block. BEFORE = barren coarse-dirt on the native + // (cindara) biome; AFTER = grass on the neon terraformed_meadow biome, with trees + a Meadow Loper. + int x2 = bx + 12; + int z2 = bz + 12; + String clearPatch = "fill " + (bx - 2) + " " + by + " " + (bz - 2) + " " + (bx + 14) + " " + + (by + 12) + " " + (bz + 14) + " minecraft:air"; + String base = "fill " + bx + " " + (by - 1) + " " + bz + " " + x2 + " " + (by - 1) + " " + z2 + + " minecraft:dirt"; + shots.add(planetShot("terraform_before", "nerospace:cindara", time, false, tcam, ttgt, + java.util.List.of(), java.util.List.of( + clearPatch, + "kill @e[type=nerospace:meadow_loper]", + "fillbiome " + bx + " " + (by - 1) + " " + bz + " " + x2 + " " + (by + 10) + " " + z2 + + " nerospace:cindara", // reset biome → barren (else the prior green lingers) + base, + "fill " + bx + " " + by + " " + bz + " " + x2 + " " + by + " " + z2 + " minecraft:coarse_dirt"))); + shots.add(planetShot("terraform_after", "nerospace:cindara", time, false, tcam, ttgt, + java.util.List.of(), java.util.List.of( + clearPatch, + "kill @e[type=nerospace:meadow_loper]", + "fillbiome " + bx + " " + (by - 1) + " " + bz + " " + x2 + " " + (by + 10) + " " + z2 + + " nerospace:terraformed_meadow", + base, + "fill " + bx + " " + by + " " + bz + " " + x2 + " " + by + " " + z2 + " minecraft:grass_block", + "place feature minecraft:oak " + (bx + 3) + " " + (by + 1) + " " + (bz + 3), + "place feature minecraft:oak " + (bx + 9) + " " + (by + 1) + " " + (bz + 8), + "summon nerospace:meadow_loper " + (bx + 6) + " " + (by + 1) + " " + (bz + 6) + + " {NoAI:1b,PersistenceRequired:1b}"))); + return shots; + } + + /** + * Build a planet shot. {@code summons} run pre-warmup (so mobs fall to the surface during the wait); + * {@code builds} run post-warmup (block placement needs the chunks loaded). {@code hasClock} gates + * {@code time set} — space dimensions have no clock and reject it. + */ + private static Shot planetShot(String name, String dim, String time, boolean hasClock, Vec3 cam, Vec3 tgt, + java.util.List summons, java.util.List builds) { + java.util.List setup = new java.util.ArrayList<>(); + setup.add("execute in " + dim + " run tp @s " + cam.x + " " + cam.y + " " + cam.z); + setup.add("gamerule advance_time false"); // 26.1: replaces doDaylightCycle + setup.add("gamerule advance_weather false"); // 26.1: replaces doWeatherCycle + setup.add("weather clear"); + if (hasClock) { + setup.add("time set " + time); // only dims with a clock accept this + } + setup.addAll(summons); + return new Shot(name, setup, builds, PLANET_WARMUP_TICKS, cam, tgt); + } +} diff --git a/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java b/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java index 14b1183..f3be841 100644 --- a/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java +++ b/src/main/java/za/co/neroland/nerospace/command/NerospaceCommands.java @@ -1,609 +1,608 @@ -package za.co.neroland.nerospace.command; - -import java.util.ArrayList; -import java.util.List; - -import com.mojang.brigadier.Command; - -import net.minecraft.commands.CommandSourceStack; -import net.minecraft.commands.Commands; -import net.minecraft.core.BlockPos; -import net.minecraft.core.Direction; -import net.minecraft.network.chat.Component; -import net.minecraft.server.level.ServerLevel; -import net.minecraft.server.level.ServerPlayer; -import net.minecraft.world.entity.Entity; -import net.minecraft.world.entity.EntitySpawnReason; -import net.minecraft.world.entity.EntityType; -import net.minecraft.world.entity.EquipmentSlot; -import net.minecraft.world.entity.Mob; -import net.minecraft.world.entity.decoration.ArmorStand; -import net.minecraft.world.entity.player.Player; -import net.minecraft.world.item.Item; -import net.minecraft.world.item.ItemStack; -import net.minecraft.world.item.Items; -import net.minecraft.world.level.block.Block; -import net.minecraft.world.level.block.Blocks; -import net.minecraft.world.level.block.LeverBlock; -import net.minecraft.world.level.block.state.BlockState; -import net.minecraft.world.level.block.state.properties.AttachFace; -import net.minecraft.world.level.material.Fluids; -import net.minecraft.world.phys.AABB; -import net.neoforged.bus.api.SubscribeEvent; -import net.neoforged.fml.common.EventBusSubscriber; -import net.neoforged.neoforge.event.RegisterCommandsEvent; -import net.neoforged.neoforge.transfer.fluid.FluidResource; -import net.neoforged.neoforge.transfer.item.ItemResource; - -import za.co.neroland.nerospace.Nerospace; -import za.co.neroland.nerospace.meteor.FallingMeteorEntity; -import za.co.neroland.nerospace.meteor.MeteorCoreBlockEntity; -import za.co.neroland.nerospace.machine.CombustionGeneratorBlockEntity; -import za.co.neroland.nerospace.machine.FuelRefineryBlockEntity; -import za.co.neroland.nerospace.machine.HydrationModuleBlockEntity; -import za.co.neroland.nerospace.machine.NerosiumGrinderBlockEntity; -import za.co.neroland.nerospace.machine.quarry.QuarryControllerBlockEntity; -import za.co.neroland.nerospace.machine.quarry.QuarryRegion; -import za.co.neroland.nerospace.pipe.PipeIoMode; -import za.co.neroland.nerospace.pipe.PipeResourceType; -import za.co.neroland.nerospace.pipe.UniversalPipeBlockEntity; -import za.co.neroland.nerospace.registry.ModBlocks; -import za.co.neroland.nerospace.registry.ModEntities; -import za.co.neroland.nerospace.registry.ModItems; -import za.co.neroland.nerospace.rocket.RocketEntity; -import za.co.neroland.nerospace.rocket.RocketLaunchPadBlock; -import za.co.neroland.nerospace.rocket.RocketTier; -import za.co.neroland.nerospace.storage.CreativeFluidTankBlockEntity; -import za.co.neroland.nerospace.storage.CreativeItemStoreBlockEntity; - -/** - * Creative-only debug commands (cheats / op level 2). {@code /nerospace gallery} builds a showcase - * platform near the player: every Nerospace block floating two blocks above the floor (so all faces - * are visible) on a ~3-block grid, every machine RUNNING the way it is meant to be wired in - * survival (fuelled, powered and fed — except the Terraformer and Oxygen Generator, which sit - * behind an off lever so the world-changing machines only run when deliberately switched on), all - * four rocket tiers standing on their required pad formations, every suit variant on a stand, and - * each creature spawned twice — once with AI and once frozen (NoAI) — for inspection. - * {@code /nerospace gallery clear} wipes that footprint (blocks + spawned entities) so a rebuild — - * or the screenshot harness — doesn't stack duplicates. - */ -@EventBusSubscriber(modid = Nerospace.MODID) -public final class NerospaceCommands { - - private static final int SPACING = 3; // blocks between display cells - private static final int FLOAT_ABOVE = 3; // display sits this many blocks above the floor (2 air gap) - private static final int SUIT_SPACING = 3; // blocks between suit stands (roomier than the old 2) - private static final float SUIT_YAW = -10.0f; // every suit stand faces one way, angled a few degrees left - - private NerospaceCommands() { - } - - @SubscribeEvent - public static void onRegister(RegisterCommandsEvent event) { - // Player-only; the executor further restricts to creative. (Commands themselves require the - // world to have cheats/commands enabled, so this is effectively creative + commands gated.) - event.getDispatcher().register( - Commands.literal("nerospace") - .requires(src -> src.getPlayer() != null) - .then(Commands.literal("gallery") - .executes(ctx -> buildGallery(ctx.getSource())) - .then(Commands.literal("clear") - .executes(ctx -> clearGallery(ctx.getSource()))))); - } - - private static int buildGallery(CommandSourceStack source) { - ServerPlayer player = source.getPlayer(); - if (player == null) { - source.sendFailure(Component.literal("Run this as a player.")); - return 0; - } - if (!player.getAbilities().instabuild) { - source.sendFailure(Component.literal("The Nerospace gallery is creative-only.")); - return 0; - } - ServerLevel level = player.level(); - BlockPos origin = player.blockPosition(); - - List blocks = new ArrayList<>(); - for (var holder : ModBlocks.BLOCKS.getEntries()) { - Block block = holder.value(); - if (block != ModBlocks.ROCKET_FUEL_BLOCK.get()) { // skip the fluid block (renders oddly free-standing) - blocks.add(block); - } - } - - int cols = (int) Math.ceil(Math.sqrt(Math.max(1, blocks.size()))); - int rows = (int) Math.ceil(blocks.size() / (double) cols); - // ROTUNDA: each cluster sits on a ring ~48 blocks out on its own compass bearing, so a camera - // near the centre shoots each one outward against empty ground with no other display in frame. - // The /nsgallery capture harness mirrors these bearings. Bases are placed so the body centres - // on the ring. Tune distances together with the harness if reframing. - int ox = origin.getX() + 38; // block grid → EAST - int oz = origin.getZ() - 9; - int fy = origin.getY(); - - BlockState floor = ModBlocks.STATION_FLOOR.get().defaultBlockState(); - - // Floor slab under the whole grid (with a 1-block margin). - for (int gx = -1; gx <= cols * SPACING; gx++) { - for (int gz = -1; gz <= rows * SPACING; gz++) { - level.setBlockAndUpdate(new BlockPos(ox + gx, fy, oz + gz), floor); - } - } - // Floating block displays (2 air blocks below each → visible from all angles). - for (int i = 0; i < blocks.size(); i++) { - int col = i % cols; - int row = i / cols; - level.setBlockAndUpdate( - new BlockPos(ox + col * SPACING, fy + FLOAT_ABOVE, oz + row * SPACING), - blocks.get(i).defaultBlockState()); - } - - // MACHINES, ALL RUNNING (one strip, four wired clusters — each exactly the survival hookup): - // A. Combustion Generator (coal) → pipe → Grinder (raw nerosium), Passive Generator feeding in. - // B. Creative Battery → pipe → Fuel Refinery (coal + blaze powder) → pipe → Fuel Tank. - // C. Creative Battery → pipe → Oxygen Generator — parked behind an OFF lever. - // D. Creative Battery → pipe → Terraformer + touching Hydration Module (glacite) and - // Terraform Monitor — parked behind an OFF lever (it WILL reshape the area when on). - int sx = origin.getX() - 13; // machine strip → SOUTH - int sz = origin.getZ() + 48; - for (int dx = -1; dx <= 27; dx++) { - for (int dz = -2; dz <= 2; dz++) { - level.setBlockAndUpdate(new BlockPos(sx + dx, fy, sz + dz), floor); - } - } - BlockState lever = Blocks.LEVER.defaultBlockState() - .setValue(LeverBlock.FACE, AttachFace.FLOOR) - .setValue(LeverBlock.FACING, Direction.EAST); - - // A: the classic first power line. - level.setBlockAndUpdate(new BlockPos(sx, fy + 1, sz), ModBlocks.COMBUSTION_GENERATOR.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(sx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(sx + 2, fy + 1, sz), ModBlocks.NEROSIUM_GRINDER.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(sx + 1, fy + 1, sz + 1), ModBlocks.PASSIVE_GENERATOR.get().defaultBlockState()); - if (level.getBlockEntity(new BlockPos(sx, fy + 1, sz)) instanceof CombustionGeneratorBlockEntity gen) { - gen.setItem(CombustionGeneratorBlockEntity.FUEL_SLOT, new ItemStack(Items.COAL, 64)); - } - if (level.getBlockEntity(new BlockPos(sx + 2, fy + 1, sz)) instanceof NerosiumGrinderBlockEntity grinder) { - grinder.setItem(NerosiumGrinderBlockEntity.INPUT_SLOT, new ItemStack(ModItems.RAW_NEROSIUM.get(), 64)); - } - - // B: refining line — power in from the endless battery, fuel out into a Fuel Tank. - int bx = sx + 6; - level.setBlockAndUpdate(new BlockPos(bx, fy + 1, sz), ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(bx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(bx + 2, fy + 1, sz), ModBlocks.FUEL_REFINERY.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(bx + 3, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(bx + 4, fy + 1, sz), ModBlocks.FUEL_TANK.get().defaultBlockState()); - setAllModes(level, new BlockPos(bx + 1, fy + 1, sz), Direction.WEST, PipeIoMode.IN); - setAllModes(level, new BlockPos(bx + 1, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); - setAllModes(level, new BlockPos(bx + 3, fy + 1, sz), Direction.WEST, PipeIoMode.IN); - setAllModes(level, new BlockPos(bx + 3, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); - if (level.getBlockEntity(new BlockPos(bx + 2, fy + 1, sz)) instanceof FuelRefineryBlockEntity refinery) { - refinery.setItem(FuelRefineryBlockEntity.CARBON_SLOT, new ItemStack(Items.COAL, 64)); - refinery.setItem(FuelRefineryBlockEntity.CATALYST_SLOT, new ItemStack(Items.BLAZE_POWDER, 64)); - } - - // C: oxygen generator behind its lever (off until flipped — then the bubble forms). - int cx = sx + 13; - level.setBlockAndUpdate(new BlockPos(cx, fy + 1, sz), ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(cx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(cx + 2, fy + 1, sz), ModBlocks.OXYGEN_GENERATOR.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(cx + 3, fy + 1, sz), lever); - setAllModes(level, new BlockPos(cx + 1, fy + 1, sz), Direction.WEST, PipeIoMode.IN); - setAllModes(level, new BlockPos(cx + 1, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); - - // D: terraformer cluster behind its lever, with the full deeper-terraform support crew. - int tx = sx + 19; - level.setBlockAndUpdate(new BlockPos(tx, fy + 1, sz), ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(tx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(tx + 2, fy + 1, sz), ModBlocks.TERRAFORMER.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(tx + 2, fy + 1, sz + 1), ModBlocks.HYDRATION_MODULE.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(tx + 2, fy + 1, sz - 1), ModBlocks.TERRAFORM_MONITOR.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(tx + 3, fy + 1, sz), lever); - setAllModes(level, new BlockPos(tx + 1, fy + 1, sz), Direction.WEST, PipeIoMode.IN); - setAllModes(level, new BlockPos(tx + 1, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); - if (level.getBlockEntity(new BlockPos(tx + 2, fy + 1, sz + 1)) instanceof HydrationModuleBlockEntity module) { - module.setItem(HydrationModuleBlockEntity.INPUT_SLOT, new ItemStack(ModItems.GLACITE.get(), 64)); - } - - // FOUR LIVE PIPE SCENARIOS: creative source → 3 pipes → sink, one row per resource layer. - // The source-touching face is set IN (pull-only — otherwise the pipe would void its buffer - // back into the endless source) and the sink-touching face OUT, mirroring real Configurator use. - int px = origin.getX() - 50; // pipe scenarios → WEST (rows run north-south → broadside from the centre) - int pz = origin.getZ() + 5; - Block[][] scenarioRows = { - {ModBlocks.CREATIVE_BATTERY.get(), ModBlocks.BATTERY.get()}, - {ModBlocks.CREATIVE_FLUID_TANK.get(), ModBlocks.FLUID_TANK.get()}, - {ModBlocks.CREATIVE_GAS_TANK.get(), ModBlocks.GAS_TANK.get()}, - {ModBlocks.CREATIVE_ITEM_STORE.get(), ModBlocks.ITEM_STORE.get()}, - }; - for (int row = 0; row < scenarioRows.length; row++) { - int rz = pz - row * 3; - for (int dx = -1; dx <= 5; dx++) { - for (int dz = -1; dz <= 1; dz++) { - level.setBlockAndUpdate(new BlockPos(px + dx, fy, rz + dz), floor); - } - } - level.setBlockAndUpdate(new BlockPos(px, fy + 1, rz), scenarioRows[row][0].defaultBlockState()); - for (int dx = 1; dx <= 3; dx++) { - level.setBlockAndUpdate(new BlockPos(px + dx, fy + 1, rz), - ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - } - level.setBlockAndUpdate(new BlockPos(px + 4, fy + 1, rz), scenarioRows[row][1].defaultBlockState()); - setAllModes(level, new BlockPos(px + 1, fy + 1, rz), Direction.WEST, PipeIoMode.IN); - setAllModes(level, new BlockPos(px + 3, fy + 1, rz), Direction.EAST, PipeIoMode.OUT); - } - // Pre-configure the endless sources so the rows run on arrival. - if (level.getBlockEntity(new BlockPos(px, fy + 1, pz - 3)) instanceof CreativeFluidTankBlockEntity tank) { - tank.setSource(FluidResource.of(Fluids.WATER)); - } - if (level.getBlockEntity(new BlockPos(px, fy + 1, pz - 9)) instanceof CreativeItemStoreBlockEntity store) { - store.setSource(ItemResource.of(ModItems.NEROSIUM_INGOT.get())); - } - - // Suit displays (every variant) + a LOADED Star Guide pedestal (book installed → hologram runs). - int ax = origin.getX() - 40; // suits + Star Guide → NORTH-WEST (well clear of the pipes spoke) - int az = origin.getZ() - 34; - int suit0 = 0; - int suit1 = SUIT_SPACING; - int suit2 = SUIT_SPACING * 2; - int suit3 = SUIT_SPACING * 3; - int guideX = SUIT_SPACING * 4; - for (int dx = -1; dx <= guideX + 1; dx++) { - for (int dz = -1; dz <= 1; dz++) { - level.setBlockAndUpdate(new BlockPos(ax + dx, fy, az + dz), floor); - } - } - spawnSuitStand(level, new BlockPos(ax + suit0, fy + 1, az), Component.literal("Oxygen Suit"), SUIT_YAW, - ModItems.OXYGEN_SUIT_HELMET.get(), ModItems.OXYGEN_SUIT_CHESTPLATE.get(), - ModItems.OXYGEN_SUIT_LEGGINGS.get(), ModItems.OXYGEN_SUIT_BOOTS.get()); - spawnSuitStand(level, new BlockPos(ax + suit1, fy + 1, az), Component.literal("Tier 2 Oxygen Suit"), SUIT_YAW, - ModItems.OXYGEN_SUIT_T2_HELMET.get(), ModItems.OXYGEN_SUIT_T2_CHESTPLATE.get(), - ModItems.OXYGEN_SUIT_T2_LEGGINGS.get(), ModItems.OXYGEN_SUIT_T2_BOOTS.get()); - spawnSuitStand(level, new BlockPos(ax + suit2, fy + 1, az), Component.literal("Thermal Suit"), SUIT_YAW, - ModItems.OXYGEN_SUIT_HEAT_HELMET.get(), ModItems.OXYGEN_SUIT_HEAT_CHESTPLATE.get(), - ModItems.OXYGEN_SUIT_HEAT_LEGGINGS.get(), ModItems.OXYGEN_SUIT_HEAT_BOOTS.get()); - spawnSuitStand(level, new BlockPos(ax + suit3, fy + 1, az), Component.literal("Cryo Suit"), SUIT_YAW, - ModItems.OXYGEN_SUIT_COLD_HELMET.get(), ModItems.OXYGEN_SUIT_COLD_CHESTPLATE.get(), - ModItems.OXYGEN_SUIT_COLD_LEGGINGS.get(), ModItems.OXYGEN_SUIT_COLD_BOOTS.get()); - BlockPos guidePos = new BlockPos(ax + guideX, fy + 1, az); - level.setBlockAndUpdate(guidePos, ModBlocks.STAR_GUIDE.get().defaultBlockState()); - if (level.getBlockEntity(guidePos) - instanceof za.co.neroland.nerospace.progression.StarGuideBlockEntity guide) { - guide.installBook(new ItemStack(ModItems.STAR_GUIDE_BOOK.get())); - } - - // ROCKET ROW: every tier on the pad formation it actually requires (RocketItem gating): - // T1 + T2: a full 3x3 pad. T3: a 3x3 pad ringed with Station Wall. - // T4: the Heavy Launch Complex — full 5x5 pad + a Launch Gantry on its border ring. - int rx = origin.getX() - 14; // rocket row → NORTH (the hero spoke) - int rz0 = origin.getZ() - 49; - for (int dx = -2; dx <= 31; dx++) { - for (int dz = -3; dz <= 5; dz++) { - level.setBlockAndUpdate(new BlockPos(rx + dx, fy, rz0 + dz), floor); - } - } - BlockState pad = ModBlocks.ROCKET_LAUNCH_PAD.get().defaultBlockState(); - // T1 (3x3 + the classic pad-side Fuel Tank). - fillPad(level, new BlockPos(rx, fy + 1, rz0), 3, pad); - level.setBlockAndUpdate(new BlockPos(rx + 3, fy + 1, rz0 + 1), ModBlocks.FUEL_TANK.get().defaultBlockState()); - spawnRocket(level, rx + 1, fy + 1, rz0 + 1, RocketTier.TIER_1); - // T2 (3x3). - fillPad(level, new BlockPos(rx + 8, fy + 1, rz0), 3, pad); - spawnRocket(level, rx + 9, fy + 1, rz0 + 1, RocketTier.TIER_2); - // T3 (3x3 ringed with Station Wall). - fillPad(level, new BlockPos(rx + 16, fy + 1, rz0), 3, pad); - BlockState wall = ModBlocks.STATION_WALL.get().defaultBlockState(); - for (int dx = -1; dx <= 3; dx++) { - for (int dz = -1; dz <= 3; dz++) { - if (dx == -1 || dx == 3 || dz == -1 || dz == 3) { - level.setBlockAndUpdate(new BlockPos(rx + 16 + dx, fy + 1, rz0 + dz), wall); - } - } - } - spawnRocket(level, rx + 17, fy + 1, rz0 + 1, RocketTier.TIER_3); - // T4 (Heavy Launch Complex: 5x5 + gantry + fuel tank). - fillPad(level, new BlockPos(rx + 24, fy + 1, rz0 - 1), 5, pad); - level.setBlockAndUpdate(new BlockPos(rx + 23, fy + 1, rz0 + 1), - ModBlocks.LAUNCH_GANTRY.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(rx + 29, fy + 1, rz0 + 1), - ModBlocks.FUEL_TANK.get().defaultBlockState()); - spawnRocket(level, rx + 26, fy + 1, rz0 + 1, RocketTier.TIER_4); - - // Creatures: each spawned twice — live (AI) and frozen (NoAI) — on a small floor strip. - int mx = origin.getX() + 18; // creatures → SOUTH-EAST - int mz = origin.getZ() + 33; - for (int dx = -1; dx <= 8 * 4; dx++) { - for (int dz = -1; dz <= 3; dz++) { - level.setBlockAndUpdate(new BlockPos(mx + dx, fy, mz + dz), floor); - } - } - List> creatures = List.of( - ModEntities.XERTZ_STALKER.get(), ModEntities.QUARTZ_CRAWLER.get(), - ModEntities.GREENLING.get(), ModEntities.CINDER_STALKER.get(), - ModEntities.FROST_STRIDER.get(), - // Terraform livestock (DEEPER_TERRAFORM_DESIGN.md §5). - ModEntities.MEADOW_LOPER.get(), ModEntities.EMBER_STRUTTER.get(), - ModEntities.WOOLLY_DRIFT.get()); - // One frozen (NoAI) row only — AI mobs wander, which breaks reproducible screenshots. - for (int i = 0; i < creatures.size(); i++) { - spawnShowcase(level, creatures.get(i), new BlockPos(mx + i * 4, fy + 1, mz + 1), true); - } - - // METEOR SITE (meteor-events-design.md): a small crater of meteor_rock around a loot-bearing - // meteor_core, with a frozen meteor hovering above it (spins + trails for the shot). SW spoke. - buildMeteorSite(level, floor, origin.getX() - 28, origin.getZ() + 30, fy); - - // QUARRY (MINER_DESIGN): two NE displays. - // 1. Landmark-only — three landmarks in an L (shows the projected marker lasers). - // 2. Fully operating — a powered quarry mid-dig: frame ring, drill head, a real pit forming. - BlockState landmark = ModBlocks.QUARRY_LANDMARK.get().defaultBlockState(); - int lx = origin.getX() + 28; // landmark-only display (NE, nearer the centre) - int lz = origin.getZ() - 40; - for (int dx = -1; dx <= 7; dx++) { - for (int dz = -1; dz <= 7; dz++) { - level.setBlockAndUpdate(new BlockPos(lx + dx, fy, lz + dz), floor); - } - } - level.setBlockAndUpdate(new BlockPos(lx, fy + 1, lz), landmark); - level.setBlockAndUpdate(new BlockPos(lx + 6, fy + 1, lz), landmark); - level.setBlockAndUpdate(new BlockPos(lx, fy + 1, lz + 6), landmark); - - // Operating quarries: staged straight into a deep mid-dig so the frame, gantry, drill head and - // interior-only excavation all read at a glance. Two sizes — a standard 9x9 and a big 17x17 to - // stress-test rendering + mining over a large area. - buildGalleryQuarry(level, floor, origin.getX() + 42, origin.getZ() - 40, fy, 8, 8); - buildGalleryQuarry(level, floor, origin.getX() + 64, origin.getZ() - 56, fy, 16, 12); - - // SOLAR ARRAYS (SOLAR_PANEL_DESIGN, SW bearing): one unit per tier, then a multi-unit seam-joined - // field per tier (so the per-cell trackers reading as one surface is visible), plus a - // battery → universal cable → panel hookup that lights the panel's power connector. - buildSolarArrays(level, floor, origin.getX() - 50, origin.getZ() + 36, fy); - - source.sendSuccess(() -> Component.literal("Built the Nerospace gallery: " - + blocks.size() + " blocks, 4 RUNNING machine clusters (grinder line, fuel refinery " - + "line, oxygen generator + lever, terraformer crew + lever — flip a lever to start " - + "those two), 4 live pipe scenarios (energy/fluid/gas/items), all 4 suit variants, " - + "a loaded Star Guide pedestal, all 4 rocket tiers on their required pads (3x3, " - + "3x3, walled ring, Heavy Launch Complex), 8 creatures (frozen for clean shots), " - + "a meteor crash site (crater + loot core + hovering meteor), and the solar arrays " - + "(T1/T2/T3 single units + a seam-joined field per tier + a cabled hookup showing " - + "the power connector)."), false); - return Command.SINGLE_SUCCESS; - } - - /** - * Wipe the gallery built at the player's feet so a rebuild (or the screenshot harness) doesn't - * stack duplicates. Clears the whole footprint to air from the floor layer ({@code origin.y}) up, - * leaving the natural ground at {@code origin.y - 1} intact, and removes every non-player entity - * in the box (rockets, suit stands, creatures). Run it standing where you ran {@code gallery}. - */ - private static int clearGallery(CommandSourceStack source) { - ServerPlayer player = source.getPlayer(); - if (player == null) { - source.sendFailure(Component.literal("Run this as a player.")); - return 0; - } - if (!player.getAbilities().instabuild) { - source.sendFailure(Component.literal("The Nerospace gallery is creative-only.")); - return 0; - } - ServerLevel level = player.level(); - BlockPos origin = player.blockPosition(); - int ox = origin.getX(); - int oy = origin.getY(); - int oz = origin.getZ(); - - // Footprint of the ROTUNDA buildGallery() (clusters sit ~48 out on N/S/E/W/SE/NW bearings) - // plus margin, so the clear covers every cluster — else reruns stack creatures/rockets/stands. - // The floor sits at oy, so clearing oy..topY to air restores the original flat ground at oy-1. - int minX = ox - 56; - int maxX = ox + 62; - int minZ = oz - 58; - int maxZ = oz + 56; - int topY = oy + 16; - - BlockState air = Blocks.AIR.defaultBlockState(); - BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); - int cleared = 0; - for (int x = minX; x <= maxX; x++) { - for (int z = minZ; z <= maxZ; z++) { - for (int y = oy; y <= topY; y++) { - cursor.set(x, y, z); - if (!level.getBlockState(cursor).isAir()) { - level.setBlock(cursor, air, 2); // flag 2 = notify clients, skip neighbour cascade - cleared++; - } - } - } - } - - // Remove the spawned entities (rockets, armour stands, creatures) — everything but players. - AABB box = new AABB(minX, oy - 1, minZ, maxX + 1, topY + 4, maxZ + 1); - int removed = 0; - for (Entity entity : level.getEntitiesOfClass(Entity.class, box, e -> !(e instanceof Player))) { - entity.discard(); - removed++; - } - - int clearedBlocks = cleared; - int removedEntities = removed; - source.sendSuccess(() -> Component.literal("Cleared the Nerospace gallery: " + clearedBlocks - + " blocks → air, " + removedEntities + " entities removed."), false); - return Command.SINGLE_SUCCESS; - } - - /** - * Solar showcase (SW). Front row: one of each tier as a single unit — a 1×1 T1, a 2×2 T2 (one big - * panel) and a 3×3 T3 (one big panel). Behind it: several units of each tier side by side — nine T1 - * panels (a seam-joined 3×3 field), four T2 units and two T3 units — so multiple arrays tiling is - * visible. A Creative Battery → Universal Pipe → T1 panel line shows the dynamic power connector (the - * panel grows a stub toward the cable so the hookup butts up with no gap). Built at {@code (baseX, - * baseZ)}, extending east (+X) and south (+Z); panels sit on the floor with the tracking deck above. - */ - private static void buildSolarArrays(ServerLevel level, BlockState floor, int baseX, int baseZ, int fy) { - int sy = fy + 1; - for (int dx = -2; dx <= 20; dx++) { - for (int dz = -2; dz <= 10; dz++) { - level.setBlockAndUpdate(new BlockPos(baseX + dx, fy, baseZ + dz), floor); - } - } - - // Front row: one of each tier (multiblock anchors auto-fill their N×N footprint via onPlace). - placeSolar(level, ModBlocks.SOLAR_PANEL_T1.get(), baseX, sy, baseZ); - placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 2, sy, baseZ); // fills +2..3 - placeSolar(level, ModBlocks.SOLAR_PANEL_T3.get(), baseX + 5, sy, baseZ); // fills +5..7 - - // Cable hookup: Creative Battery → Universal Pipe → T1 panel (lights the panel's west connector). - level.setBlockAndUpdate(new BlockPos(baseX + 10, sy, baseZ), - ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(baseX + 11, sy, baseZ), - ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - placeSolar(level, ModBlocks.SOLAR_PANEL_T1.get(), baseX + 12, sy, baseZ); - - // Multi-unit seam-joined fields, set back (+Z) so footprints don't touch the front row. - // T1: a 3x3 field of nine single panels → one continuous tracking surface. - for (int dx = 0; dx <= 2; dx++) { - for (int dz = 4; dz <= 6; dz++) { - placeSolar(level, ModBlocks.SOLAR_PANEL_T1.get(), baseX + dx, sy, baseZ + dz); - } - } - // T2: four 2x2 units → a 4x4 field. - placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 5, sy, baseZ + 4); - placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 7, sy, baseZ + 4); - placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 5, sy, baseZ + 6); - placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 7, sy, baseZ + 6); - // T3: two 3x3 units → a 6x3 field. - placeSolar(level, ModBlocks.SOLAR_PANEL_T3.get(), baseX + 11, sy, baseZ + 4); // fills +11..13 - placeSolar(level, ModBlocks.SOLAR_PANEL_T3.get(), baseX + 14, sy, baseZ + 4); // fills +14..16 - } - - /** Place a solar panel anchor; multiblock tiers auto-expand their footprint in {@code onPlace}. */ - private static void placeSolar(ServerLevel level, Block block, int x, int y, int z) { - level.setBlockAndUpdate(new BlockPos(x, y, z), block.defaultBlockState()); - } - - /** - * Build one staged, fully-powered gallery quarry: a {@code (side+1) x (side+1)} region with its - * frame ring, a west-side creative battery + pipe feed, an interior-only pre-carved pit - * {@code pitDepth} deep (the columns under the frame stay, matching real mining), dropped straight - * into MINING so the gantry + drill animate immediately. - */ - private static void buildGalleryQuarry(ServerLevel level, BlockState floor, int qx, int qz, int fy, - int side, int pitDepth) { - int refY = fy + 1; - int mid = side / 2; - for (int dx = -5; dx <= side; dx++) { // ground: power pad (west) + under the region - for (int dz = -1; dz <= side; dz++) { - level.setBlockAndUpdate(new BlockPos(qx + dx, fy, qz + dz), floor); - } - } - QuarryRegion region = new QuarryRegion(qx, qz, qx + side, qz + side, refY); - BlockState frameBlock = ModBlocks.QUARRY_FRAME.get().defaultBlockState(); - for (BlockPos fp : region.framePositions()) { - level.setBlockAndUpdate(fp, frameBlock); - } - // Pre-carve a starter pit — INTERIOR only, leaving the columns under the frame intact. - for (int x = qx + 1; x <= qx + side - 1; x++) { - for (int z = qz + 1; z <= qz + side - 1; z++) { - for (int y = refY - 1; y >= refY - pitDepth; y--) { - level.setBlockAndUpdate(new BlockPos(x, y, z), Blocks.AIR.defaultBlockState()); - } - } - } - BlockPos quarryPos = new BlockPos(qx - 2, refY, qz + mid); - level.setBlockAndUpdate(new BlockPos(qx - 4, refY, qz + mid), - ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); - level.setBlockAndUpdate(new BlockPos(qx - 3, refY, qz + mid), - ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); - level.setBlockAndUpdate(quarryPos, ModBlocks.QUARRY_CONTROLLER.get().defaultBlockState()); - setAllModes(level, new BlockPos(qx - 3, refY, qz + mid), Direction.WEST, PipeIoMode.IN); - setAllModes(level, new BlockPos(qx - 3, refY, qz + mid), Direction.EAST, PipeIoMode.OUT); - if (level.getBlockEntity(quarryPos) instanceof QuarryControllerBlockEntity quarry) { - quarry.setItem(QuarryControllerBlockEntity.FRAME_SLOT, - new ItemStack(ModItems.FRAME_CASING.get(), 64)); - quarry.stageDisplay(region, refY - pitDepth); - } - } - - /** - * A showcase meteor crash site: a 7x7 floor pad, a 5x5 {@code meteor_rock} crater floor with a - * raised rim, a loot-pre-rolled {@code meteor_core} nestled in the centre, and a frozen - * {@link FallingMeteorEntity} hovering above (spins + trails, but never falls — gallery only). - */ - private static void buildMeteorSite(ServerLevel level, BlockState floor, int cx, int cz, int fy) { - for (int dx = -3; dx <= 3; dx++) { - for (int dz = -3; dz <= 3; dz++) { - level.setBlockAndUpdate(new BlockPos(cx + dx, fy, cz + dz), floor); - } - } - BlockState rock = ModBlocks.METEOR_ROCK.get().defaultBlockState(); - for (int dx = -2; dx <= 2; dx++) { - for (int dz = -2; dz <= 2; dz++) { - level.setBlockAndUpdate(new BlockPos(cx + dx, fy + 1, cz + dz), rock); // crater floor - if (Math.abs(dx) == 2 || Math.abs(dz) == 2) { - level.setBlockAndUpdate(new BlockPos(cx + dx, fy + 2, cz + dz), rock); // raised rim - } - } - } - BlockPos corePos = new BlockPos(cx, fy + 2, cz); - level.setBlockAndUpdate(corePos, ModBlocks.METEOR_CORE.get().defaultBlockState()); - if (level.getBlockEntity(corePos) instanceof MeteorCoreBlockEntity core) { - core.generateLoot(level.getRandom().nextLong()); - } - FallingMeteorEntity.spawnFrozen(level, cx + 0.5D, fy + 11, cz + 0.5D); - } - - /** A full {@code size x size} square of launch pads with min-corner {@code corner}. */ - private static void fillPad(ServerLevel level, BlockPos corner, int size, BlockState pad) { - for (int dx = 0; dx < size; dx++) { - for (int dz = 0; dz < size; dz++) { - level.setBlockAndUpdate(corner.offset(dx, 0, dz), pad); - } - } - } - - /** A rocket standing on the pad surface of the pad block at {@code (x, y, z)}. */ - private static void spawnRocket(ServerLevel level, int x, int y, int z, RocketTier tier) { - level.addFreshEntity(new RocketEntity(level, - x + 0.5D, y + RocketLaunchPadBlock.SURFACE_HEIGHT, z + 0.5D, tier)); - } - - /** An invulnerable, named armor stand wearing the given four-piece suit. */ - private static void spawnSuitStand(ServerLevel level, BlockPos pos, Component name, float yaw, - Item helmet, Item chestplate, Item leggings, Item boots) { - ArmorStand stand = EntityType.ARMOR_STAND.spawn(level, pos, EntitySpawnReason.COMMAND); - if (stand == null) { - return; - } - stand.setItemSlot(EquipmentSlot.HEAD, new ItemStack(helmet)); - stand.setItemSlot(EquipmentSlot.CHEST, new ItemStack(chestplate)); - stand.setItemSlot(EquipmentSlot.LEGS, new ItemStack(leggings)); - stand.setItemSlot(EquipmentSlot.FEET, new ItemStack(boots)); - stand.setCustomName(name); - stand.setCustomNameVisible(true); - stand.setInvulnerable(true); - stand.setYRot(yaw); // uniform facing so the row reads as a clean line, angled a few degrees off straight-on - stand.setYBodyRot(yaw); - stand.setYHeadRot(yaw); - } - - /** Set one face of the pipe at {@code pos} to {@code mode} for ALL four resource layers. */ - private static void setAllModes(ServerLevel level, BlockPos pos, Direction face, PipeIoMode mode) { - if (level.getBlockEntity(pos) instanceof UniversalPipeBlockEntity pipe) { - for (PipeResourceType type : PipeResourceType.VALUES) { - pipe.setMode(face, type, mode); - } - } - } - - private static void spawnShowcase(ServerLevel level, EntityType type, BlockPos pos, boolean noAi) { - Mob mob = type.spawn(level, pos, EntitySpawnReason.COMMAND); - if (mob != null) { - mob.setNoAi(noAi); - mob.setPersistenceRequired(); - } - } -} - \ No newline at end of file +package za.co.neroland.nerospace.command; + +import java.util.ArrayList; +import java.util.List; + +import com.mojang.brigadier.Command; + +import net.minecraft.commands.CommandSourceStack; +import net.minecraft.commands.Commands; +import net.minecraft.core.BlockPos; +import net.minecraft.core.Direction; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerLevel; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.entity.EntitySpawnReason; +import net.minecraft.world.entity.EntityType; +import net.minecraft.world.entity.EquipmentSlot; +import net.minecraft.world.entity.Mob; +import net.minecraft.world.entity.decoration.ArmorStand; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.item.Item; +import net.minecraft.world.item.ItemStack; +import net.minecraft.world.item.Items; +import net.minecraft.world.level.block.Block; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.level.block.LeverBlock; +import net.minecraft.world.level.block.state.BlockState; +import net.minecraft.world.level.block.state.properties.AttachFace; +import net.minecraft.world.level.material.Fluids; +import net.minecraft.world.phys.AABB; +import net.neoforged.bus.api.SubscribeEvent; +import net.neoforged.fml.common.EventBusSubscriber; +import net.neoforged.neoforge.event.RegisterCommandsEvent; +import net.neoforged.neoforge.transfer.fluid.FluidResource; +import net.neoforged.neoforge.transfer.item.ItemResource; + +import za.co.neroland.nerospace.Nerospace; +import za.co.neroland.nerospace.meteor.FallingMeteorEntity; +import za.co.neroland.nerospace.meteor.MeteorCoreBlockEntity; +import za.co.neroland.nerospace.machine.CombustionGeneratorBlockEntity; +import za.co.neroland.nerospace.machine.FuelRefineryBlockEntity; +import za.co.neroland.nerospace.machine.HydrationModuleBlockEntity; +import za.co.neroland.nerospace.machine.NerosiumGrinderBlockEntity; +import za.co.neroland.nerospace.machine.quarry.QuarryControllerBlockEntity; +import za.co.neroland.nerospace.machine.quarry.QuarryRegion; +import za.co.neroland.nerospace.pipe.PipeIoMode; +import za.co.neroland.nerospace.pipe.PipeResourceType; +import za.co.neroland.nerospace.pipe.UniversalPipeBlockEntity; +import za.co.neroland.nerospace.registry.ModBlocks; +import za.co.neroland.nerospace.registry.ModEntities; +import za.co.neroland.nerospace.registry.ModItems; +import za.co.neroland.nerospace.rocket.RocketEntity; +import za.co.neroland.nerospace.rocket.RocketLaunchPadBlock; +import za.co.neroland.nerospace.rocket.RocketTier; +import za.co.neroland.nerospace.storage.CreativeFluidTankBlockEntity; +import za.co.neroland.nerospace.storage.CreativeItemStoreBlockEntity; + +/** + * Creative-only debug commands (cheats / op level 2). {@code /nerospace gallery} builds a showcase + * platform near the player: every Nerospace block floating two blocks above the floor (so all faces + * are visible) on a ~3-block grid, every machine RUNNING the way it is meant to be wired in + * survival (fuelled, powered and fed — except the Terraformer and Oxygen Generator, which sit + * behind an off lever so the world-changing machines only run when deliberately switched on), all + * four rocket tiers standing on their required pad formations, every suit variant on a stand, and + * each creature spawned twice — once with AI and once frozen (NoAI) — for inspection. + * {@code /nerospace gallery clear} wipes that footprint (blocks + spawned entities) so a rebuild — + * or the screenshot harness — doesn't stack duplicates. + */ +@EventBusSubscriber(modid = Nerospace.MODID) +public final class NerospaceCommands { + + private static final int SPACING = 3; // blocks between display cells + private static final int FLOAT_ABOVE = 3; // display sits this many blocks above the floor (2 air gap) + private static final int SUIT_SPACING = 3; // blocks between suit stands (roomier than the old 2) + private static final float SUIT_YAW = -10.0f; // every suit stand faces one way, angled a few degrees left + + private NerospaceCommands() { + } + + @SubscribeEvent + public static void onRegister(RegisterCommandsEvent event) { + // Player-only; the executor further restricts to creative. (Commands themselves require the + // world to have cheats/commands enabled, so this is effectively creative + commands gated.) + event.getDispatcher().register( + Commands.literal("nerospace") + .requires(src -> src.getPlayer() != null) + .then(Commands.literal("gallery") + .executes(ctx -> buildGallery(ctx.getSource())) + .then(Commands.literal("clear") + .executes(ctx -> clearGallery(ctx.getSource()))))); + } + + private static int buildGallery(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + source.sendFailure(Component.literal("Run this as a player.")); + return 0; + } + if (!player.getAbilities().instabuild) { + source.sendFailure(Component.literal("The Nerospace gallery is creative-only.")); + return 0; + } + ServerLevel level = player.level(); + BlockPos origin = player.blockPosition(); + + List blocks = new ArrayList<>(); + for (var holder : ModBlocks.BLOCKS.getEntries()) { + Block block = holder.value(); + if (block != ModBlocks.ROCKET_FUEL_BLOCK.get()) { // skip the fluid block (renders oddly free-standing) + blocks.add(block); + } + } + + int cols = (int) Math.ceil(Math.sqrt(Math.max(1, blocks.size()))); + int rows = (int) Math.ceil(blocks.size() / (double) cols); + // ROTUNDA: each cluster sits on a ring ~48 blocks out on its own compass bearing, so a camera + // near the centre shoots each one outward against empty ground with no other display in frame. + // The /nsgallery capture harness mirrors these bearings. Bases are placed so the body centres + // on the ring. Tune distances together with the harness if reframing. + int ox = origin.getX() + 38; // block grid → EAST + int oz = origin.getZ() - 9; + int fy = origin.getY(); + + BlockState floor = ModBlocks.STATION_FLOOR.get().defaultBlockState(); + + // Floor slab under the whole grid (with a 1-block margin). + for (int gx = -1; gx <= cols * SPACING; gx++) { + for (int gz = -1; gz <= rows * SPACING; gz++) { + level.setBlockAndUpdate(new BlockPos(ox + gx, fy, oz + gz), floor); + } + } + // Floating block displays (2 air blocks below each → visible from all angles). + for (int i = 0; i < blocks.size(); i++) { + int col = i % cols; + int row = i / cols; + level.setBlockAndUpdate( + new BlockPos(ox + col * SPACING, fy + FLOAT_ABOVE, oz + row * SPACING), + blocks.get(i).defaultBlockState()); + } + + // MACHINES, ALL RUNNING (one strip, four wired clusters — each exactly the survival hookup): + // A. Combustion Generator (coal) → pipe → Grinder (raw nerosium), Passive Generator feeding in. + // B. Creative Battery → pipe → Fuel Refinery (coal + blaze powder) → pipe → Fuel Tank. + // C. Creative Battery → pipe → Oxygen Generator — parked behind an OFF lever. + // D. Creative Battery → pipe → Terraformer + touching Hydration Module (glacite) and + // Terraform Monitor — parked behind an OFF lever (it WILL reshape the area when on). + int sx = origin.getX() - 13; // machine strip → SOUTH + int sz = origin.getZ() + 48; + for (int dx = -1; dx <= 27; dx++) { + for (int dz = -2; dz <= 2; dz++) { + level.setBlockAndUpdate(new BlockPos(sx + dx, fy, sz + dz), floor); + } + } + BlockState lever = Blocks.LEVER.defaultBlockState() + .setValue(LeverBlock.FACE, AttachFace.FLOOR) + .setValue(LeverBlock.FACING, Direction.EAST); + + // A: the classic first power line. + level.setBlockAndUpdate(new BlockPos(sx, fy + 1, sz), ModBlocks.COMBUSTION_GENERATOR.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(sx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(sx + 2, fy + 1, sz), ModBlocks.NEROSIUM_GRINDER.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(sx + 1, fy + 1, sz + 1), ModBlocks.PASSIVE_GENERATOR.get().defaultBlockState()); + if (level.getBlockEntity(new BlockPos(sx, fy + 1, sz)) instanceof CombustionGeneratorBlockEntity gen) { + gen.setItem(CombustionGeneratorBlockEntity.FUEL_SLOT, new ItemStack(Items.COAL, 64)); + } + if (level.getBlockEntity(new BlockPos(sx + 2, fy + 1, sz)) instanceof NerosiumGrinderBlockEntity grinder) { + grinder.setItem(NerosiumGrinderBlockEntity.INPUT_SLOT, new ItemStack(ModItems.RAW_NEROSIUM.get(), 64)); + } + + // B: refining line — power in from the endless battery, fuel out into a Fuel Tank. + int bx = sx + 6; + level.setBlockAndUpdate(new BlockPos(bx, fy + 1, sz), ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(bx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(bx + 2, fy + 1, sz), ModBlocks.FUEL_REFINERY.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(bx + 3, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(bx + 4, fy + 1, sz), ModBlocks.FUEL_TANK.get().defaultBlockState()); + setAllModes(level, new BlockPos(bx + 1, fy + 1, sz), Direction.WEST, PipeIoMode.IN); + setAllModes(level, new BlockPos(bx + 1, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); + setAllModes(level, new BlockPos(bx + 3, fy + 1, sz), Direction.WEST, PipeIoMode.IN); + setAllModes(level, new BlockPos(bx + 3, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); + if (level.getBlockEntity(new BlockPos(bx + 2, fy + 1, sz)) instanceof FuelRefineryBlockEntity refinery) { + refinery.setItem(FuelRefineryBlockEntity.CARBON_SLOT, new ItemStack(Items.COAL, 64)); + refinery.setItem(FuelRefineryBlockEntity.CATALYST_SLOT, new ItemStack(Items.BLAZE_POWDER, 64)); + } + + // C: oxygen generator behind its lever (off until flipped — then the bubble forms). + int cx = sx + 13; + level.setBlockAndUpdate(new BlockPos(cx, fy + 1, sz), ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(cx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(cx + 2, fy + 1, sz), ModBlocks.OXYGEN_GENERATOR.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(cx + 3, fy + 1, sz), lever); + setAllModes(level, new BlockPos(cx + 1, fy + 1, sz), Direction.WEST, PipeIoMode.IN); + setAllModes(level, new BlockPos(cx + 1, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); + + // D: terraformer cluster behind its lever, with the full deeper-terraform support crew. + int tx = sx + 19; + level.setBlockAndUpdate(new BlockPos(tx, fy + 1, sz), ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(tx + 1, fy + 1, sz), ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(tx + 2, fy + 1, sz), ModBlocks.TERRAFORMER.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(tx + 2, fy + 1, sz + 1), ModBlocks.HYDRATION_MODULE.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(tx + 2, fy + 1, sz - 1), ModBlocks.TERRAFORM_MONITOR.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(tx + 3, fy + 1, sz), lever); + setAllModes(level, new BlockPos(tx + 1, fy + 1, sz), Direction.WEST, PipeIoMode.IN); + setAllModes(level, new BlockPos(tx + 1, fy + 1, sz), Direction.EAST, PipeIoMode.OUT); + if (level.getBlockEntity(new BlockPos(tx + 2, fy + 1, sz + 1)) instanceof HydrationModuleBlockEntity module) { + module.setItem(HydrationModuleBlockEntity.INPUT_SLOT, new ItemStack(ModItems.GLACITE.get(), 64)); + } + + // FOUR LIVE PIPE SCENARIOS: creative source → 3 pipes → sink, one row per resource layer. + // The source-touching face is set IN (pull-only — otherwise the pipe would void its buffer + // back into the endless source) and the sink-touching face OUT, mirroring real Configurator use. + int px = origin.getX() - 50; // pipe scenarios → WEST (rows run north-south → broadside from the centre) + int pz = origin.getZ() + 5; + Block[][] scenarioRows = { + {ModBlocks.CREATIVE_BATTERY.get(), ModBlocks.BATTERY.get()}, + {ModBlocks.CREATIVE_FLUID_TANK.get(), ModBlocks.FLUID_TANK.get()}, + {ModBlocks.CREATIVE_GAS_TANK.get(), ModBlocks.GAS_TANK.get()}, + {ModBlocks.CREATIVE_ITEM_STORE.get(), ModBlocks.ITEM_STORE.get()}, + }; + for (int row = 0; row < scenarioRows.length; row++) { + int rz = pz - row * 3; + for (int dx = -1; dx <= 5; dx++) { + for (int dz = -1; dz <= 1; dz++) { + level.setBlockAndUpdate(new BlockPos(px + dx, fy, rz + dz), floor); + } + } + level.setBlockAndUpdate(new BlockPos(px, fy + 1, rz), scenarioRows[row][0].defaultBlockState()); + for (int dx = 1; dx <= 3; dx++) { + level.setBlockAndUpdate(new BlockPos(px + dx, fy + 1, rz), + ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + } + level.setBlockAndUpdate(new BlockPos(px + 4, fy + 1, rz), scenarioRows[row][1].defaultBlockState()); + setAllModes(level, new BlockPos(px + 1, fy + 1, rz), Direction.WEST, PipeIoMode.IN); + setAllModes(level, new BlockPos(px + 3, fy + 1, rz), Direction.EAST, PipeIoMode.OUT); + } + // Pre-configure the endless sources so the rows run on arrival. + if (level.getBlockEntity(new BlockPos(px, fy + 1, pz - 3)) instanceof CreativeFluidTankBlockEntity tank) { + tank.setSource(FluidResource.of(Fluids.WATER)); + } + if (level.getBlockEntity(new BlockPos(px, fy + 1, pz - 9)) instanceof CreativeItemStoreBlockEntity store) { + store.setSource(ItemResource.of(ModItems.NEROSIUM_INGOT.get())); + } + + // Suit displays (every variant) + a LOADED Star Guide pedestal (book installed → hologram runs). + int ax = origin.getX() - 40; // suits + Star Guide → NORTH-WEST (well clear of the pipes spoke) + int az = origin.getZ() - 34; + int suit0 = 0; + int suit1 = SUIT_SPACING; + int suit2 = SUIT_SPACING * 2; + int suit3 = SUIT_SPACING * 3; + int guideX = SUIT_SPACING * 4; + for (int dx = -1; dx <= guideX + 1; dx++) { + for (int dz = -1; dz <= 1; dz++) { + level.setBlockAndUpdate(new BlockPos(ax + dx, fy, az + dz), floor); + } + } + spawnSuitStand(level, new BlockPos(ax + suit0, fy + 1, az), Component.literal("Oxygen Suit"), SUIT_YAW, + ModItems.OXYGEN_SUIT_HELMET.get(), ModItems.OXYGEN_SUIT_CHESTPLATE.get(), + ModItems.OXYGEN_SUIT_LEGGINGS.get(), ModItems.OXYGEN_SUIT_BOOTS.get()); + spawnSuitStand(level, new BlockPos(ax + suit1, fy + 1, az), Component.literal("Tier 2 Oxygen Suit"), SUIT_YAW, + ModItems.OXYGEN_SUIT_T2_HELMET.get(), ModItems.OXYGEN_SUIT_T2_CHESTPLATE.get(), + ModItems.OXYGEN_SUIT_T2_LEGGINGS.get(), ModItems.OXYGEN_SUIT_T2_BOOTS.get()); + spawnSuitStand(level, new BlockPos(ax + suit2, fy + 1, az), Component.literal("Thermal Suit"), SUIT_YAW, + ModItems.OXYGEN_SUIT_HEAT_HELMET.get(), ModItems.OXYGEN_SUIT_HEAT_CHESTPLATE.get(), + ModItems.OXYGEN_SUIT_HEAT_LEGGINGS.get(), ModItems.OXYGEN_SUIT_HEAT_BOOTS.get()); + spawnSuitStand(level, new BlockPos(ax + suit3, fy + 1, az), Component.literal("Cryo Suit"), SUIT_YAW, + ModItems.OXYGEN_SUIT_COLD_HELMET.get(), ModItems.OXYGEN_SUIT_COLD_CHESTPLATE.get(), + ModItems.OXYGEN_SUIT_COLD_LEGGINGS.get(), ModItems.OXYGEN_SUIT_COLD_BOOTS.get()); + BlockPos guidePos = new BlockPos(ax + guideX, fy + 1, az); + level.setBlockAndUpdate(guidePos, ModBlocks.STAR_GUIDE.get().defaultBlockState()); + if (level.getBlockEntity(guidePos) + instanceof za.co.neroland.nerospace.progression.StarGuideBlockEntity guide) { + guide.installBook(new ItemStack(ModItems.STAR_GUIDE_BOOK.get())); + } + + // ROCKET ROW: every tier on the pad formation it actually requires (RocketItem gating): + // T1 + T2: a full 3x3 pad. T3: a 3x3 pad ringed with Station Wall. + // T4: the Heavy Launch Complex — full 5x5 pad + a Launch Gantry on its border ring. + int rx = origin.getX() - 14; // rocket row → NORTH (the hero spoke) + int rz0 = origin.getZ() - 49; + for (int dx = -2; dx <= 31; dx++) { + for (int dz = -3; dz <= 5; dz++) { + level.setBlockAndUpdate(new BlockPos(rx + dx, fy, rz0 + dz), floor); + } + } + BlockState pad = ModBlocks.ROCKET_LAUNCH_PAD.get().defaultBlockState(); + // T1 (3x3 + the classic pad-side Fuel Tank). + fillPad(level, new BlockPos(rx, fy + 1, rz0), 3, pad); + level.setBlockAndUpdate(new BlockPos(rx + 3, fy + 1, rz0 + 1), ModBlocks.FUEL_TANK.get().defaultBlockState()); + spawnRocket(level, rx + 1, fy + 1, rz0 + 1, RocketTier.TIER_1); + // T2 (3x3). + fillPad(level, new BlockPos(rx + 8, fy + 1, rz0), 3, pad); + spawnRocket(level, rx + 9, fy + 1, rz0 + 1, RocketTier.TIER_2); + // T3 (3x3 ringed with Station Wall). + fillPad(level, new BlockPos(rx + 16, fy + 1, rz0), 3, pad); + BlockState wall = ModBlocks.STATION_WALL.get().defaultBlockState(); + for (int dx = -1; dx <= 3; dx++) { + for (int dz = -1; dz <= 3; dz++) { + if (dx == -1 || dx == 3 || dz == -1 || dz == 3) { + level.setBlockAndUpdate(new BlockPos(rx + 16 + dx, fy + 1, rz0 + dz), wall); + } + } + } + spawnRocket(level, rx + 17, fy + 1, rz0 + 1, RocketTier.TIER_3); + // T4 (Heavy Launch Complex: 5x5 + gantry + fuel tank). + fillPad(level, new BlockPos(rx + 24, fy + 1, rz0 - 1), 5, pad); + level.setBlockAndUpdate(new BlockPos(rx + 23, fy + 1, rz0 + 1), + ModBlocks.LAUNCH_GANTRY.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(rx + 29, fy + 1, rz0 + 1), + ModBlocks.FUEL_TANK.get().defaultBlockState()); + spawnRocket(level, rx + 26, fy + 1, rz0 + 1, RocketTier.TIER_4); + + // Creatures: each spawned twice — live (AI) and frozen (NoAI) — on a small floor strip. + int mx = origin.getX() + 18; // creatures → SOUTH-EAST + int mz = origin.getZ() + 33; + for (int dx = -1; dx <= 8 * 4; dx++) { + for (int dz = -1; dz <= 3; dz++) { + level.setBlockAndUpdate(new BlockPos(mx + dx, fy, mz + dz), floor); + } + } + List> creatures = List.of( + ModEntities.XERTZ_STALKER.get(), ModEntities.QUARTZ_CRAWLER.get(), + ModEntities.GREENLING.get(), ModEntities.CINDER_STALKER.get(), + ModEntities.FROST_STRIDER.get(), + // Terraform livestock (DEEPER_TERRAFORM_DESIGN.md §5). + ModEntities.MEADOW_LOPER.get(), ModEntities.EMBER_STRUTTER.get(), + ModEntities.WOOLLY_DRIFT.get()); + // One frozen (NoAI) row only — AI mobs wander, which breaks reproducible screenshots. + for (int i = 0; i < creatures.size(); i++) { + spawnShowcase(level, creatures.get(i), new BlockPos(mx + i * 4, fy + 1, mz + 1), true); + } + + // METEOR SITE (meteor-events-design.md): a small crater of meteor_rock around a loot-bearing + // meteor_core, with a frozen meteor hovering above it (spins + trails for the shot). SW spoke. + buildMeteorSite(level, floor, origin.getX() - 28, origin.getZ() + 30, fy); + + // QUARRY (MINER_DESIGN): two NE displays. + // 1. Landmark-only — three landmarks in an L (shows the projected marker lasers). + // 2. Fully operating — a powered quarry mid-dig: frame ring, drill head, a real pit forming. + BlockState landmark = ModBlocks.QUARRY_LANDMARK.get().defaultBlockState(); + int lx = origin.getX() + 28; // landmark-only display (NE, nearer the centre) + int lz = origin.getZ() - 40; + for (int dx = -1; dx <= 7; dx++) { + for (int dz = -1; dz <= 7; dz++) { + level.setBlockAndUpdate(new BlockPos(lx + dx, fy, lz + dz), floor); + } + } + level.setBlockAndUpdate(new BlockPos(lx, fy + 1, lz), landmark); + level.setBlockAndUpdate(new BlockPos(lx + 6, fy + 1, lz), landmark); + level.setBlockAndUpdate(new BlockPos(lx, fy + 1, lz + 6), landmark); + + // Operating quarries: staged straight into a deep mid-dig so the frame, gantry, drill head and + // interior-only excavation all read at a glance. Two sizes — a standard 9x9 and a big 17x17 to + // stress-test rendering + mining over a large area. + buildGalleryQuarry(level, floor, origin.getX() + 42, origin.getZ() - 40, fy, 8, 8); + buildGalleryQuarry(level, floor, origin.getX() + 64, origin.getZ() - 56, fy, 16, 12); + + // SOLAR ARRAYS (SOLAR_PANEL_DESIGN, SW bearing): one unit per tier, then a multi-unit seam-joined + // field per tier (so the per-cell trackers reading as one surface is visible), plus a + // battery → universal cable → panel hookup that lights the panel's power connector. + buildSolarArrays(level, floor, origin.getX() - 50, origin.getZ() + 36, fy); + + source.sendSuccess(() -> Component.literal("Built the Nerospace gallery: " + + blocks.size() + " blocks, 4 RUNNING machine clusters (grinder line, fuel refinery " + + "line, oxygen generator + lever, terraformer crew + lever — flip a lever to start " + + "those two), 4 live pipe scenarios (energy/fluid/gas/items), all 4 suit variants, " + + "a loaded Star Guide pedestal, all 4 rocket tiers on their required pads (3x3, " + + "3x3, walled ring, Heavy Launch Complex), 8 creatures (frozen for clean shots), " + + "a meteor crash site (crater + loot core + hovering meteor), and the solar arrays " + + "(T1/T2/T3 single units + a seam-joined field per tier + a cabled hookup showing " + + "the power connector)."), false); + return Command.SINGLE_SUCCESS; + } + + /** + * Wipe the gallery built at the player's feet so a rebuild (or the screenshot harness) doesn't + * stack duplicates. Clears the whole footprint to air from the floor layer ({@code origin.y}) up, + * leaving the natural ground at {@code origin.y - 1} intact, and removes every non-player entity + * in the box (rockets, suit stands, creatures). Run it standing where you ran {@code gallery}. + */ + private static int clearGallery(CommandSourceStack source) { + ServerPlayer player = source.getPlayer(); + if (player == null) { + source.sendFailure(Component.literal("Run this as a player.")); + return 0; + } + if (!player.getAbilities().instabuild) { + source.sendFailure(Component.literal("The Nerospace gallery is creative-only.")); + return 0; + } + ServerLevel level = player.level(); + BlockPos origin = player.blockPosition(); + int ox = origin.getX(); + int oy = origin.getY(); + int oz = origin.getZ(); + + // Footprint of the ROTUNDA buildGallery() (clusters sit ~48 out on N/S/E/W/SE/NW bearings) + // plus margin, so the clear covers every cluster — else reruns stack creatures/rockets/stands. + // The floor sits at oy, so clearing oy..topY to air restores the original flat ground at oy-1. + int minX = ox - 56; + int maxX = ox + 62; + int minZ = oz - 58; + int maxZ = oz + 56; + int topY = oy + 16; + + BlockState air = Blocks.AIR.defaultBlockState(); + BlockPos.MutableBlockPos cursor = new BlockPos.MutableBlockPos(); + int cleared = 0; + for (int x = minX; x <= maxX; x++) { + for (int z = minZ; z <= maxZ; z++) { + for (int y = oy; y <= topY; y++) { + cursor.set(x, y, z); + if (!level.getBlockState(cursor).isAir()) { + level.setBlock(cursor, air, 2); // flag 2 = notify clients, skip neighbour cascade + cleared++; + } + } + } + } + + // Remove the spawned entities (rockets, armour stands, creatures) — everything but players. + AABB box = new AABB(minX, oy - 1, minZ, maxX + 1, topY + 4, maxZ + 1); + int removed = 0; + for (Entity entity : level.getEntitiesOfClass(Entity.class, box, e -> !(e instanceof Player))) { + entity.discard(); + removed++; + } + + int clearedBlocks = cleared; + int removedEntities = removed; + source.sendSuccess(() -> Component.literal("Cleared the Nerospace gallery: " + clearedBlocks + + " blocks → air, " + removedEntities + " entities removed."), false); + return Command.SINGLE_SUCCESS; + } + + /** + * Solar showcase (SW). Front row: one of each tier as a single unit — a 1×1 T1, a 2×2 T2 (one big + * panel) and a 3×3 T3 (one big panel). Behind it: several units of each tier side by side — nine T1 + * panels (a seam-joined 3×3 field), four T2 units and two T3 units — so multiple arrays tiling is + * visible. A Creative Battery → Universal Pipe → T1 panel line shows the dynamic power connector (the + * panel grows a stub toward the cable so the hookup butts up with no gap). Built at {@code (baseX, + * baseZ)}, extending east (+X) and south (+Z); panels sit on the floor with the tracking deck above. + */ + private static void buildSolarArrays(ServerLevel level, BlockState floor, int baseX, int baseZ, int fy) { + int sy = fy + 1; + for (int dx = -2; dx <= 20; dx++) { + for (int dz = -2; dz <= 10; dz++) { + level.setBlockAndUpdate(new BlockPos(baseX + dx, fy, baseZ + dz), floor); + } + } + + // Front row: one of each tier (multiblock anchors auto-fill their N×N footprint via onPlace). + placeSolar(level, ModBlocks.SOLAR_PANEL_T1.get(), baseX, sy, baseZ); + placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 2, sy, baseZ); // fills +2..3 + placeSolar(level, ModBlocks.SOLAR_PANEL_T3.get(), baseX + 5, sy, baseZ); // fills +5..7 + + // Cable hookup: Creative Battery → Universal Pipe → T1 panel (lights the panel's west connector). + level.setBlockAndUpdate(new BlockPos(baseX + 10, sy, baseZ), + ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(baseX + 11, sy, baseZ), + ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + placeSolar(level, ModBlocks.SOLAR_PANEL_T1.get(), baseX + 12, sy, baseZ); + + // Multi-unit seam-joined fields, set back (+Z) so footprints don't touch the front row. + // T1: a 3x3 field of nine single panels → one continuous tracking surface. + for (int dx = 0; dx <= 2; dx++) { + for (int dz = 4; dz <= 6; dz++) { + placeSolar(level, ModBlocks.SOLAR_PANEL_T1.get(), baseX + dx, sy, baseZ + dz); + } + } + // T2: four 2x2 units → a 4x4 field. + placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 5, sy, baseZ + 4); + placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 7, sy, baseZ + 4); + placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 5, sy, baseZ + 6); + placeSolar(level, ModBlocks.SOLAR_PANEL_T2.get(), baseX + 7, sy, baseZ + 6); + // T3: two 3x3 units → a 6x3 field. + placeSolar(level, ModBlocks.SOLAR_PANEL_T3.get(), baseX + 11, sy, baseZ + 4); // fills +11..13 + placeSolar(level, ModBlocks.SOLAR_PANEL_T3.get(), baseX + 14, sy, baseZ + 4); // fills +14..16 + } + + /** Place a solar panel anchor; multiblock tiers auto-expand their footprint in {@code onPlace}. */ + private static void placeSolar(ServerLevel level, Block block, int x, int y, int z) { + level.setBlockAndUpdate(new BlockPos(x, y, z), block.defaultBlockState()); + } + + /** + * Build one staged, fully-powered gallery quarry: a {@code (side+1) x (side+1)} region with its + * frame ring, a west-side creative battery + pipe feed, an interior-only pre-carved pit + * {@code pitDepth} deep (the columns under the frame stay, matching real mining), dropped straight + * into MINING so the gantry + drill animate immediately. + */ + private static void buildGalleryQuarry(ServerLevel level, BlockState floor, int qx, int qz, int fy, + int side, int pitDepth) { + int refY = fy + 1; + int mid = side / 2; + for (int dx = -5; dx <= side; dx++) { // ground: power pad (west) + under the region + for (int dz = -1; dz <= side; dz++) { + level.setBlockAndUpdate(new BlockPos(qx + dx, fy, qz + dz), floor); + } + } + QuarryRegion region = new QuarryRegion(qx, qz, qx + side, qz + side, refY); + BlockState frameBlock = ModBlocks.QUARRY_FRAME.get().defaultBlockState(); + for (BlockPos fp : region.framePositions()) { + level.setBlockAndUpdate(fp, frameBlock); + } + // Pre-carve a starter pit — INTERIOR only, leaving the columns under the frame intact. + for (int x = qx + 1; x <= qx + side - 1; x++) { + for (int z = qz + 1; z <= qz + side - 1; z++) { + for (int y = refY - 1; y >= refY - pitDepth; y--) { + level.setBlockAndUpdate(new BlockPos(x, y, z), Blocks.AIR.defaultBlockState()); + } + } + } + BlockPos quarryPos = new BlockPos(qx - 2, refY, qz + mid); + level.setBlockAndUpdate(new BlockPos(qx - 4, refY, qz + mid), + ModBlocks.CREATIVE_BATTERY.get().defaultBlockState()); + level.setBlockAndUpdate(new BlockPos(qx - 3, refY, qz + mid), + ModBlocks.UNIVERSAL_PIPE.get().defaultBlockState()); + level.setBlockAndUpdate(quarryPos, ModBlocks.QUARRY_CONTROLLER.get().defaultBlockState()); + setAllModes(level, new BlockPos(qx - 3, refY, qz + mid), Direction.WEST, PipeIoMode.IN); + setAllModes(level, new BlockPos(qx - 3, refY, qz + mid), Direction.EAST, PipeIoMode.OUT); + if (level.getBlockEntity(quarryPos) instanceof QuarryControllerBlockEntity quarry) { + quarry.setItem(QuarryControllerBlockEntity.FRAME_SLOT, + new ItemStack(ModItems.FRAME_CASING.get(), 64)); + quarry.stageDisplay(region, refY - pitDepth); + } + } + + /** + * A showcase meteor crash site: a 7x7 floor pad, a 5x5 {@code meteor_rock} crater floor with a + * raised rim, a loot-pre-rolled {@code meteor_core} nestled in the centre, and a frozen + * {@link FallingMeteorEntity} hovering above (spins + trails, but never falls — gallery only). + */ + private static void buildMeteorSite(ServerLevel level, BlockState floor, int cx, int cz, int fy) { + for (int dx = -3; dx <= 3; dx++) { + for (int dz = -3; dz <= 3; dz++) { + level.setBlockAndUpdate(new BlockPos(cx + dx, fy, cz + dz), floor); + } + } + BlockState rock = ModBlocks.METEOR_ROCK.get().defaultBlockState(); + for (int dx = -2; dx <= 2; dx++) { + for (int dz = -2; dz <= 2; dz++) { + level.setBlockAndUpdate(new BlockPos(cx + dx, fy + 1, cz + dz), rock); // crater floor + if (Math.abs(dx) == 2 || Math.abs(dz) == 2) { + level.setBlockAndUpdate(new BlockPos(cx + dx, fy + 2, cz + dz), rock); // raised rim + } + } + } + BlockPos corePos = new BlockPos(cx, fy + 2, cz); + level.setBlockAndUpdate(corePos, ModBlocks.METEOR_CORE.get().defaultBlockState()); + if (level.getBlockEntity(corePos) instanceof MeteorCoreBlockEntity core) { + core.generateLoot(level.getRandom().nextLong()); + } + FallingMeteorEntity.spawnFrozen(level, cx + 0.5D, fy + 11, cz + 0.5D); + } + + /** A full {@code size x size} square of launch pads with min-corner {@code corner}. */ + private static void fillPad(ServerLevel level, BlockPos corner, int size, BlockState pad) { + for (int dx = 0; dx < size; dx++) { + for (int dz = 0; dz < size; dz++) { + level.setBlockAndUpdate(corner.offset(dx, 0, dz), pad); + } + } + } + + /** A rocket standing on the pad surface of the pad block at {@code (x, y, z)}. */ + private static void spawnRocket(ServerLevel level, int x, int y, int z, RocketTier tier) { + level.addFreshEntity(new RocketEntity(level, + x + 0.5D, y + RocketLaunchPadBlock.SURFACE_HEIGHT, z + 0.5D, tier)); + } + + /** An invulnerable, named armor stand wearing the given four-piece suit. */ + private static void spawnSuitStand(ServerLevel level, BlockPos pos, Component name, float yaw, + Item helmet, Item chestplate, Item leggings, Item boots) { + ArmorStand stand = EntityType.ARMOR_STAND.spawn(level, pos, EntitySpawnReason.COMMAND); + if (stand == null) { + return; + } + stand.setItemSlot(EquipmentSlot.HEAD, new ItemStack(helmet)); + stand.setItemSlot(EquipmentSlot.CHEST, new ItemStack(chestplate)); + stand.setItemSlot(EquipmentSlot.LEGS, new ItemStack(leggings)); + stand.setItemSlot(EquipmentSlot.FEET, new ItemStack(boots)); + stand.setCustomName(name); + stand.setCustomNameVisible(true); + stand.setInvulnerable(true); + stand.setYRot(yaw); // uniform facing so the row reads as a clean line, angled a few degrees off straight-on + stand.setYBodyRot(yaw); + stand.setYHeadRot(yaw); + } + + /** Set one face of the pipe at {@code pos} to {@code mode} for ALL four resource layers. */ + private static void setAllModes(ServerLevel level, BlockPos pos, Direction face, PipeIoMode mode) { + if (level.getBlockEntity(pos) instanceof UniversalPipeBlockEntity pipe) { + for (PipeResourceType type : PipeResourceType.VALUES) { + pipe.setMode(face, type, mode); + } + } + } + + private static void spawnShowcase(ServerLevel level, EntityType type, BlockPos pos, boolean noAi) { + Mob mob = type.spawn(level, pos, EntitySpawnReason.COMMAND); + if (mob != null) { + mob.setNoAi(noAi); + mob.setPersistenceRequired(); + } + } +}