From a5549ab90c650c5175041b81986a5063e1c30c6a Mon Sep 17 00:00:00 2001 From: Arena Agent Date: Sun, 11 Oct 2026 07:13:04 +0000 Subject: [PATCH 01/24] Remove previous mod content (clean slate for the racing mod) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 34 - README.md | 120 - assets/blockbench/null_warden.bbmodel | 2961 ----------------- build.gradle | 43 - gradle.properties | 11 - settings.gradle | 9 - .../dev/qynl/myfirstmod/MyFirstModClient.java | 28 - .../myfirstmod/client/NullWardenTexture.java | 199 -- .../client/model/NullWardenModel.java | 594 ---- .../render/NullWardenGlowFeatureRenderer.java | 69 - .../client/render/NullWardenRenderer.java | 24 - src/client/resources/myfirstmod.mixins.json | 11 - .../java/dev/qynl/myfirstmod/MyFirstMod.java | 37 - .../dev/qynl/myfirstmod/block/ModBlocks.java | 19 - .../myfirstmod/block/VoidPortalBlock.java | 20 - .../dev/qynl/myfirstmod/boss/ModEntities.java | 24 - .../myfirstmod/boss/NullWardenEntity.java | 55 - .../myfirstmod/boss/NullWardenManager.java | 1630 --------- .../qynl/myfirstmod/boss/NullWardenState.java | 147 - .../dev/qynl/myfirstmod/item/ModItems.java | 25 - .../qynl/myfirstmod/item/NullbladeItem.java | 99 - .../myfirstmod/portal/VoidPortalManager.java | 160 - .../myfirstmod/blockstates/void_portal.json | 7 - .../assets/myfirstmod/items/null_relic.json | 6 - .../assets/myfirstmod/items/nullblade.json | 6 - .../assets/myfirstmod/lang/en_us.json | 5 - .../myfirstmod/models/block/void_portal.json | 6 - .../myfirstmod/models/item/null_relic.json | 6 - .../myfirstmod/models/item/nullblade.json | 23 - .../data/myfirstmod/dimension/null_realm.json | 11 - .../myfirstmod/dimension_type/null_realm.json | 24 - .../loot_tables/entities/null_warden.json | 14 - src/main/resources/fabric.mod.json | 27 - 33 files changed, 6454 deletions(-) delete mode 100644 .github/workflows/build.yml delete mode 100644 README.md delete mode 100644 assets/blockbench/null_warden.bbmodel delete mode 100644 build.gradle delete mode 100644 gradle.properties delete mode 100644 settings.gradle delete mode 100644 src/client/java/dev/qynl/myfirstmod/MyFirstModClient.java delete mode 100644 src/client/java/dev/qynl/myfirstmod/client/NullWardenTexture.java delete mode 100644 src/client/java/dev/qynl/myfirstmod/client/model/NullWardenModel.java delete mode 100644 src/client/java/dev/qynl/myfirstmod/client/render/NullWardenGlowFeatureRenderer.java delete mode 100644 src/client/java/dev/qynl/myfirstmod/client/render/NullWardenRenderer.java delete mode 100644 src/client/resources/myfirstmod.mixins.json delete mode 100644 src/main/java/dev/qynl/myfirstmod/MyFirstMod.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/block/ModBlocks.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/block/VoidPortalBlock.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/boss/ModEntities.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/boss/NullWardenEntity.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/boss/NullWardenManager.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/boss/NullWardenState.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/item/ModItems.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/item/NullbladeItem.java delete mode 100644 src/main/java/dev/qynl/myfirstmod/portal/VoidPortalManager.java delete mode 100644 src/main/resources/assets/myfirstmod/blockstates/void_portal.json delete mode 100644 src/main/resources/assets/myfirstmod/items/null_relic.json delete mode 100644 src/main/resources/assets/myfirstmod/items/nullblade.json delete mode 100644 src/main/resources/assets/myfirstmod/lang/en_us.json delete mode 100644 src/main/resources/assets/myfirstmod/models/block/void_portal.json delete mode 100644 src/main/resources/assets/myfirstmod/models/item/null_relic.json delete mode 100644 src/main/resources/assets/myfirstmod/models/item/nullblade.json delete mode 100644 src/main/resources/data/myfirstmod/dimension/null_realm.json delete mode 100644 src/main/resources/data/myfirstmod/dimension_type/null_realm.json delete mode 100644 src/main/resources/data/myfirstmod/loot_tables/entities/null_warden.json delete mode 100644 src/main/resources/fabric.mod.json diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml deleted file mode 100644 index e518f1c..0000000 --- a/.github/workflows/build.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: Build Mod - -on: - push: - branches: ["main"] - workflow_dispatch: - -jobs: - build: - runs-on: ubuntu-latest - - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Java 21 - uses: actions/setup-java@v4 - with: - distribution: temurin - java-version: "21" - - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - with: - gradle-version: "9.5.1" - - - name: Build - run: gradle build - - - name: Upload JAR - uses: actions/upload-artifact@v4 - with: - name: MyFirstMod - path: build/libs/*.jar diff --git a/README.md b/README.md deleted file mode 100644 index 4854778..0000000 --- a/README.md +++ /dev/null @@ -1,120 +0,0 @@ -# ๐Ÿ•ณ๏ธ The Null Warden - -> A handcrafted Minecraft 1.21.1 Fabric boss encounter hidden beneath an Ancient City. - -![Minecraft](https://img.shields.io/badge/Minecraft-1.21.1-62B47A?style=flat-square) -![Fabric](https://img.shields.io/badge/Fabric-1.21.1-DBD0B4?style=flat-square) -![Java](https://img.shields.io/badge/Java-21-ED8B00?style=flat-square) -![Build](https://github.com/Qynl/MyFirstMod/actions/workflows/build.yml/badge.svg) - -## โš”๏ธ The encounter - -The Null Warden is a multi-phase boss encounter built around Ancient City technology and a custom **Null Realm**. - -The intended loop is: - -1. Find an Ancient City. -2. Find the reinforced-deepslate seal. -3. Ignite the completed seal with flint and steel. -4. Enter the Void Gate. -5. Fight the Null Warden through four phases. -6. Defeat the encounter. -7. Claim the **Nullblade** and **Heart of the Null**. -8. Use the return gate to go back to your exact entry location. - -## ๐Ÿงฟ Boss phases - -The fight progressively introduces different attack patterns: - -- **Phase 1:** Void Cleave -- **Phase 2:** Sculk Ring and Void Rain -- **Phase 3:** Null Dash and Gravity Well -- **Phase 4:** Reality Tear and Collapse - -Later phases can also summon **Null Echoes**, which are tracked as part of the encounter so resets can clean them up correctly. - -## ๐ŸŸ๏ธ The Null Realm - -The Null Realm contains a deterministic arena with: - -- reinforced-deepslate pylons -- crying-obsidian perimeter -- polished-blackstone combat floor -- sculk structures -- dedicated return portal -- encounter-controlled boss state - -The arena is rebuilt from a known footprint, making failed encounters recoverable instead of leaving an uncontrolled collection of boss entities behind. - -## ๐Ÿ‘ฅ Multiplayer - -The encounter tracks participating players by UUID. - -The system handles: - -- players joining an active fight -- players leaving the realm -- deaths and disconnects -- boss-bar membership -- per-player reward protection -- encounter reset when everyone has abandoned the fight - -The return portal also stores the player's original dimension, position, yaw, and pitch. - -## ๐Ÿ—ก๏ธ Nullblade - -The **Nullblade** is the primary boss reward. - -It is a custom Netherite-based weapon with a charged area ability, cooldown, knockback, and a visual Null-themed particle effect. - -## ๐Ÿ› ๏ธ Development - -### Requirements - -- Minecraft **1.21.1** -- Fabric Loader -- Fabric API -- Java **21** -- Gradle / GitHub Actions - -### Build - -Every push to `main` triggers the GitHub Actions build. - -You can also run it manually from: - -**GitHub โ†’ Actions โ†’ Build Mod โ†’ Run workflow** - -The generated JAR is uploaded as the **MyFirstMod** artifact when the build succeeds. - -### Source layout - -```text -src/ -โ”œโ”€โ”€ main/ -โ”‚ โ”œโ”€โ”€ java/dev/qynl/myfirstmod/ -โ”‚ โ”‚ โ”œโ”€โ”€ MyFirstMod.java -โ”‚ โ”‚ โ”œโ”€โ”€ boss/ -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ NullWardenManager.java -โ”‚ โ”‚ โ”œโ”€โ”€ block/ -โ”‚ โ”‚ โ”œโ”€โ”€ item/ -โ”‚ โ”‚ โ””โ”€โ”€ portal/ -โ”‚ โ””โ”€โ”€ resources/ -โ””โ”€โ”€ client/ - โ”œโ”€โ”€ java/ - โ””โ”€โ”€ resources/ -``` - -## ๐Ÿงช Current development status - -This is an active development project rather than a finished release. - -The current architecture focuses on making the encounter recoverable and multiplayer-aware before adding more content. The next major gameplay layer is deeper arena interaction and more deliberate attack counterplay. - -## ๐Ÿ“„ License - -MIT - ---- - -Made by **Qynl** ๐Ÿ•ณ๏ธ diff --git a/assets/blockbench/null_warden.bbmodel b/assets/blockbench/null_warden.bbmodel deleted file mode 100644 index 49beb6a..0000000 --- a/assets/blockbench/null_warden.bbmodel +++ /dev/null @@ -1,2961 +0,0 @@ -{ - "meta": { - "format_version": "4.10", - "model_format": "free", - "box_uv": false - }, - "name": "Null Warden | Authored Boss Model | UV Matched", - "resolution": { - "width": 128, - "height": 128 - }, - "elements": [ - { - "id": "torso", - "name": "Torso", - "from": [ - -7, - 4, - -4 - ], - "to": [ - 7, - 22, - 4 - ], - "faces": { - "north": { - "uv": [ - 8.94, - 0.5, - 33.06, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 8.94, - 0.5, - 33.06, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 14.11, - 0.5, - 27.89, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 14.11, - 0.5, - 27.89, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 0.5, - 4.29, - 41.5, - 27.71 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 0.5, - 4.29, - 41.5, - 27.71 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "chest", - "name": "Chest Plate", - "from": [ - -8, - 4, - -6 - ], - "to": [ - 8, - 15, - -4 - ], - "faces": { - "north": { - "uv": [ - 0.5, - 64.41, - 41.5, - 92.59 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 0.5, - 64.41, - 41.5, - 92.59 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 17.91, - 61.5, - 24.09, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 17.91, - 61.5, - 24.09, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 0.5, - 75.94, - 41.5, - 81.06 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 0.5, - 75.94, - 41.5, - 81.06 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "core", - "name": "Null Core", - "from": [ - -4, - 8, - -7 - ], - "to": [ - 4, - 16, - -5 - ], - "faces": { - "north": { - "uv": [ - 9.5, - 60.5, - 18.5, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 9.5, - 60.5, - 18.5, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 12.88, - 60.5, - 15.13, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 12.88, - 60.5, - 15.13, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 0.5, - 61.63, - 27.5, - 68.38 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 0.5, - 61.63, - 27.5, - 68.38 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "head", - "name": "Head", - "from": [ - -5, - -7, - -5 - ], - "to": [ - 5, - 2, - 5 - ], - "faces": { - "north": { - "uv": [ - 47.28, - 0.5, - 81.72, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 47.28, - 0.5, - 81.72, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 47.28, - 0.5, - 81.72, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 47.28, - 0.5, - 81.72, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 49, - 0.5, - 80, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 49, - 0.5, - 80, - 31.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "jaw", - "name": "Jaw", - "from": [ - -4, - 2, - -5 - ], - "to": [ - 4, - 6, - 5 - ], - "faces": { - "north": { - "uv": [ - 87.5, - 6, - 127.5, - 26 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 87.5, - 6, - 127.5, - 26 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 87.5, - 8, - 127.5, - 24 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 87.5, - 8, - 127.5, - 24 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 95.1, - 0.5, - 119.9, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 95.1, - 0.5, - 119.9, - 31.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "left_horn", - "name": "Left Horn", - "from": [ - 4, - -14, - -2 - ], - "to": [ - 6, - -2, - 2 - ], - "faces": { - "north": { - "uv": [ - 55.42, - 35.5, - 58.58, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 55.42, - 35.5, - 58.58, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 53.83, - 35.5, - 60.17, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 53.83, - 35.5, - 60.17, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 52.5, - 36, - 61.5, - 54 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 52.5, - 36, - 61.5, - 54 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "right_horn", - "name": "Right Horn", - "from": [ - -6, - -14, - -2 - ], - "to": [ - -4, - -2, - 2 - ], - "faces": { - "north": { - "uv": [ - 55.42, - 35.5, - 58.58, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 55.42, - 35.5, - 58.58, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 53.83, - 35.5, - 60.17, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 53.83, - 35.5, - 60.17, - 54.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 52.5, - 36, - 61.5, - 54 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 52.5, - 36, - 61.5, - 54 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "left_arm", - "name": "Left Arm", - "from": [ - 8, - -1, - -3 - ], - "to": [ - 14, - 14, - 3 - ], - "faces": { - "north": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 67, - 32.5, - 94, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 67, - 32.5, - 94, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "right_arm", - "name": "Right Arm", - "from": [ - -14, - -1, - -3 - ], - "to": [ - -8, - 14, - 3 - ], - "faces": { - "north": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 67, - 32.5, - 94, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 67, - 32.5, - 94, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "left_leg", - "name": "Left Leg", - "from": [ - 1, - 14, - -4 - ], - "to": [ - 8, - 31, - 4 - ], - "faces": { - "north": { - "uv": [ - 9.94, - 32.5, - 21.06, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 9.94, - 32.5, - 21.06, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 9.15, - 32.5, - 21.85, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 9.15, - 32.5, - 21.85, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 3.69, - 32.5, - 27.31, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 3.69, - 32.5, - 27.31, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "right_leg", - "name": "Right Leg", - "from": [ - -8, - 14, - -4 - ], - "to": [ - -1, - 31, - 4 - ], - "faces": { - "north": { - "uv": [ - 9.94, - 32.5, - 21.06, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 9.94, - 32.5, - 21.06, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 9.15, - 32.5, - 21.85, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 9.15, - 32.5, - 21.85, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 3.69, - 32.5, - 27.31, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 3.69, - 32.5, - 27.31, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "back_spine", - "name": "Back Spine", - "from": [ - -2, - 3, - 4 - ], - "to": [ - 2, - 19, - 8 - ], - "faces": { - "north": { - "uv": [ - 35.25, - 82.5, - 38.75, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 35.25, - 82.5, - 38.75, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 35.25, - 82.5, - 38.75, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 35.25, - 82.5, - 38.75, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 32.5, - 85, - 41.5, - 94 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 32.5, - 85, - 41.5, - 94 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "crown", - "name": "Crown", - "from": [ - -2, - -21, - -2 - ], - "to": [ - 2, - -10, - 2 - ], - "faces": { - "north": { - "uv": [ - 108.18, - 0.5, - 115.82, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 108.18, - 0.5, - 115.82, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 108.18, - 0.5, - 115.82, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 108.18, - 0.5, - 115.82, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 101.5, - 0.5, - 122.5, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 101.5, - 0.5, - 122.5, - 21.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "shard_left", - "name": "Floating Shard Left", - "from": [ - -12, - 1, - 0 - ], - "to": [ - -8, - 8, - 2 - ], - "faces": { - "north": { - "uv": [ - 115.14, - 60.5, - 120.86, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 115.14, - 60.5, - 120.86, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 116.57, - 60.5, - 119.43, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 116.57, - 60.5, - 119.43, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 108.5, - 60.75, - 127.5, - 70.25 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 108.5, - 60.75, - 127.5, - 70.25 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "shard_right", - "name": "Floating Shard Right", - "from": [ - 8, - 1, - 0 - ], - "to": [ - 12, - 8, - 2 - ], - "faces": { - "north": { - "uv": [ - 115.14, - 60.5, - 120.86, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 115.14, - 60.5, - 120.86, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 116.57, - 60.5, - 119.43, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 116.57, - 60.5, - 119.43, - 70.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 108.5, - 60.75, - 127.5, - 70.25 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 108.5, - 60.75, - 127.5, - 70.25 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "chest_spine", - "name": "Chest Spine", - "from": [ - -2, - 5, - 4 - ], - "to": [ - 2, - 19, - 7 - ], - "faces": { - "north": { - "uv": [ - 112.14, - 26.5, - 115.86, - 39.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 112.14, - 26.5, - 115.86, - 39.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 112.61, - 26.5, - 115.39, - 39.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 112.61, - 26.5, - 115.39, - 39.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 105.33, - 26.5, - 122.67, - 39.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 105.33, - 26.5, - 122.67, - 39.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "forearm_l", - "name": "Forearm Left", - "from": [ - 8, - -2, - -3 - ], - "to": [ - 15, - 9, - 3 - ], - "faces": { - "north": { - "uv": [ - 71.91, - 32.5, - 89.09, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 71.91, - 32.5, - 89.09, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 73.14, - 32.5, - 87.86, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 73.14, - 32.5, - 87.86, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 65.5, - 33.14, - 95.5, - 58.86 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 65.5, - 33.14, - 95.5, - 58.86 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "forearm_r", - "name": "Forearm Right", - "from": [ - -15, - -2, - -3 - ], - "to": [ - -8, - 9, - 3 - ], - "faces": { - "north": { - "uv": [ - 71.91, - 32.5, - 89.09, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 71.91, - 32.5, - 89.09, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 73.14, - 32.5, - 87.86, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 73.14, - 32.5, - 87.86, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 65.5, - 33.14, - 95.5, - 58.86 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 65.5, - 33.14, - 95.5, - 58.86 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "shoulder_l", - "name": "Shoulder Left", - "from": [ - 7, - -2, - -5 - ], - "to": [ - 11, - 5, - 5 - ], - "faces": { - "north": { - "uv": [ - 72.79, - 32.5, - 88.21, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 72.79, - 32.5, - 88.21, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 65.5, - 35.5, - 95.5, - 56.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 65.5, - 35.5, - 95.5, - 56.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "shoulder_r", - "name": "Shoulder Right", - "from": [ - -11, - -2, - -5 - ], - "to": [ - -7, - 5, - 5 - ], - "faces": { - "north": { - "uv": [ - 72.79, - 32.5, - 88.21, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 72.79, - 32.5, - 88.21, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 65.5, - 35.5, - 95.5, - 56.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 65.5, - 35.5, - 95.5, - 56.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 75.1, - 32.5, - 85.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "waist", - "name": "Waist Armor", - "from": [ - -7, - 13, - -5 - ], - "to": [ - 7, - 18, - 5 - ], - "faces": { - "north": { - "uv": [ - 43.5, - 71, - 85.5, - 86 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 43.5, - 71, - 85.5, - 86 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 43.5, - 68, - 85.5, - 89 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 43.5, - 68, - 85.5, - 89 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 43.5, - 63.5, - 85.5, - 93.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 43.5, - 63.5, - 85.5, - 93.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "mantle", - "name": "Mantle", - "from": [ - -10, - -1, - -3 - ], - "to": [ - 10, - 5, - 5 - ], - "faces": { - "north": { - "uv": [ - 43.5, - 72.2, - 85.5, - 84.8 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 43.5, - 72.2, - 85.5, - 84.8 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 43.5, - 62.75, - 85.5, - 94.25 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 43.5, - 62.75, - 85.5, - 94.25 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 43.5, - 70.1, - 85.5, - 86.9 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 43.5, - 70.1, - 85.5, - 86.9 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "rib_l", - "name": "Rib Left", - "from": [ - 4, - 7, - -5 - ], - "to": [ - 7, - 14, - -3 - ], - "faces": { - "north": { - "uv": [ - 57.21, - 61.5, - 71.79, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 57.21, - 61.5, - 71.79, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 59.64, - 61.5, - 69.36, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 59.64, - 61.5, - 69.36, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 43.5, - 64.5, - 85.5, - 92.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 43.5, - 64.5, - 85.5, - 92.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "rib_r", - "name": "Rib Right", - "from": [ - -7, - 7, - -5 - ], - "to": [ - -4, - 14, - -3 - ], - "faces": { - "north": { - "uv": [ - 57.21, - 61.5, - 71.79, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 57.21, - 61.5, - 71.79, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 59.64, - 61.5, - 69.36, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 59.64, - 61.5, - 69.36, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 43.5, - 64.5, - 85.5, - 92.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 43.5, - 64.5, - 85.5, - 92.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "knee_l", - "name": "Knee Left", - "from": [ - 1, - 23, - -5 - ], - "to": [ - 8, - 28, - -3 - ], - "faces": { - "north": { - "uv": [ - 0.5, - 35.29, - 30.5, - 56.71 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 0.5, - 35.29, - 30.5, - 56.71 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 10.1, - 32.5, - 20.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 10.1, - 32.5, - 20.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 0.5, - 41.71, - 30.5, - 50.29 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 0.5, - 41.71, - 30.5, - 50.29 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "knee_r", - "name": "Knee Right", - "from": [ - -8, - 23, - -5 - ], - "to": [ - -1, - 28, - -3 - ], - "faces": { - "north": { - "uv": [ - 0.5, - 35.29, - 30.5, - 56.71 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 0.5, - 35.29, - 30.5, - 56.71 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 10.1, - 32.5, - 20.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 10.1, - 32.5, - 20.9, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 0.5, - 41.71, - 30.5, - 50.29 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 0.5, - 41.71, - 30.5, - 50.29 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "ankle_l", - "name": "Ankle Left", - "from": [ - 1, - 28, - -5 - ], - "to": [ - 8, - 32, - 5 - ], - "faces": { - "north": { - "uv": [ - 0.5, - 37.43, - 30.5, - 54.57 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 0.5, - 37.43, - 30.5, - 54.57 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 0.5, - 40, - 30.5, - 52 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 0.5, - 40, - 30.5, - 52 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 6.05, - 32.5, - 24.95, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 6.05, - 32.5, - 24.95, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "ankle_r", - "name": "Ankle Right", - "from": [ - -8, - 28, - -5 - ], - "to": [ - -1, - 32, - 5 - ], - "faces": { - "north": { - "uv": [ - 0.5, - 37.43, - 30.5, - 54.57 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 0.5, - 37.43, - 30.5, - 54.57 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 0.5, - 40, - 30.5, - 52 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 0.5, - 40, - 30.5, - 52 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 6.05, - 32.5, - 24.95, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 6.05, - 32.5, - 24.95, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "core_ring", - "name": "Core Ring", - "from": [ - -5, - 7, - -8 - ], - "to": [ - 5, - 17, - -6 - ], - "faces": { - "north": { - "uv": [ - 9.5, - 60.5, - 18.5, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 9.5, - 60.5, - 18.5, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 13.1, - 60.5, - 14.9, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 13.1, - 60.5, - 14.9, - 69.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 0.5, - 62.3, - 27.5, - 67.7 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 0.5, - 62.3, - 27.5, - 67.7 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "jaw_plate", - "name": "Jaw Plate", - "from": [ - -5, - 2, - -6 - ], - "to": [ - 5, - 5, - -4 - ], - "faces": { - "north": { - "uv": [ - 87.5, - 10, - 127.5, - 22 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 87.5, - 10, - 127.5, - 22 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 97.17, - 0.5, - 117.83, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 97.17, - 0.5, - 117.83, - 31.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 87.5, - 12, - 127.5, - 20 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 87.5, - 12, - 127.5, - 20 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "mantle_shard", - "name": "Mantle Shard", - "from": [ - 7, - -2, - 4 - ], - "to": [ - 11, - 4, - 7 - ], - "faces": { - "north": { - "uv": [ - 53.17, - 61.5, - 75.83, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 53.17, - 61.5, - 75.83, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 56, - 61.5, - 73, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 56, - 61.5, - 73, - 95.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 43.5, - 62.75, - 85.5, - 94.25 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 43.5, - 62.75, - 85.5, - 94.25 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "crown_l", - "name": "Left Crown Shard", - "from": [ - -6, - -17, - -2 - ], - "to": [ - -3, - -10, - 2 - ], - "faces": { - "north": { - "uv": [ - 107.5, - 0.5, - 116.5, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 107.5, - 0.5, - 116.5, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 106, - 0.5, - 118, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 106, - 0.5, - 118, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 104.13, - 0.5, - 119.88, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 104.13, - 0.5, - 119.88, - 21.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "crown_r", - "name": "Right Crown Shard", - "from": [ - 3, - -18, - -2 - ], - "to": [ - 6, - -10, - 2 - ], - "faces": { - "north": { - "uv": [ - 108.06, - 0.5, - 115.94, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 108.06, - 0.5, - 115.94, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 106.75, - 0.5, - 117.25, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 106.75, - 0.5, - 117.25, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 104.13, - 0.5, - 119.88, - 21.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 104.13, - 0.5, - 119.88, - 21.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "head_back", - "name": "Head Back", - "from": [ - -4, - -5, - 4 - ], - "to": [ - 4, - 1, - 7 - ], - "faces": { - "north": { - "uv": [ - 89.5, - 32.5, - 125.5, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 89.5, - 32.5, - 125.5, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 100.75, - 32.5, - 114.25, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 100.75, - 32.5, - 114.25, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 87.5, - 38.5, - 127.5, - 53.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 87.5, - 38.5, - 127.5, - 53.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "back_shard_l", - "name": "Back Shard Left", - "from": [ - -7, - 0, - 5 - ], - "to": [ - -5, - 10, - 9 - ], - "faces": { - "north": { - "uv": [ - 35.6, - 82.5, - 38.4, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 35.6, - 82.5, - 38.4, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 34.2, - 82.5, - 39.8, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 34.2, - 82.5, - 39.8, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 33.5, - 82.5, - 40.5, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 33.5, - 82.5, - 40.5, - 96.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "back_shard_r", - "name": "Back Shard Right", - "from": [ - 5, - -2, - 5 - ], - "to": [ - 7, - 10, - 9 - ], - "faces": { - "north": { - "uv": [ - 35.83, - 82.5, - 38.17, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 35.83, - 82.5, - 38.17, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 34.67, - 82.5, - 39.33, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 34.67, - 82.5, - 39.33, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 33.5, - 82.5, - 40.5, - 96.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 33.5, - 82.5, - 40.5, - 96.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "foot_l", - "name": "Left Foot", - "from": [ - 0, - 30, - -6 - ], - "to": [ - 9, - 34, - 6 - ], - "faces": { - "north": { - "uv": [ - 0.5, - 39.33, - 30.5, - 52.67 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 0.5, - 39.33, - 30.5, - 52.67 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 0.5, - 41, - 30.5, - 51 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 0.5, - 41, - 30.5, - 51 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 5.38, - 32.5, - 25.63, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 5.38, - 32.5, - 25.63, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - }, - { - "id": "foot_r", - "name": "Right Foot", - "from": [ - -9, - 30, - -6 - ], - "to": [ - 0, - 34, - 6 - ], - "faces": { - "north": { - "uv": [ - 0.5, - 39.33, - 30.5, - 52.67 - ], - "texture": 0, - "rotation": 0 - }, - "south": { - "uv": [ - 0.5, - 39.33, - 30.5, - 52.67 - ], - "texture": 0, - "rotation": 0 - }, - "east": { - "uv": [ - 0.5, - 41, - 30.5, - 51 - ], - "texture": 0, - "rotation": 0 - }, - "west": { - "uv": [ - 0.5, - 41, - 30.5, - 51 - ], - "texture": 0, - "rotation": 0 - }, - "up": { - "uv": [ - 5.38, - 32.5, - 25.63, - 59.5 - ], - "texture": 0, - "rotation": 0 - }, - "down": { - "uv": [ - 5.38, - 32.5, - 25.63, - 59.5 - ], - "texture": 0, - "rotation": 0 - } - } - } - ], - "outliner": [ - { - "uuid": "root", - "name": "Null Warden", - "origin": [ - 0, - 0, - 0 - ], - "children": [ - "torso", - "head", - "left_arm", - "right_arm", - "left_leg", - "right_leg", - "back_spine", - "crown", - "shard_left", - "shard_right", - "chest_spine", - "forearm_l", - "forearm_r", - "shoulder_l", - "shoulder_r", - "waist", - "mantle", - "rib_l", - "rib_r", - "knee_l", - "knee_r", - "ankle_l", - "ankle_r", - "core_ring", - "jaw_plate", - "mantle_shard", - "crown_l", - "crown_r", - "head_back", - "back_shard_l", - "back_shard_r", - "foot_l", - "foot_r" - ] - } - ], - "textures": [ - { - "path": "assets/myfirstmod/textures/entity/null_warden.png", - "name": "null_warden.png", - "folder": "", - "namespace": "", - "id": "nullwardentexture", - "particle": false, - "render_mode": "normal", - "visible": true, - "internal": false - } - ] -} diff --git a/build.gradle b/build.gradle deleted file mode 100644 index 6a3058e..0000000 --- a/build.gradle +++ /dev/null @@ -1,43 +0,0 @@ -plugins { - id 'fabric-loom' version '1.9.2' -} - -repositories { - maven { name = "Fabric"; url = "https://maven.fabricmc.net/" } - mavenCentral() -} - -loom { - splitEnvironmentSourceSets() - - mods { - "myfirstmod" { - sourceSet sourceSets.main - sourceSet sourceSets.client - } - } -} - -dependencies { - minecraft "com.mojang:minecraft:${minecraft_version}" - mappings "net.fabricmc:yarn:${yarn_mappings}:v2" - modImplementation "net.fabricmc:fabric-loader:${loader_version}" - modImplementation "net.fabricmc.fabric-api:fabric-api:${fabric_api_version}" -} - -processResources { - inputs.property "version", project.mod_version - filesMatching("fabric.mod.json") { - expand "version": project.mod_version - } -} - -tasks.withType(JavaCompile).configureEach { - it.options.release = 21 -} - -java { - sourceCompatibility = JavaVersion.VERSION_21 - targetCompatibility = JavaVersion.VERSION_21 - withSourcesJar() -} diff --git a/gradle.properties b/gradle.properties deleted file mode 100644 index f6f6398..0000000 --- a/gradle.properties +++ /dev/null @@ -1,11 +0,0 @@ -org.gradle.jvmargs=-Xmx1G -org.gradle.parallel=true -org.gradle.configuration-cache=false - -minecraft_version=1.21.1 -loader_version=0.16.10 -yarn_mappings=1.21.1+build.3 -fabric_api_version=0.116.17+1.21.1 - -mod_version=1.0.0 -maven_group=dev.qynl diff --git a/settings.gradle b/settings.gradle deleted file mode 100644 index 29edc31..0000000 --- a/settings.gradle +++ /dev/null @@ -1,9 +0,0 @@ -pluginManagement { - repositories { - maven { name = 'Fabric'; url = 'https://maven.fabricmc.net/' } - mavenCentral() - gradlePluginPortal() - } -} - -rootProject.name = 'myfirstmod' diff --git a/src/client/java/dev/qynl/myfirstmod/MyFirstModClient.java b/src/client/java/dev/qynl/myfirstmod/MyFirstModClient.java deleted file mode 100644 index cf0a6e4..0000000 --- a/src/client/java/dev/qynl/myfirstmod/MyFirstModClient.java +++ /dev/null @@ -1,28 +0,0 @@ -package dev.qynl.myfirstmod; - -import dev.qynl.myfirstmod.block.ModBlocks; -import dev.qynl.myfirstmod.boss.ModEntities; -import dev.qynl.myfirstmod.client.NullWardenTexture; -import dev.qynl.myfirstmod.client.model.NullWardenModel; -import dev.qynl.myfirstmod.client.render.NullWardenRenderer; -import net.fabricmc.api.ClientModInitializer; -import net.fabricmc.fabric.api.blockrenderlayer.v1.BlockRenderLayerMap; -import net.fabricmc.fabric.api.client.rendering.v1.EntityModelLayerRegistry; -import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; -import net.minecraft.client.render.RenderLayer; - -public class MyFirstModClient implements ClientModInitializer { - @Override - public void onInitializeClient() { - BlockRenderLayerMap.INSTANCE.putBlock(ModBlocks.VOID_PORTAL, RenderLayer.getTranslucent()); - - EntityModelLayerRegistry.registerModelLayer( - NullWardenRenderer.MODEL_LAYER, - NullWardenModel::getTexturedModelData); - - NullWardenTexture.TEXTURE = NullWardenTexture.register(); - NullWardenTexture.GLOW_TEXTURE = NullWardenTexture.registerGlow(); - - EntityRendererRegistry.register(ModEntities.NULL_WARDEN, NullWardenRenderer::new); - } -} diff --git a/src/client/java/dev/qynl/myfirstmod/client/NullWardenTexture.java b/src/client/java/dev/qynl/myfirstmod/client/NullWardenTexture.java deleted file mode 100644 index be5fc8f..0000000 --- a/src/client/java/dev/qynl/myfirstmod/client/NullWardenTexture.java +++ /dev/null @@ -1,199 +0,0 @@ -package dev.qynl.myfirstmod.client; - -import net.minecraft.client.MinecraftClient; -import net.minecraft.client.texture.NativeImage; -import net.minecraft.client.texture.NativeImageBackedTexture; -import net.minecraft.util.Identifier; - -public final class NullWardenTexture { - public static Identifier TEXTURE; - public static Identifier GLOW_TEXTURE; - private NullWardenTexture() {} - - public static Identifier register() { - return registerTexture(buildBaseTexture(), "null_warden"); - } - - public static Identifier registerGlow() { - return registerTexture(buildGlowTexture(), "null_warden_glow"); - } - - private static Identifier registerTexture(NativeImage image, String name) { - NativeImageBackedTexture texture = new NativeImageBackedTexture(image); - return MinecraftClient.getInstance().getTextureManager().registerDynamicTexture(name, texture); - } - - private static NativeImage buildBaseTexture() { - NativeImage image = new NativeImage(128, 128, false); - - // Layered Null-forged material palette. Broad regions stay readable at - // game distance, while fine seams provide close-up detail. - for (int y = 0; y < 96; y++) { - for (int x = 0; x < 128; x++) { - int r = 7, g = 9, b = 13; - int band = (x / 16 + y / 12) & 3; - switch (band) { - case 1 -> { r = 13; g = 16; b = 22; } - case 2 -> { r = 19; g = 22; b = 29; } - case 3 -> { r = 10; g = 13; b = 18; } - } - - // Subtle vertical grain keeps large armor plates from reading flat. - int grain = (x * 13 + y * 7) & 15; - if (grain == 0 || grain == 1) { - r += 5; g += 6; b += 8; - } - - // Occasional cold mineral flecks. - if ((x * 17 + y * 29) % 137 == 0) { - r = 31; g = 39; b = 47; - } - - image.setColor(x, y, argb(255, r, g, b)); - } - } - - // Distinct panel fields: chest/torso, limbs, head and mantle each use - // a different value range so the silhouette remains legible. - fillRect(image, 0, 0, 42, 32, 255, 15, 17, 23); - fillRect(image, 43, 0, 43, 32, 255, 9, 12, 18); - fillRect(image, 87, 0, 41, 32, 255, 20, 19, 28); - fillRect(image, 0, 32, 31, 28, 255, 11, 14, 20); - fillRect(image, 32, 32, 32, 28, 255, 18, 20, 27); - fillRect(image, 65, 32, 31, 28, 255, 12, 15, 21); - fillRect(image, 97, 32, 31, 28, 255, 16, 18, 25); - fillRect(image, 0, 61, 42, 35, 255, 12, 15, 21); - fillRect(image, 43, 61, 43, 35, 255, 22, 22, 28); - fillRect(image, 87, 61, 41, 35, 255, 8, 11, 16); - - // Hard armor boundaries. Broken segments imply overlapping plates. - int[][] seams = { - {2, 31, 39, 31}, {44, 30, 82, 30}, {88, 29, 125, 29}, - {3, 59, 27, 59}, {35, 61, 62, 61}, {68, 58, 94, 58}, - {99, 62, 125, 62}, {4, 94, 38, 94}, {47, 91, 81, 91}, - {90, 95, 124, 95} - }; - for (int i = 0; i < seams.length; i++) { - int[] s = seams[i]; - paintLine(image, s[0], s[1], s[2], s[3], - i % 2 == 0 ? 4 : 30, i % 2 == 0 ? 7 : 34, i % 2 == 0 ? 11 : 42); - } - - // Signature fractured armor seams, intentionally asymmetric. - int[][] fractures = { - {10, 8, 20, 15}, {27, 5, 35, 18}, {47, 22, 57, 14}, - {67, 6, 76, 13}, {82, 17, 91, 10}, {98, 34, 115, 26}, - {6, 42, 17, 36}, {24, 48, 34, 59}, {43, 43, 53, 37}, - {57, 52, 69, 64}, {74, 45, 83, 54}, {96, 56, 113, 49}, - {9, 73, 22, 66}, {35, 69, 46, 82}, {63, 76, 77, 68}, - {85, 78, 96, 88}, {105, 71, 121, 79} - }; - for (int i = 0; i < fractures.length; i++) { - int[] f = fractures[i]; - paintLine(image, f[0], f[1], f[2], f[3], 43, 13, 55); - paintLine(image, f[0], f[1] + 1, f[2], f[3] + 1, 18, 24, 31); - if ((i & 1) == 0) { - paintLine(image, f[2], f[3], f[2] + 2, f[3] + 4, 26, 78, 84); - } - } - - // Raised plate highlights: narrow, offset strips rather than outlines. - int[][] highlights = { - {4, 3, 18, 3}, {48, 4, 61, 4}, {91, 4, 105, 4}, - {5, 35, 18, 35}, {38, 34, 54, 34}, {71, 34, 83, 34}, - {8, 64, 20, 64}, {49, 64, 62, 64}, {92, 66, 107, 66} - }; - for (int[] h : highlights) { - paintLine(image, h[0], h[1], h[2], h[3], 48, 53, 61); - } - - // Small cyan stress points make energy leak through the fractures. - for (int i = 0; i < 14; i++) { - int x = 7 + (i * 29) % 114; - int y = 7 + (i * 19) % 84; - fillRect(image, x, y, 1 + (i & 1), 1, 255, 35, 148, 160); - } - - // Core, eye, crown and horn UV islands. - paintGlowGlyph(image, 0, 60, 28, 10, 33, 236, 255); - fillRect(image, 0, 104, 8, 2, 255, 39, 214, 229); - fillRect(image, 10, 104, 4, 1, 255, 98, 242, 255); - paintGlowGlyph(image, 96, 0, 32, 22, 96, 42, 255); - paintGlowGlyph(image, 52, 35, 10, 20, 39, 194, 210); - - // Dark secondary material on the lower atlas prevents accidental - // bright sampling on newly added fragment faces. - fillRect(image, 0, 108, 128, 20, 255, 4, 6, 10); - - return image; - } - - private static NativeImage buildGlowTexture() { - NativeImage image = new NativeImage(128, 128, true); - - // Transparent by default. Only the model's core, crown and horn UV islands glow. - fillRect(image, 0, 60, 28, 10, 255, 38, 245, 255); - fillRect(image, 0, 104, 8, 2, 255, 48, 235, 255); - fillRect(image, 10, 104, 4, 1, 255, 120, 255, 255); - fillRect(image, 20, 60, 6, 6, 255, 84, 255, 255); - fillRect(image, 36, 60, 2, 2, 255, 255, 255, 255); - - fillRect(image, 96, 0, 32, 22, 210, 80, 40, 255); - fillRect(image, 52, 35, 10, 20, 210, 34, 235, 255); - fillRect(image, 108, 60, 20, 11, 170, 35, 220, 255); - - return image; - } - - private static void paintGlowGlyph(NativeImage image, int x, int y, int w, int h, int r, int g, int b) { - int cx = x + w / 2; - int cy = y + h / 2; - for (int py = y; py < y + h; py++) { - for (int px = x; px < x + w; px++) { - int dx = Math.abs(px - cx); - int dy = Math.abs(py - cy); - if (dx + dy <= Math.max(2, Math.min(w, h) / 3)) { - image.setColor(px, py, argb(255, r, g, b)); - } - } - } - } - - private static void paintLine(NativeImage image, int x0, int y0, int x1, int y1, int r, int g, int b) { - int dx = Math.abs(x1 - x0); - int dy = -Math.abs(y1 - y0); - int sx = x0 < x1 ? 1 : -1; - int sy = y0 < y1 ? 1 : -1; - int err = dx + dy; - - while (true) { - if (x0 >= 0 && x0 < 128 && y0 >= 0 && y0 < 128) { - image.setColor(x0, y0, argb(255, r, g, b)); - } - if (x0 == x1 && y0 == y1) { - break; - } - int e2 = 2 * err; - if (e2 >= dy) { - err += dy; - x0 += sx; - } - if (e2 <= dx) { - err += dx; - y0 += sy; - } - } - } - - private static void fillRect(NativeImage image, int x, int y, int w, int h, int a, int r, int g, int b) { - for (int py = y; py < y + h && py < 128; py++) { - for (int px = x; px < x + w && px < 128; px++) { - image.setColor(px, py, argb(a, r, g, b)); - } - } - } - - private static int argb(int a, int r, int g, int b) { - return (a << 24) | (b << 16) | (g << 8) | r; - } -} diff --git a/src/client/java/dev/qynl/myfirstmod/client/model/NullWardenModel.java b/src/client/java/dev/qynl/myfirstmod/client/model/NullWardenModel.java deleted file mode 100644 index d2eaa62..0000000 --- a/src/client/java/dev/qynl/myfirstmod/client/model/NullWardenModel.java +++ /dev/null @@ -1,594 +0,0 @@ -package dev.qynl.myfirstmod.client.model; - -import dev.qynl.myfirstmod.boss.NullWardenEntity; -import net.minecraft.client.model.Dilation; -import net.minecraft.client.model.ModelData; -import net.minecraft.client.model.ModelPart; -import net.minecraft.client.model.ModelPartBuilder; -import net.minecraft.client.model.ModelPartData; -import net.minecraft.client.model.ModelTransform; -import net.minecraft.client.model.TexturedModelData; -import net.minecraft.client.render.entity.model.SinglePartEntityModel; -import net.minecraft.util.math.MathHelper; - -public class NullWardenModel extends SinglePartEntityModel { - private final ModelPart root; - private final ModelPart torso; - private final ModelPart neck; - private final ModelPart head; - private final ModelPart eyeSlit; - private final ModelPart core; - private final ModelPart leftArm; - private final ModelPart rightArm; - private final ModelPart leftLeg; - private final ModelPart rightLeg; - private final ModelPart leftHorn; - private final ModelPart rightHorn; - private final ModelPart shoulderLeft; - private final ModelPart shoulderRight; - private final ModelPart forearmLeft; - private final ModelPart forearmRight; - private final ModelPart waist; - private final ModelPart mantle; - private final ModelPart chestPlate; - private final ModelPart ribLeft; - private final ModelPart ribRight; - private final ModelPart jaw; - private final ModelPart backSpine; - private final ModelPart spineTip; - private final ModelPart leftHip; - private final ModelPart rightHip; - private final ModelPart crown; - private final ModelPart shardLeft; - private final ModelPart shardRight; - private final ModelPart chestSpine; - private final ModelPart collarLeft; - private final ModelPart collarRight; - private final ModelPart kneeLeft; - private final ModelPart kneeRight; - private final ModelPart ankleLeft; - private final ModelPart ankleRight; - private final ModelPart coreRing; - private final ModelPart leftCrownShard; - private final ModelPart rightCrownShard; - private final ModelPart jawPlate; - private final ModelPart mantleShard; - private final ModelPart leftFoot; - private final ModelPart rightFoot; - private final ModelPart backShardLeft; - private final ModelPart backShardRight; - private final ModelPart headBack; - - public NullWardenModel(ModelPart root) { - this.root = root; - this.torso = root.getChild("torso"); - this.neck = torso.getChild("neck"); - this.head = torso.getChild("head"); - this.eyeSlit = head.getChild("eye_slit"); - this.core = torso.getChild("core"); - this.leftArm = torso.getChild("left_arm"); - this.rightArm = torso.getChild("right_arm"); - this.leftLeg = root.getChild("left_leg"); - this.rightLeg = root.getChild("right_leg"); - this.leftHorn = head.getChild("left_horn"); - this.rightHorn = head.getChild("right_horn"); - this.shoulderLeft = torso.getChild("shoulder_left"); - this.shoulderRight = torso.getChild("shoulder_right"); - this.forearmLeft = leftArm.getChild("forearm_left"); - this.forearmRight = rightArm.getChild("forearm_right"); - this.waist = torso.getChild("waist"); - this.mantle = torso.getChild("mantle"); - this.chestPlate = torso.getChild("chest_plate"); - this.ribLeft = torso.getChild("rib_left"); - this.ribRight = torso.getChild("rib_right"); - this.jaw = head.getChild("jaw"); - this.backSpine = torso.getChild("back_spine"); - this.spineTip = torso.getChild("spine_tip"); - this.leftHip = root.getChild("left_hip"); - this.rightHip = root.getChild("right_hip"); - this.crown = head.getChild("crown"); - this.shardLeft = torso.getChild("shard_left"); - this.shardRight = torso.getChild("shard_right"); - this.chestSpine = torso.getChild("chest_spine"); - this.collarLeft = torso.getChild("collar_left"); - this.collarRight = torso.getChild("collar_right"); - this.kneeLeft = root.getChild("knee_left"); - this.kneeRight = root.getChild("knee_right"); - this.ankleLeft = root.getChild("ankle_left"); - this.ankleRight = root.getChild("ankle_right"); - this.coreRing = torso.getChild("core_ring"); - this.leftCrownShard = head.getChild("left_crown_shard"); - this.rightCrownShard = head.getChild("right_crown_shard"); - this.jawPlate = head.getChild("jaw_plate"); - this.mantleShard = torso.getChild("mantle_shard"); - this.leftFoot = root.getChild("left_foot"); - this.rightFoot = root.getChild("right_foot"); - this.backShardLeft = torso.getChild("back_shard_left"); - this.backShardRight = torso.getChild("back_shard_right"); - this.headBack = head.getChild("head_back"); - } - - public static TexturedModelData getTexturedModelData() { - ModelData data = new ModelData(); - ModelPartData r = data.getRoot(); - - r.addChild("torso", ModelPartBuilder.create() - .uv(0, 0).cuboid(-7, -12, -4, 14, 18, 8) - .uv(0, 28).cuboid(-9, -9, -3, 18, 10, 6, new Dilation(1.0f)) - .uv(0, 48).cuboid(-6, -1, -5, 12, 9, 3), - ModelTransform.pivot(0, 4, 0)); - - ModelPartData t = r.getChild("torso"); - - t.addChild("neck", ModelPartBuilder.create() - .uv(40, 0).cuboid(-4, -4, -3, 8, 6, 6) - .uv(40, 12).cuboid(-5, -2, -4, 10, 4, 8, new Dilation(0.5f)), - ModelTransform.pivot(0, -11, 0)); - - t.addChild("head", ModelPartBuilder.create() - .uv(52, 0).cuboid(-5, -7, -5, 10, 9, 10) - .uv(52, 19).cuboid(-3, 2, -4, 6, 3, 8) - .uv(76, 0).cuboid(-6, -4, -6, 12, 5, 2), - ModelTransform.pivot(0, -12, 0)); - - t.addChild("core", ModelPartBuilder.create() - .uv(0, 60).cuboid(-4, -4, -5, 8, 8, 2) - .uv(20, 60).cuboid(-3, -3, -6, 6, 6, 2) - .uv(36, 60).cuboid(-1, -1, -7, 2, 2, 2), - ModelTransform.pivot(0, 0, -1)); - - t.addChild("core_ring", ModelPartBuilder.create() - .uv(24, 104).cuboid(-5, -5, -5.5f, 10, 1, 1) - .uv(24, 106).cuboid(-5, 4, -5.5f, 10, 1, 1) - .uv(40, 104).cuboid(-5.5f, -5, -5.5f, 1, 10, 1), - ModelTransform.pivot(0, 0, 0)); - - t.addChild("chest_plate", ModelPartBuilder.create() - .uv(0, 72).cuboid(-7, -8, -5, 14, 9, 2) - .uv(32, 72).cuboid(-5, 0, -6, 10, 4, 2) - .uv(56, 72).cuboid(-3, 3, -5, 6, 3, 2), - ModelTransform.pivot(0, 0, 0)); - - t.addChild("rib_left", ModelPartBuilder.create() - .uv(0, 82).cuboid(0, -3, -6, 7, 3, 2) - .uv(0, 88).cuboid(1, 1, -6, 6, 2, 2), - ModelTransform.pivot(-1, 0, 0)); - - t.addChild("rib_right", ModelPartBuilder.create() - .uv(16, 82).cuboid(-7, -3, -6, 7, 3, 2) - .uv(16, 88).cuboid(-7, 1, -6, 6, 2, 2), - ModelTransform.pivot(1, 0, 0)); - - t.addChild("back_spine", ModelPartBuilder.create() - .uv(32, 82).cuboid(-2, -9, 4, 4, 15, 4) - .uv(44, 82).cuboid(-3, -2, 6, 6, 4, 3), - ModelTransform.pivot(0, 0, 0)); - - t.addChild("spine_tip", ModelPartBuilder.create() - .uv(56, 82).cuboid(-2, -5, 5, 4, 8, 4) - .uv(68, 82).cuboid(-1, -8, 6, 2, 4, 2), - ModelTransform.pivot(0, 6, 0)); - - t.addChild("shoulder_left", ModelPartBuilder.create() - .uv(0, 92).cuboid(-2, -3, -5, 10, 6, 10) - .uv(24, 92).cuboid(6, -1, -4, 5, 3, 8), - ModelTransform.pivot(7, -6, 0)); - - t.addChild("shoulder_right", ModelPartBuilder.create() - .uv(0, 92).cuboid(-8, -3, -5, 10, 6, 10) - .uv(24, 92).cuboid(-11, -1, -4, 5, 3, 8), - ModelTransform.pivot(-7, -6, 0)); - - t.addChild("waist", ModelPartBuilder.create() - .uv(40, 48).cuboid(-8, -1, -5, 16, 4, 10) - .uv(40, 56).cuboid(-6, 2, -6, 12, 3, 12), - ModelTransform.pivot(0, 7, 0)); - - t.addChild("mantle", ModelPartBuilder.create() - .uv(76, 18).cuboid(-9, -5, 3, 18, 12, 3) - .uv(76, 33).cuboid(-7, 6, 3, 14, 7, 3), - ModelTransform.pivot(0, -4, 0)); - - t.addChild("mantle_shard", ModelPartBuilder.create() - .uv(112, 104).cuboid(-1, -5, 3, 2, 9, 3), - ModelTransform.of(9, -1, 0, 0, 0, -0.12f)); - - t.addChild("left_arm", ModelPartBuilder.create() - .uv(68, 48).cuboid(0, -3, -3, 6, 15, 6) - .uv(92, 48).cuboid(3, 7, -4, 5, 8, 8), - ModelTransform.pivot(8, -5, 0)); - - t.addChild("right_arm", ModelPartBuilder.create() - .uv(68, 48).cuboid(-6, -3, -3, 6, 15, 6) - .uv(92, 48).cuboid(-8, 7, -4, 5, 8, 8), - ModelTransform.pivot(-8, -5, 0)); - - ModelPartData la = t.getChild("left_arm"); - la.addChild("forearm_left", ModelPartBuilder.create() - .uv(108, 48).cuboid(0, -1, -4, 5, 8, 8) - .uv(108, 60).cuboid(1, 5, -5, 3, 5, 10), - ModelTransform.pivot(3, 8, 0)); - - ModelPartData ra = t.getChild("right_arm"); - ra.addChild("forearm_right", ModelPartBuilder.create() - .uv(108, 48).cuboid(-5, -1, -4, 5, 8, 8) - .uv(108, 60).cuboid(-4, 5, -5, 3, 5, 10), - ModelTransform.pivot(-3, 8, 0)); - - r.addChild("left_leg", ModelPartBuilder.create() - .uv(0, 38).cuboid(-4, 0, -4, 7, 17, 8) - .uv(30, 38).cuboid(-5, 14, -5, 9, 5, 10) - .uv(0, 56).cuboid(-3, 4, -5, 5, 8, 2), - ModelTransform.pivot(4, 10, 0)); - - r.addChild("right_leg", ModelPartBuilder.create() - .uv(0, 38).cuboid(-3, 0, -4, 7, 17, 8) - .uv(30, 38).cuboid(-4, 14, -5, 9, 5, 10) - .uv(0, 56).cuboid(-2, 4, -5, 5, 8, 2), - ModelTransform.pivot(-4, 10, 0)); - - r.addChild("left_hip", ModelPartBuilder.create() - .uv(76, 48).cuboid(-5, -2, -5, 10, 5, 10), - ModelTransform.pivot(4, 9, 0)); - - r.addChild("right_hip", ModelPartBuilder.create() - .uv(76, 48).cuboid(-5, -2, -5, 10, 5, 10), - ModelTransform.pivot(-4, 9, 0)); - - r.addChild("knee_left", ModelPartBuilder.create() - .uv(84, 82).cuboid(-4, -2, -5, 8, 5, 3) - .uv(96, 82).cuboid(-3, 2, -4, 6, 3, 2), - ModelTransform.pivot(4, 22, 0)); - - r.addChild("knee_right", ModelPartBuilder.create() - .uv(84, 82).cuboid(-4, -2, -5, 8, 5, 3) - .uv(96, 82).cuboid(-3, 2, -4, 6, 3, 2), - ModelTransform.pivot(-4, 22, 0)); - - r.addChild("ankle_left", ModelPartBuilder.create() - .uv(104, 88).cuboid(-4, -1, -5, 8, 4, 9), - ModelTransform.pivot(4, 28, 0)); - - r.addChild("ankle_right", ModelPartBuilder.create() - .uv(104, 88).cuboid(-4, -1, -5, 8, 4, 9), - ModelTransform.pivot(-4, 28, 0)); - - ModelPartData h = t.getChild("head"); - - h.addChild("left_horn", ModelPartBuilder.create() - .uv(52, 35).cuboid(0, -8, -2, 3, 10, 4) - .uv(52, 49).cuboid(2, -12, -1, 2, 5, 2) - .uv(58, 49).cuboid(3, -15, -1, 2, 4, 2), - ModelTransform.pivot(4, -2, 0)); - - h.addChild("right_horn", ModelPartBuilder.create() - .uv(52, 35).cuboid(-3, -8, -2, 3, 10, 4) - .uv(52, 49).cuboid(-4, -12, -1, 2, 5, 2) - .uv(58, 49).cuboid(-5, -15, -1, 2, 4, 2), - ModelTransform.pivot(-4, -2, 0)); - - h.addChild("eye_slit", ModelPartBuilder.create() - .uv(0, 104).cuboid(-4, -2, -5.25f, 8, 2, 1) - .uv(10, 104).cuboid(-2, -1, -5.35f, 4, 1, 1), - ModelTransform.pivot(0, 0, 0)); - - h.addChild("jaw", ModelPartBuilder.create() - .uv(76, 0).cuboid(-4, 1, -5, 8, 4, 10) - .uv(76, 42).cuboid(-3, 4, -4, 6, 2, 8), - ModelTransform.pivot(0, 0, 0)); - - h.addChild("crown", ModelPartBuilder.create() - .uv(96, 0).cuboid(-2, -10, -2, 4, 5, 4) - .uv(96, 12).cuboid(-7, -7, -1, 5, 3, 3) - .uv(108, 12).cuboid(2, -7, -1, 5, 3, 3) - .uv(96, 18).cuboid(-2, -14, -1, 4, 5, 2), - ModelTransform.pivot(0, 0, 0)); - - h.addChild("left_crown_shard", ModelPartBuilder.create() - .uv(72, 104).cuboid(-1, -8, -1, 2, 7, 2) - .uv(80, 104).cuboid(-2, -4, -1, 3, 2, 2), - ModelTransform.of(5, -7, 0, 0, 0, -0.18f)); - - h.addChild("right_crown_shard", ModelPartBuilder.create() - .uv(88, 104).cuboid(-1, -7, -1, 2, 6, 2) - .uv(96, 104).cuboid(-1, -3, -1, 3, 2, 2), - ModelTransform.of(-5, -7, 0, 0, 0, 0.14f)); - - h.addChild("jaw_plate", ModelPartBuilder.create() - .uv(104, 104).cuboid(-5, 0, -5.5f, 10, 2, 1), - ModelTransform.pivot(0, 0, 0)); - - t.addChild("shard_left", ModelPartBuilder.create() - .uv(116, 0).cuboid(-2, -3, -1, 4, 7, 2), - ModelTransform.of(-10, -3, 1, 0, 0, -0.25f)); - - t.addChild("shard_right", ModelPartBuilder.create() - .uv(116, 10).cuboid(-2, -3, -1, 4, 7, 2) - .uv(120, 20).cuboid(-1, -7, -1, 2, 5, 2), - ModelTransform.of(10, -3, 2, 0, 0, 0.25f)); - - t.addChild("chest_spine", ModelPartBuilder.create() - .uv(100, 26).cuboid(-2, -8, 4, 4, 14, 3) - .uv(114, 26).cuboid(-1, -11, 5, 2, 4, 2), - ModelTransform.pivot(0, 0, 0)); - - t.addChild("collar_left", ModelPartBuilder.create() - .uv(92, 70).cuboid(-1, -2, -3, 7, 4, 6) - .uv(106, 70).cuboid(4, -4, -2, 5, 2, 4), - ModelTransform.pivot(6, -9, 0)); - - t.addChild("collar_right", ModelPartBuilder.create() - .uv(92, 70).cuboid(-6, -2, -3, 7, 4, 6) - .uv(106, 70).cuboid(-9, -4, -2, 5, 2, 4), - ModelTransform.pivot(-6, -9, 0)); - - r.addChild("left_foot", ModelPartBuilder.create() - .uv(0, 112).cuboid(-4.5f, -1, -6, 9, 4, 12) - .uv(22, 112).cuboid(-3.5f, -2, -4, 7, 2, 7), - ModelTransform.pivot(4, 31, 0)); - - r.addChild("right_foot", ModelPartBuilder.create() - .uv(0, 112).cuboid(-4.5f, -1, -6, 9, 4, 12) - .uv(22, 112).cuboid(-3.5f, -2, -4, 7, 2, 7), - ModelTransform.pivot(-4, 31, 0)); - - t.addChild("back_shard_left", ModelPartBuilder.create() - .uv(32, 112).cuboid(-1, -7, 0, 2, 10, 4) - .uv(38, 112).cuboid(-2, -2, 1, 3, 5, 3), - ModelTransform.of(-5, -6, 5, 0.08f, -0.08f, -0.18f)); - - t.addChild("back_shard_right", ModelPartBuilder.create() - .uv(44, 112).cuboid(-1, -9, 0, 2, 12, 4) - .uv(50, 112).cuboid(-1, -3, 1, 3, 5, 3), - ModelTransform.of(5, -5, 5, -0.05f, 0.07f, 0.15f)); - - h.addChild("head_back", ModelPartBuilder.create() - .uv(56, 112).cuboid(-4, -5, 4, 8, 6, 3) - .uv(68, 112).cuboid(-2, -8, 5, 4, 4, 3), - ModelTransform.pivot(0, 0, 0)); - - return TexturedModelData.of(data, 128, 128); - } - - @Override - public ModelPart getPart() { - return root; - } - - @Override - public void setAngles(NullWardenEntity entity, float limbAngle, float limbDistance, - float animationProgress, float headYaw, float headPitch) { - root.traverse().forEach(ModelPart::resetTransform); - - head.yaw = headYaw * ((float) Math.PI / 180F) * 0.55f; - head.pitch = headPitch * ((float) Math.PI / 180F) * 0.45f; - - float walk = MathHelper.cos(limbAngle * 0.65f) * limbDistance * 0.55f; - leftLeg.pitch = walk; - rightLeg.pitch = -walk; - leftArm.pitch = -walk * 0.45f; - rightArm.pitch = walk * 0.45f; - forearmLeft.pitch = walk * 0.18f; - forearmRight.pitch = -walk * 0.18f; - - float healthRatio = entity.getMaxHealth() <= 0.0f ? 1.0f : entity.getHealth() / entity.getMaxHealth(); - float phase = healthRatio > 0.75f ? 1.0f - : healthRatio > 0.50f ? 2.0f - : healthRatio > 0.20f ? 3.0f - : 4.0f; - - float pulseSpeed = 0.075f + phase * 0.012f; - float pulse = MathHelper.sin(animationProgress * pulseSpeed); - float fastPulse = MathHelper.sin(animationProgress * (0.16f + phase * 0.02f)); - - float hover = MathHelper.sin(animationProgress * 0.055f) * 0.10f; - torso.pivotY += hover; - leftHip.pivotY += hover * 0.35f; - rightHip.pivotY += hover * 0.35f; - - leftHorn.roll = MathHelper.sin(animationProgress * 0.045f) * (0.07f + phase * 0.012f); - rightHorn.roll = -leftHorn.roll; - shoulderLeft.roll = MathHelper.sin(animationProgress * 0.050f) * 0.035f; - shoulderRight.roll = -shoulderLeft.roll; - - forearmLeft.roll = pulse * 0.025f; - forearmRight.roll = -pulse * 0.025f; - - waist.yaw = MathHelper.sin(animationProgress * 0.032f) * 0.045f; - mantle.pitch = MathHelper.sin(animationProgress * 0.050f) * 0.045f; - chestPlate.pitch = pulse * 0.018f; - ribLeft.pitch = -pulse * 0.025f; - ribRight.pitch = pulse * 0.025f; - jaw.pitch = MathHelper.sin(animationProgress * 0.095f) * 0.022f; - eyeSlit.yaw = fastPulse * 0.012f; - eyeSlit.pitch = pulse * 0.008f; - - crown.roll = MathHelper.sin(animationProgress * 0.047f) * 0.035f; - crown.pitch = fastPulse * 0.018f; - - backSpine.yaw = MathHelper.sin(animationProgress * 0.040f) * 0.045f; - spineTip.yaw = -backSpine.yaw * 1.5f; - - shardLeft.pitch = MathHelper.sin(animationProgress * 0.060f) * 0.18f; - shardLeft.roll += MathHelper.sin(animationProgress * 0.041f) * 0.06f; - shardRight.pitch = -shardLeft.pitch; - shardRight.roll += MathHelper.sin(animationProgress * 0.041f + 1.5f) * 0.06f; - - chestSpine.yaw = MathHelper.sin(animationProgress * 0.035f) * 0.035f; - collarLeft.roll = MathHelper.sin(animationProgress * 0.050f) * 0.025f; - collarRight.roll = -collarLeft.roll; - kneeLeft.pitch = leftLeg.pitch * 0.22f; - kneeRight.pitch = rightLeg.pitch * 0.22f; - ankleLeft.pitch = leftLeg.pitch * -0.12f; - ankleRight.pitch = rightLeg.pitch * -0.12f; - - leftFoot.pitch = leftLeg.pitch * -0.18f; - rightFoot.pitch = rightLeg.pitch * -0.18f; - leftFoot.yaw = leftLeg.pitch * 0.035f; - rightFoot.yaw = rightLeg.pitch * -0.035f; - - backShardLeft.pitch = MathHelper.sin(animationProgress * 0.043f + 0.8f) * 0.10f; - backShardLeft.roll += MathHelper.sin(animationProgress * 0.061f) * 0.035f; - backShardRight.pitch = MathHelper.sin(animationProgress * 0.051f + 1.4f) * 0.13f; - backShardRight.roll += MathHelper.sin(animationProgress * 0.057f + 1.1f) * 0.045f; - headBack.pitch = MathHelper.sin(animationProgress * 0.048f) * 0.025f; - - core.pitch = pulse * (0.055f + phase * 0.012f); - coreRing.yaw = animationProgress * (0.012f + phase * 0.002f); - coreRing.pitch = pulse * 0.06f; - leftCrownShard.pitch = fastPulse * 0.08f; - leftCrownShard.roll += MathHelper.sin(animationProgress * 0.071f) * 0.045f; - rightCrownShard.pitch = -fastPulse * 0.07f; - rightCrownShard.roll += MathHelper.sin(animationProgress * 0.064f + 1.2f) * 0.04f; - jawPlate.pitch = pulse * 0.025f; - mantleShard.roll += MathHelper.sin(animationProgress * 0.058f) * 0.08f; - core.yaw = fastPulse * 0.035f; - core.pivotZ = -1.0f - Math.max(0.0f, pulse) * 0.16f; - eyeSlit.pivotZ = -0.02f - Math.max(0.0f, fastPulse) * 0.03f; - - // The server drives the encounter state through the entity data tracker. - // These poses are intentionally readable at a glance, so every attack has - // a distinct silhouette before its hit resolves. - int attack = entity.getVisualAttack(); - float charge = entity.getVisualAttackProgress(); - float impact = 1.0f - charge; - float phaseHeat = (phase - 1.0f) / 3.0f; - - if (phase >= 2.0f) { - torso.pitch -= phaseHeat * 0.045f; - shoulderLeft.pitch += phaseHeat * 0.035f; - shoulderRight.pitch += phaseHeat * 0.035f; - } - - // Phase 4 is visually unstable even when the Warden is not attacking. - // Armor pieces drift independently, making the final phase feel like a - // creature held together by the Null core rather than a simple speed boost. - if (phase >= 4.0f) { - float fracture = MathHelper.sin(animationProgress * 0.11f); - float fractureFast = MathHelper.sin(animationProgress * 0.19f + 1.7f); - shardLeft.pivotX += fracture * 0.35f; - shardLeft.pivotY += fractureFast * 0.28f; - shardRight.pivotX -= fractureFast * 0.30f; - shardRight.pivotY += fracture * 0.25f; - collarLeft.pivotY += fracture * 0.12f; - collarRight.pivotY -= fractureFast * 0.12f; - crown.yaw += fracture * 0.045f; - leftCrownShard.pivotX += fracture * 0.16f; - rightCrownShard.pivotX -= fractureFast * 0.14f; - mantleShard.pivotY += fractureFast * 0.20f; - chestSpine.roll += fractureFast * 0.035f; - eyeSlit.roll += fracture * 0.018f; - backShardLeft.pivotX += fractureFast * 0.13f; - backShardLeft.pivotY += fracture * 0.16f; - backShardRight.pivotX -= fracture * 0.11f; - backShardRight.pivotY += fractureFast * 0.18f; - headBack.pivotZ += fracture * 0.12f; - } - - switch (attack) { - case 1 -> { // VOID CLEAVE: huge one-sided wind-up. - torso.yaw += -0.16f * charge; - shoulderLeft.pitch += -1.05f * charge; - forearmLeft.pitch += -0.55f * charge; - leftArm.roll += 0.18f * charge; - shoulderRight.pitch += 0.18f * charge; - collarLeft.roll += 0.12f * charge; - collarRight.roll -= 0.12f * charge; - head.yaw += -0.10f * charge; - if (impact > 0.72f) { - torso.yaw += 0.22f * impact; - shoulderLeft.pitch += 1.35f * impact; - forearmLeft.pitch += 0.85f * impact; - } - } - case 2 -> { // SCULK RING: body opens around the core. - shoulderLeft.pitch -= 0.55f * charge; - shoulderRight.pitch -= 0.55f * charge; - forearmLeft.roll -= 0.30f * charge; - forearmRight.roll += 0.30f * charge; - chestPlate.pitch -= 0.10f * charge; - ribLeft.pitch -= 0.08f * charge; - ribRight.pitch -= 0.08f * charge; - core.pitch -= 0.18f * charge; - core.yaw += 0.08f * fastPulse; - } - case 3 -> { // VOID RAIN: arms and crown pull energy overhead. - shoulderLeft.pitch -= 0.80f * charge; - shoulderRight.pitch -= 0.80f * charge; - forearmLeft.pitch -= 0.55f * charge; - forearmRight.pitch -= 0.55f * charge; - head.pitch -= 0.12f * charge; - crown.pitch -= 0.18f * charge; - crown.roll += 0.08f * fastPulse; - core.pivotZ -= 0.35f * charge; - chestSpine.pitch -= 0.16f * charge; - } - case 4 -> { // NULL DASH: compress, then snap forward. - torso.pitch += 0.24f * charge; - torso.yaw += 0.10f * MathHelper.sin(animationProgress * 0.2f) * charge; - leftLeg.pitch -= 0.22f * charge; - rightLeg.pitch -= 0.42f * charge; - leftArm.pitch += 0.35f * charge; - rightArm.pitch += 0.35f * charge; - if (impact > 0.65f) { - torso.pitch -= 0.42f * impact; - head.pitch -= 0.18f * impact; - } - } - case 5 -> { // GRAVITY WELL: arms pull inward around a bright core. - shoulderLeft.pitch -= 0.45f * charge; - shoulderRight.pitch -= 0.45f * charge; - forearmLeft.pitch += 0.55f * charge; - forearmRight.pitch += 0.55f * charge; - forearmLeft.roll += 0.18f * charge; - forearmRight.roll -= 0.18f * charge; - core.yaw += 0.28f * charge; - core.pivotZ -= 0.22f * charge; - } - case 6 -> { // REALITY TEAR: asymmetric dimensional distortion. - torso.yaw += 0.22f * charge; - shoulderLeft.roll += 0.32f * charge; - shoulderRight.roll -= 0.12f * charge; - head.roll -= 0.18f * charge; - eyeSlit.roll += 0.14f * charge; - shardLeft.roll += 0.30f * charge; - shardRight.roll -= 0.24f * charge; - spineTip.yaw -= 0.24f * charge; - chestSpine.yaw += 0.18f * charge; - collarLeft.roll += 0.20f * charge; - collarRight.roll -= 0.10f * charge; - } - case 7 -> { // COLLAPSE: the entire silhouette compresses before release. - torso.pitch += 0.18f * charge; - torso.pivotY += 0.45f * charge; - leftLeg.pitch -= 0.28f * charge; - rightLeg.pitch -= 0.28f * charge; - shoulderLeft.pitch += 0.32f * charge; - shoulderRight.pitch += 0.32f * charge; - core.pitch += 0.20f * charge; - crown.pitch += 0.15f * charge; - if (impact > 0.55f) { - torso.pitch -= 0.25f * impact; - shoulderLeft.pitch -= 0.55f * impact; - shoulderRight.pitch -= 0.55f * impact; - core.pitch -= 0.32f * impact; - } - } - default -> { - if (entity.isVisualDefeated()) { - torso.pitch += 0.18f; - head.pitch += 0.12f; - leftArm.pitch += 0.20f; - rightArm.pitch += 0.20f; - core.pivotZ -= 0.18f; - } - } - } - - if (entity.hurtTime > 0) { - float hit = MathHelper.sin(entity.hurtTime * 0.65f) * 0.08f; - torso.roll += hit; - head.roll -= hit * 0.6f; - } - } -} diff --git a/src/client/java/dev/qynl/myfirstmod/client/render/NullWardenGlowFeatureRenderer.java b/src/client/java/dev/qynl/myfirstmod/client/render/NullWardenGlowFeatureRenderer.java deleted file mode 100644 index aa6a463..0000000 --- a/src/client/java/dev/qynl/myfirstmod/client/render/NullWardenGlowFeatureRenderer.java +++ /dev/null @@ -1,69 +0,0 @@ -package dev.qynl.myfirstmod.client.render; - -import dev.qynl.myfirstmod.boss.NullWardenEntity; -import dev.qynl.myfirstmod.client.NullWardenTexture; -import dev.qynl.myfirstmod.client.model.NullWardenModel; -import net.minecraft.client.render.OverlayTexture; -import net.minecraft.client.render.RenderLayer; -import net.minecraft.client.render.VertexConsumerProvider; -import net.minecraft.client.render.entity.feature.FeatureRenderer; -import net.minecraft.client.render.entity.feature.FeatureRendererContext; -import net.minecraft.client.util.math.MatrixStack; - -public class NullWardenGlowFeatureRenderer extends FeatureRenderer { - public NullWardenGlowFeatureRenderer(FeatureRendererContext context) { - super(context); - } - - @Override - public void render(MatrixStack matrices, VertexConsumerProvider vertexConsumers, int light, - NullWardenEntity entity, float limbAngle, float limbDistance, - float tickDelta, float animationProgress, float headYaw, float headPitch) { - if (NullWardenTexture.GLOW_TEXTURE == null) { - return; - } - - int phase = entity.getVisualPhase(); - int attack = entity.getVisualAttack(); - float attackProgress = entity.getVisualAttackProgress(); - - // The core is always alive, but the emissive layer reacts to encounter - // state instead of sitting at a constant brightness. - float pulse = 0.82f + 0.18f * - (float) Math.sin((entity.age + animationProgress) * (0.14f + phase * 0.012f)); - - float attackSurge = attack == 0 - ? 0.0f - : 0.10f + 0.18f * (1.0f - Math.abs(attackProgress - 0.5f) * 2.0f); - - float defeatFade = entity.isVisualDefeated() ? 0.58f : 1.0f; - float intensity = Math.min(1.0f, Math.max(0.45f, (pulse + attackSurge) * defeatFade)); - - int rgb = switch (phase) { - case 2 -> 0xE66CFF; - case 3 -> 0xB94CFF; - case 4 -> 0xFFFFFF; - default -> 0x62F5FF; - }; - - int r = Math.min(255, Math.max(0, Math.round(((rgb >> 16) & 255) * intensity))); - int g = Math.min(255, Math.max(0, Math.round(((rgb >> 8) & 255) * intensity))); - int b = Math.min(255, Math.max(0, Math.round((rgb & 255) * intensity))); - - // Keep the emissive pass visually integrated with the armor. The texture - // controls exactly which UV islands emit; this tint only changes their - // encounter color and brightness. - int alpha = entity.isVisualDefeated() ? 190 : 255; - int color = (alpha << 24) | (r << 16) | (g << 8) | b; - - getContextModel().getPart().render( - matrices, - vertexConsumers.getBuffer( - RenderLayer.getEntityTranslucentEmissiveNoOutline( - NullWardenTexture.GLOW_TEXTURE)), - 0xF000F0, - OverlayTexture.DEFAULT_UV, - color - ); - } -} diff --git a/src/client/java/dev/qynl/myfirstmod/client/render/NullWardenRenderer.java b/src/client/java/dev/qynl/myfirstmod/client/render/NullWardenRenderer.java deleted file mode 100644 index f8c8f6c..0000000 --- a/src/client/java/dev/qynl/myfirstmod/client/render/NullWardenRenderer.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.qynl.myfirstmod.client.render; - -import dev.qynl.myfirstmod.boss.NullWardenEntity; -import dev.qynl.myfirstmod.client.NullWardenTexture; -import dev.qynl.myfirstmod.client.model.NullWardenModel; -import net.minecraft.client.render.entity.EntityRendererFactory; -import net.minecraft.client.render.entity.MobEntityRenderer; -import net.minecraft.client.render.entity.model.EntityModelLayer; -import net.minecraft.util.Identifier; - -public class NullWardenRenderer extends MobEntityRenderer { - public static final EntityModelLayer MODEL_LAYER = new EntityModelLayer( - Identifier.of("myfirstmod", "null_warden"), "main"); - - public NullWardenRenderer(EntityRendererFactory.Context context) { - super(context, new NullWardenModel(context.getPart(MODEL_LAYER)), 1.30f); - this.addFeature(new NullWardenGlowFeatureRenderer(this)); - } - - @Override - public Identifier getTexture(NullWardenEntity entity) { - return NullWardenTexture.TEXTURE; - } -} diff --git a/src/client/resources/myfirstmod.mixins.json b/src/client/resources/myfirstmod.mixins.json deleted file mode 100644 index ba46d5b..0000000 --- a/src/client/resources/myfirstmod.mixins.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "required": true, - "package": "dev.qynl.myfirstmod.mixin", - "compatibilityLevel": "JAVA_21", - "client": [ - "TitleScreenMixin" - ], - "injectors": { - "defaultRequire": 1 - } -} diff --git a/src/main/java/dev/qynl/myfirstmod/MyFirstMod.java b/src/main/java/dev/qynl/myfirstmod/MyFirstMod.java deleted file mode 100644 index 0e3f69e..0000000 --- a/src/main/java/dev/qynl/myfirstmod/MyFirstMod.java +++ /dev/null @@ -1,37 +0,0 @@ -package dev.qynl.myfirstmod; - -import dev.qynl.myfirstmod.block.ModBlocks; -import dev.qynl.myfirstmod.boss.ModEntities; -import dev.qynl.myfirstmod.item.ModItems; -import dev.qynl.myfirstmod.item.NullbladeItem; -import dev.qynl.myfirstmod.portal.VoidPortalManager; -import net.fabricmc.api.ModInitializer; -import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; -import net.fabricmc.fabric.api.event.player.UseBlockCallback; -import net.minecraft.block.Blocks; -import net.minecraft.item.Items; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.util.ActionResult; - -public class MyFirstMod implements ModInitializer { - public static final String MOD_ID = "myfirstmod"; - - @Override - public void onInitialize() { - ModBlocks.register(); - ModItems.register(); - ModEntities.register(); - - UseBlockCallback.EVENT.register((player, world, hand, hit) -> { - if (world.isClient || hand != net.minecraft.util.Hand.MAIN_HAND) return ActionResult.PASS; - if (!(player instanceof ServerPlayerEntity serverPlayer)) return ActionResult.PASS; - if (!serverPlayer.getStackInHand(hand).isOf(Items.FLINT_AND_STEEL)) return ActionResult.PASS; - if (world.getBlockState(hit.getBlockPos()).isOf(Blocks.REINFORCED_DEEPSLATE) - && VoidPortalManager.tryIgnite(serverPlayer, hit.getBlockPos())) return ActionResult.SUCCESS; - return ActionResult.PASS; - }); - - ServerTickEvents.END_SERVER_TICK.register(VoidPortalManager::tick); - ServerTickEvents.END_SERVER_TICK.register(NullbladeItem::tick); - } -} diff --git a/src/main/java/dev/qynl/myfirstmod/block/ModBlocks.java b/src/main/java/dev/qynl/myfirstmod/block/ModBlocks.java deleted file mode 100644 index 86c42ce..0000000 --- a/src/main/java/dev/qynl/myfirstmod/block/ModBlocks.java +++ /dev/null @@ -1,19 +0,0 @@ -package dev.qynl.myfirstmod.block; - -import dev.qynl.myfirstmod.MyFirstMod; -import net.minecraft.block.AbstractBlock; -import net.minecraft.block.Block; -import net.minecraft.block.Blocks; -import net.minecraft.registry.Registries; -import net.minecraft.registry.Registry; -import net.minecraft.util.Identifier; - -public final class ModBlocks { - public static final Block VOID_PORTAL = Registry.register( - Registries.BLOCK, - Identifier.of(MyFirstMod.MOD_ID, "void_portal"), - new VoidPortalBlock(AbstractBlock.Settings.copy(Blocks.NETHER_PORTAL).noCollision().nonOpaque().strength(-1.0F).luminance(state -> 11)) - ); - - public static void register() {} -} diff --git a/src/main/java/dev/qynl/myfirstmod/block/VoidPortalBlock.java b/src/main/java/dev/qynl/myfirstmod/block/VoidPortalBlock.java deleted file mode 100644 index 2df85a9..0000000 --- a/src/main/java/dev/qynl/myfirstmod/block/VoidPortalBlock.java +++ /dev/null @@ -1,20 +0,0 @@ -package dev.qynl.myfirstmod.block; - -import dev.qynl.myfirstmod.portal.VoidPortalManager; -import net.minecraft.block.Block; -import net.minecraft.block.BlockState; -import net.minecraft.entity.Entity; -import net.minecraft.util.math.BlockPos; -import net.minecraft.world.World; - -public class VoidPortalBlock extends Block { - public VoidPortalBlock(Settings settings) { - super(settings); - } - - @Override - public void onEntityCollision(BlockState state, World world, BlockPos pos, Entity entity) { - if (!world.isClient) VoidPortalManager.tryEnter(entity); - super.onEntityCollision(state, world, pos, entity); - } -} diff --git a/src/main/java/dev/qynl/myfirstmod/boss/ModEntities.java b/src/main/java/dev/qynl/myfirstmod/boss/ModEntities.java deleted file mode 100644 index 434bb5c..0000000 --- a/src/main/java/dev/qynl/myfirstmod/boss/ModEntities.java +++ /dev/null @@ -1,24 +0,0 @@ -package dev.qynl.myfirstmod.boss; - -import dev.qynl.myfirstmod.MyFirstMod; -import net.minecraft.entity.EntityType; -import net.minecraft.entity.SpawnGroup; -import net.minecraft.registry.Registries; -import net.minecraft.registry.Registry; -import net.minecraft.util.Identifier; - -public final class ModEntities { - private ModEntities() {} - - public static final EntityType NULL_WARDEN = Registry.register( - Registries.ENTITY_TYPE, - Identifier.of(MyFirstMod.MOD_ID, "null_warden"), - EntityType.Builder.create(NullWardenEntity::new, SpawnGroup.MONSTER) - .dimensions(1.0f, 3.5f) - .maxTrackingRange(64) - .trackingTickInterval(1) - .build("myfirstmod:null_warden") - ); - - public static void register() {} -} diff --git a/src/main/java/dev/qynl/myfirstmod/boss/NullWardenEntity.java b/src/main/java/dev/qynl/myfirstmod/boss/NullWardenEntity.java deleted file mode 100644 index 320dc38..0000000 --- a/src/main/java/dev/qynl/myfirstmod/boss/NullWardenEntity.java +++ /dev/null @@ -1,55 +0,0 @@ -package dev.qynl.myfirstmod.boss; - -import net.minecraft.entity.EntityType; -import net.minecraft.entity.data.DataTracker; -import net.minecraft.entity.data.TrackedData; -import net.minecraft.entity.data.TrackedDataHandlerRegistry; -import net.minecraft.entity.mob.WardenEntity; -import net.minecraft.world.World; - -public class NullWardenEntity extends WardenEntity { - private static final TrackedData PHASE = - DataTracker.registerData(NullWardenEntity.class, TrackedDataHandlerRegistry.BYTE); - private static final TrackedData ATTACK = - DataTracker.registerData(NullWardenEntity.class, TrackedDataHandlerRegistry.BYTE); - private static final TrackedData ATTACK_PROGRESS = - DataTracker.registerData(NullWardenEntity.class, TrackedDataHandlerRegistry.BYTE); - private static final TrackedData DEFEATED = - DataTracker.registerData(NullWardenEntity.class, TrackedDataHandlerRegistry.BOOLEAN); - - public NullWardenEntity(EntityType type, World world) { - super(type, world); - } - - @Override - protected void initDataTracker(DataTracker.Builder builder) { - super.initDataTracker(builder); - builder.add(PHASE, (byte) 1); - builder.add(ATTACK, (byte) 0); - builder.add(ATTACK_PROGRESS, (byte) 0); - builder.add(DEFEATED, false); - } - - public void setVisualState(int phase, int attack, int attackProgress, boolean defeated) { - getDataTracker().set(PHASE, (byte) Math.max(1, Math.min(4, phase))); - getDataTracker().set(ATTACK, (byte) Math.max(0, Math.min(7, attack))); - getDataTracker().set(ATTACK_PROGRESS, (byte) Math.max(0, Math.min(127, attackProgress))); - getDataTracker().set(DEFEATED, defeated); - } - - public int getVisualPhase() { - return getDataTracker().get(PHASE); - } - - public int getVisualAttack() { - return getDataTracker().get(ATTACK); - } - - public float getVisualAttackProgress() { - return getDataTracker().get(ATTACK_PROGRESS) / 100.0f; - } - - public boolean isVisualDefeated() { - return getDataTracker().get(DEFEATED); - } -} diff --git a/src/main/java/dev/qynl/myfirstmod/boss/NullWardenManager.java b/src/main/java/dev/qynl/myfirstmod/boss/NullWardenManager.java deleted file mode 100644 index 279a89c..0000000 --- a/src/main/java/dev/qynl/myfirstmod/boss/NullWardenManager.java +++ /dev/null @@ -1,1630 +0,0 @@ -package dev.qynl.myfirstmod.boss; - -import dev.qynl.myfirstmod.block.ModBlocks; -import dev.qynl.myfirstmod.item.ModItems; -import net.minecraft.block.Block; -import net.minecraft.block.Blocks; -import net.minecraft.entity.Entity; -import net.minecraft.entity.EntityType; -import net.minecraft.entity.attribute.EntityAttributes; -import net.minecraft.entity.boss.ServerBossBar; -import net.minecraft.entity.mob.WardenEntity; -import net.minecraft.item.ItemStack; -import net.minecraft.particle.DustParticleEffect; -import net.minecraft.particle.ParticleEffect; -import net.minecraft.particle.ParticleTypes; -import net.minecraft.registry.RegistryKey; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.server.world.ServerWorld; -import net.minecraft.sound.SoundCategory; -import net.minecraft.sound.SoundEvents; -import net.minecraft.text.Text; -import net.minecraft.util.math.BlockPos; -import net.minecraft.util.math.Vec3d; -import net.minecraft.world.World; -import org.joml.Vector3f; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - -public final class NullWardenManager { - private static final Map, ArenaState> ARENAS = new HashMap<>(); - private static final BlockPos CENTER = new BlockPos(0, 80, 0); - private static final int ARENA_RADIUS = 22; - private static final int HARD_BOUNDARY = 34; - private static final BlockPos RETURN_PORTAL = new BlockPos(13, 81, 13); - private static final BlockPos[] PYLONS = { - new BlockPos(15, 81, 0), new BlockPos(0, 81, 15), - new BlockPos(-15, 81, 0), new BlockPos(0, 81, -15) - }; - - private NullWardenManager() {} - - private static NullWardenState saved(ServerWorld world) { - return world.getPersistentStateManager().getOrCreate( - NullWardenState.type(), NullWardenState.ID); - } - - private static ArenaState getArena(ServerWorld world) { - return ARENAS.computeIfAbsent(world.getRegistryKey(), key -> load(world)); - } - - private static ArenaState load(ServerWorld world) { - NullWardenState data = saved(world); - ArenaState a = new ArenaState(); - a.participants.addAll(data.participants); - a.eligiblePlayers.addAll(data.eligiblePlayers); - a.rewardedPlayers.addAll(data.rewardedPlayers); - a.echoes.addAll(data.echoes); - a.defeated = data.defeated; - a.phase = Math.max(1, data.phase); - a.activePylons = data.activePylons; - a.cleansedPylons = data.cleansedPylons; - a.pylonProgress = data.pylonProgress.clone(); - a.bossUuid = data.bossUuid; - a.returnPortalBuilt = data.returnPortalBuilt; - - if (a.bossUuid != null) { - Entity entity = world.getEntity(a.bossUuid); - if (entity instanceof NullWardenEntity w && w.isAlive()) { - a.boss = w; - a.bar = createBar(); - a.bar.setPercent(w.getHealth() / w.getMaxHealth()); - for (UUID id : a.participants) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(id); - if (p != null && p.getServerWorld() == world) a.bar.addPlayer(p); - } - return a; - } - } - - if (a.defeated) { - if (a.returnPortalBuilt) buildReturnPortal(world, a); - return a; - } - - cleanup(world, a); - clearSaved(world); - return new ArenaState(); - } - - private static ServerBossBar createBar() { - ServerBossBar bar = new ServerBossBar( - Text.literal("THE NULL WARDEN"), - ServerBossBar.Color.PURPLE, - ServerBossBar.Style.NOTCHED_20); - bar.setDarkenSky(true); - bar.setThickenFog(true); - return bar; - } - - private static void persist(ServerWorld world, ArenaState a) { - NullWardenState data = saved(world); - data.bossUuid = a.boss == null ? a.bossUuid : a.boss.getUuid(); - data.participants.clear(); - data.participants.addAll(a.participants); - data.eligiblePlayers.clear(); - data.eligiblePlayers.addAll(a.eligiblePlayers); - data.rewardedPlayers.clear(); - data.rewardedPlayers.addAll(a.rewardedPlayers); - data.echoes.clear(); - data.echoes.addAll(a.echoes); - data.defeated = a.defeated; - data.rewarded = a.rewardedPlayers.containsAll(a.eligiblePlayers); - data.returnPortalBuilt = a.returnPortalBuilt; - data.phase = a.phase; - data.activePylons = a.activePylons; - data.cleansedPylons = a.cleansedPylons; - data.pylonProgress = a.pylonProgress.clone(); - data.dirty(); - } - - private static void clearSaved(ServerWorld world) { - NullWardenState data = saved(world); - data.bossUuid = null; - data.participants.clear(); - data.eligiblePlayers.clear(); - data.rewardedPlayers.clear(); - data.echoes.clear(); - data.defeated = false; - data.rewarded = false; - data.returnPortalBuilt = false; - data.phase = 1; - data.activePylons = 0; - data.cleansedPylons = 0; - data.pylonProgress = new int[4]; - data.dirty(); - } - - public static void enterArena(ServerWorld world, ServerPlayerEntity player) { - ArenaState a = getArena(world); - a.participants.add(player.getUuid()); - - if (a.defeated) { - rewardIfEligible(player, a); - buildReturnPortal(world, a); - persist(world, a); - return; - } - - if (a.boss != null && a.boss.isAlive()) { - if (a.bar != null) a.bar.addPlayer(player); - player.sendMessage(Text.literal("The Null Warden is already awake."), true); - persist(world, a); - return; - } - - start(world, player, a); - } - - public static void tick(MinecraftServer server) { - ARENAS.entrySet().removeIf(e -> server.getWorld(e.getKey()) == null); - for (Map.Entry, ArenaState> entry : ARENAS.entrySet()) { - ServerWorld world = server.getWorld(entry.getKey()); - if (world != null) tickArena(server, world, entry.getValue()); - } - } - - private static void start(ServerWorld world, ServerPlayerEntity player, ArenaState a) { - buildArena(world); - a.boss = ModEntities.NULL_WARDEN.create(world); - if (a.boss == null) { - a.participants.clear(); - return; - } - - var hp = a.boss.getAttributeInstance(EntityAttributes.GENERIC_MAX_HEALTH); - var damage = a.boss.getAttributeInstance(EntityAttributes.GENERIC_ATTACK_DAMAGE); - var speed = a.boss.getAttributeInstance(EntityAttributes.GENERIC_MOVEMENT_SPEED); - if (hp != null) hp.setBaseValue(500); - if (damage != null) damage.setBaseValue(18); - if (speed != null) speed.setBaseValue(.38); - - a.boss.setHealth(500); - a.boss.refreshPositionAndAngles(.5, 81, .5, 180, 0); - a.boss.setCustomName(Text.literal("THE NULL WARDEN")); - a.boss.setCustomNameVisible(false); - a.boss.setAiDisabled(true); - a.boss.setInvulnerable(true); - a.boss.setVisualState(1, 0, 0, false); - world.spawnEntity(a.boss); - - a.bossUuid = a.boss.getUuid(); - a.bar = createBar(); - for (UUID id : a.participants) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(id); - if (p != null && p.getServerWorld() == world) a.bar.addPlayer(p); - } - - a.ticks = 0; - a.intro = 100; - a.phase = 1; - a.activePylons = 0; - a.cleansedPylons = 0; - a.pylonProgress = new int[4]; - a.attack = Attack.NONE; - a.attackTarget = null; - a.attackStep = 0; - a.nextAttackTick = 40; - a.idleTicks = 0; - a.victoryTicks = 0; - a.targetRotation = 0; - a.recoveryTicks = 0; - a.defeated = false; - a.rewardedPlayers.clear(); - a.eligiblePlayers.clear(); - a.returnPortalBuilt = false; - a.joinTicks.clear(); - - world.playSound(null, CENTER, SoundEvents.ENTITY_WARDEN_EMERGE, - SoundCategory.HOSTILE, 5, .5f); - world.spawnParticles(ParticleTypes.SCULK_SOUL, .5, 82, .5, - 180, 4, 2, 4, .04); - player.sendMessage(Text.literal("THE NULL WARDEN"), false); - player.sendMessage(Text.literal("The city was never meant to open this door."), false); - persist(world, a); - } - - private static void tickArena(MinecraftServer server, ServerWorld world, ArenaState a) { - if (a.boss == null) { - if (a.defeated) rewardPending(server, world, a); - return; - } - - updatePlayers(server, world, a); - if (a.activeParticipants.isEmpty()) { - if (++a.idleTicks >= 600) reset(world, a); - return; - } - a.idleTicks = 0; - - if (a.defeated) { - rewardPending(server, world, a); - if (!a.returnPortalBuilt) buildReturnPortal(world, a); - - // Victory is a short aftermath, not an instant despawn. The arena - // visibly powers down while the defeated core collapses in stages. - a.victoryTicks++; - int v = a.victoryTicks; - a.boss.setAiDisabled(true); - a.boss.setInvulnerable(true); - a.boss.setVelocity(Vec3d.ZERO); - - if (v <= 80 && v % 5 == 0) { - double radius = 1.5 + v * 0.08; - ring(world, a.boss.getX(), a.boss.getY() + 0.1, a.boss.getZ(), - radius, ParticleTypes.REVERSE_PORTAL, 48); - world.spawnParticles(ParticleTypes.SCULK_SOUL, - a.boss.getX(), a.boss.getY() + 1.0, a.boss.getZ(), - 10, 1.0 + v * 0.015, 1.0, 1.0 + v * 0.015, .02); - } - if (v == 1) { - world.playSound(null, a.boss.getBlockPos(), SoundEvents.ENTITY_WARDEN_DEATH, - SoundCategory.HOSTILE, 3.5f, .7f); - } - if (v == 25 || v == 50) { - world.playSound(null, a.boss.getX(), a.boss.getY(), a.boss.getZ(), - SoundEvents.BLOCK_RESPAWN_ANCHOR_DEPLETE.value(), - SoundCategory.HOSTILE, 1.8f, .55f); - } - if (v == 70) { - world.playSound(null, CENTER, SoundEvents.BLOCK_SCULK_CATALYST_BLOOM, - SoundCategory.HOSTILE, 2.2f, .65f); - for (int i = 0; i < 4; i++) { - BlockPos p = PYLONS[i]; - world.spawnParticles(ParticleTypes.SCULK_SOUL, - p.getX() + .5, p.getY() + 11, p.getZ() + .5, - 30, .7, 1.2, .7, .02); - } - } - if (v >= 80 && v % 4 == 0) { - ring(world, a.boss.getX(), a.boss.getY() + .2, a.boss.getZ(), - Math.max(0.5, 8.0 - (v - 80) * .7), - ParticleTypes.END_ROD, 36); - } - - if (v >= 100) { - a.boss.discard(); - a.boss = null; - a.bossUuid = null; - persist(world, a); - } else if (v % 10 == 0) { - persist(world, a); - } - return; - } - - if (!a.boss.isAlive()) { - finish(world, a); - return; - } - - a.ticks++; - updateEligibility(a); - - tickRealmAtmosphere(world, a); - - // Ambient arena pulse: the room itself should feel alive between attacks. - if (a.ticks % 5 == 0) { - double pulse = 0.9 + Math.sin(a.ticks * 0.08) * 0.18; - world.spawnParticles(ParticleTypes.SCULK_SOUL, - CENTER.getX() + 0.5, 81.2, CENTER.getZ() + 0.5, - 5, 5.5 * pulse, 0.15, 5.5 * pulse, 0.008); - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - a.boss.getX(), a.boss.getY() + 1.0, a.boss.getZ(), - 3, 1.1, 1.4, 1.1, 0.015); - } - // Late phases visibly destabilize the arena. The effect is intentionally - // sparse: the room should feel like it is failing, not like it is full of fog. - if (a.phase >= 3 && a.ticks % (a.phase == 4 ? 8 : 14) == 0) { - double radius = a.phase == 4 - ? 6.0 + Math.sin(a.ticks * 0.08) * 2.0 - : 10.0 + Math.sin(a.ticks * 0.045) * 1.5; - ring(world, .5, 80.18, .5, radius, - a.phase == 4 ? ParticleTypes.REVERSE_PORTAL : ParticleTypes.SCULK_SOUL, - a.phase == 4 ? 56 : 40); - if (a.phase == 4 && a.ticks % 40 == 0) { - world.playSound(null, CENTER, SoundEvents.BLOCK_SCULK_SHRIEKER_SHRIEK, - SoundCategory.HOSTILE, .45f, .75f); - } - } - - // Ritual pathways continuously connect the boss to every active pylon. - if (a.ticks % 3 == 0 && a.activePylons != 0) { - for (int i = 0; i < 4; i++) { - if ((a.activePylons & (1 << i)) == 0) continue; - BlockPos p = PYLONS[i]; - double bx = a.boss.getX(), bz = a.boss.getZ(); - double px = p.getX() + .5, pz = p.getZ() + .5; - for (int n = 0; n < 10; n++) { - double k = (n + ((a.ticks % 15) / 15.0)) / 10.0; - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - bx + (px - bx) * k, 80.35, bz + (pz - bz) * k, - 1, .08, .02, .08, .005); - } - } - } - if (a.ticks % 20 == 0) { - for (int i = 0; i < 4; i++) { - if ((a.activePylons & (1 << i)) == 0) continue; - BlockPos p = PYLONS[i]; - world.spawnParticles(ParticleTypes.SCULK_SOUL, - p.getX() + 0.5, p.getY() + 13, p.getZ() + 0.5, - 10, 0.5, 1.2, 0.5, 0.015); - } - } - - if (a.intro > 0) { - a.intro--; - a.boss.setAiDisabled(true); - a.boss.setVisualState(1, 0, 0, false); - // Cinematic awakening: the arena contracts toward the boss in pulses. - double t = (100 - a.intro) / 100.0; - double radius = Math.max(1.0, 9.0 - t * 7.5); - ring(world, a.boss.getX(), a.boss.getY() + .08, a.boss.getZ(), - radius, ParticleTypes.REVERSE_PORTAL, 80); - if (a.intro % 5 == 0) { - world.spawnParticles(ParticleTypes.SCULK_SOUL, - a.boss.getX(), a.boss.getY() + 1.2, a.boss.getZ(), - 20, 2.0 + t, 1.5, 2.0 + t, .025); - world.spawnParticles(ParticleTypes.END_ROD, - a.boss.getX(), a.boss.getY() + 1.0, a.boss.getZ(), - 8, 1.0, .8, 1.0, .02); - } - a.boss.setInvulnerable(true); - a.boss.setVelocity(Vec3d.ZERO); - if (a.intro % 10 == 0) { - world.playSound(null, a.boss.getBlockPos(), SoundEvents.ENTITY_WARDEN_HEARTBEAT, - SoundCategory.HOSTILE, 2, .5f + a.intro / 250f); - world.spawnParticles(ParticleTypes.SCULK_SOUL, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), - 24, 1.3, 1.2, 1.3, .02); - } - if (a.intro == 0) { - a.boss.setInvulnerable(false); - a.boss.setAiDisabled(false); - world.playSound(null, a.boss.getBlockPos(), SoundEvents.ENTITY_WARDEN_ROAR, - SoundCategory.HOSTILE, 4, .55f); - } - return; - } - - a.boss.setAiDisabled(a.attack != Attack.NONE || a.exposeTicks > 0); - a.boss.setInvulnerable(a.activePylons != 0); - - if (a.exposeTicks > 0) { - a.exposeTicks--; - a.boss.setVelocity(Vec3d.ZERO); - a.boss.setVisualState(a.phase, 0, - Math.min(100, a.exposeTicks), false); - if (a.exposeTicks % 5 == 0) { - ring(world, a.boss.getX(), a.boss.getY() + .15, a.boss.getZ(), - 3.0 + (100 - a.exposeTicks) * .035, - ParticleTypes.END_ROD, 72); - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), - 12, 1.4, 1.0, 1.4, .025); - } - if (a.exposeTicks == 0) { - a.nextAttackTick = a.ticks + 30; - a.recoveryTicks = 30; - for (ServerPlayerEntity p : participants(world, a)) { - p.sendMessage(Text.literal("The Null Warden is re-forming."), true); - } - } - } else { - applyPhaseMovement(world, a); - } - - a.boss.setVisualState(a.phase, attackVisualId(a.attack), - a.attack == Attack.NONE ? 0 : (int) Math.min(100, - 100.0 * a.attackWindup / Math.max(1, a.attack.windup)), false); - - if (a.bar != null && a.attack != Attack.NONE && a.attackWindup % 5 == 0) { - a.bar.setName(Text.literal("THE NULL WARDEN // " + a.attack.name)); - } else if (a.bar != null && a.attack == Attack.NONE) { - a.bar.setName(Text.literal("THE NULL WARDEN // " + phaseName(a.phase))); - } - - float health = a.boss.getHealth() / a.boss.getMaxHealth(); - if (a.bar != null) a.bar.setPercent(Math.max(0, health)); - - int phase = health > .75f ? 1 : health > .50f ? 2 : health > .20f ? 3 : 4; - if (phase != a.phase) { - a.phase = phase; - a.attack = Attack.NONE; - a.attackTarget = null; - a.attackX = a.attackY = a.attackZ = 0; - a.attackWindup = 0; - a.activePylons = ((1 << phase) - 1) & ~a.cleansedPylons; - a.pylonProgress = new int[4]; - a.hazardTicks = 0; - a.hazardPattern = -1; - a.attackStep = 0; - phaseShift(world, a); - persist(world, a); - } - - tickPylons(world, a); - tickPylonPressure(world, a); - tickPhaseArena(world, a); - - if (a.exposeTicks > 0) { - if (a.ticks % 10 == 0) persist(world, a); - return; - } - - if (a.attack != Attack.NONE) { - a.attackWindup--; - // Telegraphs are visual warnings, not a particle flood. Updating every - // second tick keeps the signal crisp while cutting arena particle load. - if ((a.attackWindup & 1) == 0) telegraph(world, a); - if (a.attackWindup <= 0) { - resolveAttack(world, a); - a.attack = Attack.NONE; - a.attackTarget = null; - a.boss.setAiDisabled(false); - } - } else if (a.ticks >= a.nextAttackTick) { - beginAttack(world, a); - } - - // Short recovery windows are intentional: the boss cannot chain attacks - // indefinitely, giving players a reliable moment to reposition or cleanse. - if (a.attack == Attack.NONE && a.recoveryTicks > 0) { - a.recoveryTicks--; - a.boss.setAiDisabled(false); - } - - int echoInterval = switch (a.phase) { - case 2 -> 300; - case 3 -> 220; - default -> 160; - }; - if (a.phase >= 2 && a.ticks % echoInterval == 0) summonEcho(world, a); - if (a.ticks % 40 == 0) persist(world, a); - } - - private static void updatePlayers(MinecraftServer server, ServerWorld world, ArenaState a) { - a.activeParticipants.clear(); - for (UUID id : a.participants) { - ServerPlayerEntity p = server.getPlayerManager().getPlayer(id); - if (p != null && p.getServerWorld() == world && p.isAlive() - && p.squaredDistanceTo(.5, 81, .5) <= HARD_BOUNDARY * HARD_BOUNDARY) { - a.activeParticipants.add(id); - a.joinTicks.putIfAbsent(id, a.ticks); - if (a.bar != null) a.bar.addPlayer(p); - } else if (p != null && a.bar != null) { - a.bar.removePlayer(p); - } - } - } - - private static void updateEligibility(ArenaState a) { - for (UUID id : a.activeParticipants) { - int joined = a.joinTicks.getOrDefault(id, a.ticks); - if (a.ticks - joined >= 100) a.eligiblePlayers.add(id); - } - } - - private static void tickPylons(ServerWorld world, ArenaState a) { - if (a.activePylons == 0) return; - - for (int i = 0; i < 4; i++) { - if ((a.activePylons & (1 << i)) == 0) continue; - BlockPos p = PYLONS[i]; - boolean cleansing = false; - ServerPlayerEntity cleanser = null; - for (UUID id : a.activeParticipants) { - ServerPlayerEntity player = world.getServer().getPlayerManager().getPlayer(id); - if (player != null && player.isSneaking() - && player.squaredDistanceTo(p.getX() + .5, p.getY() + 1, p.getZ() + .5) <= 9) { - cleansing = true; - cleanser = player; - break; - } - } - - // Cleansing is deliberately exposed: the Warden keeps attacking while - // somebody works on a pylon. The objective is now a contested space, - // not a safe maintenance phase. - if (cleansing) { - a.pylonProgress[i]++; - if (a.pylonProgress[i] % 5 == 0 && cleanser != null) { - world.spawnParticles(ParticleTypes.SCULK_SOUL, - cleanser.getX(), cleanser.getY() + 1.0, cleanser.getZ(), - 8, .25, .35, .25, .01); - } - if (a.pylonProgress[i] % 10 == 0) { - cleanser.sendMessage(Text.literal("NULL PYLON // " + a.pylonProgress[i] + "/50"), true); - world.playSound(null, p, SoundEvents.BLOCK_SCULK_CATALYST_BLOOM, - SoundCategory.BLOCKS, .8f, 1.3f); - world.spawnParticles(ParticleTypes.SCULK_SOUL, - p.getX() + .5, p.getY() + 12, p.getZ() + .5, - 12, .35, .5, .35, .02); - } - if (a.pylonProgress[i] >= 50) { - a.activePylons &= ~(1 << i); - a.cleansedPylons |= (1 << i); - a.pylonProgress[i] = 0; - world.setBlockState(p.up(11), Blocks.AIR.getDefaultState()); - world.playSound(null, p, SoundEvents.BLOCK_SCULK_CATALYST_BLOOM, - SoundCategory.BLOCKS, 1.5f, .55f); - world.spawnParticles(ParticleTypes.EXPLOSION, - p.getX() + .5, p.getY() + 12, p.getZ() + .5, - 8, .6, 1.2, .6, .03); - for (ServerPlayerEntity player : participants(world, a)) { - player.sendMessage(Text.literal(a.activePylons == 0 - ? "The Null Warden is exposed." - : "Pylon cleansed. Keep moving."), true); - } - if (a.activePylons == 0) { - a.exposeTicks = 100; - a.attack = Attack.NONE; - a.attackTarget = null; - a.attackWindup = 0; - a.nextAttackTick = a.ticks + 100; - a.recoveryTicks = 100; - world.playSound(null, a.boss.getBlockPos(), - SoundEvents.ENTITY_WARDEN_HEARTBEAT, - SoundCategory.HOSTILE, 3.0f, 1.35f); - } - persist(world, a); - } - } else if (a.pylonProgress[i] > 0 && a.ticks % 10 == 0) { - a.pylonProgress[i] = Math.max(0, a.pylonProgress[i] - 2); - } - } - } - - /** - * Active pylons are deliberately dangerous to stand beside. Their pressure - * scales with the phase, turning cleansing into a real contested objective: - * players must commit to the pylon while managing a local hazard. - */ - private static void tickPylonPressure(ServerWorld world, ArenaState a) { - if (a.activePylons == 0) return; - - int interval = switch (a.phase) { - case 2 -> 45; - case 3 -> 35; - default -> 25; - }; - if (a.ticks % interval != 0) return; - - float damage = switch (a.phase) { - case 2 -> 5.0f; - case 3 -> 7.0f; - default -> 9.0f; - }; - double radius = switch (a.phase) { - case 2 -> 3.5; - case 3 -> 4.25; - default -> 5.0; - }; - - for (int i = 0; i < 4; i++) { - if ((a.activePylons & (1 << i)) == 0) continue; - - BlockPos p = PYLONS[i]; - world.spawnParticles(ParticleTypes.SCULK_SOUL, - p.getX() + .5, p.getY() + 12.2, p.getZ() + .5, - 16, .6, .7, .6, .025); - - for (ServerPlayerEntity player : activePlayers(world, a)) { - double distance = player.squaredDistanceTo( - p.getX() + .5, player.getY(), p.getZ() + .5); - if (distance <= radius * radius) { - player.damage(world.getDamageSources().mobAttack(a.boss), damage); - - if (a.phase >= 3) { - Vec3d pull = new Vec3d( - p.getX() + .5 - player.getX(), - 0, - p.getZ() + .5 - player.getZ()); - double length = Math.sqrt(pull.x * pull.x + pull.z * pull.z); - if (length > .1) { - double strength = a.phase == 3 ? .16 : .24; - player.addVelocity( - pull.x / length * strength, - .04, - pull.z / length * strength); - player.velocityModified = true; - } - } - } - } - - ring(world, p.getX() + .5, 81.2, p.getZ() + .5, - radius, ParticleTypes.REVERSE_PORTAL, 40); - } - } - - private static int attackVisualId(Attack attack) { - return switch (attack) { - case NONE -> 0; - case VOID_CLEAVE -> 1; - case SCULK_RING -> 2; - case VOID_RAIN -> 3; - case NULL_DASH -> 4; - case GRAVITY_WELL -> 5; - case REALITY_TEAR -> 6; - case COLLAPSE -> 7; - }; - } - - - /** - * Each late phase changes the floor pattern, not just the Warden's attack list. - * Hazards always telegraph first and only resolve while the Warden is between - * attacks, keeping the arena readable instead of turning the fight into noise. - */ - private static void applyPhaseMovement(ServerWorld world, ArenaState a) { - if (a.boss == null || a.attack != Attack.NONE || a.intro > 0 || a.recoveryTicks > 0) return; - - double angle = Math.atan2(a.boss.getZ() - .5, a.boss.getX() - .5); - double radius = switch (a.phase) { - case 1 -> 2.0; - case 2 -> 7.0; - case 3 -> 11.0; - default -> 14.0; - }; - - double desiredAngle = angle + (a.phase % 2 == 0 ? 0.018 : -0.018); - double targetX = .5 + Math.cos(desiredAngle) * radius; - double targetZ = .5 + Math.sin(desiredAngle) * radius; - double dx = targetX - a.boss.getX(); - double dz = targetZ - a.boss.getZ(); - double len = Math.sqrt(dx * dx + dz * dz); - - if (len > .15) { - double speed = switch (a.phase) { - case 1 -> .025; - case 2 -> .045; - case 3 -> .065; - default -> .08; - }; - a.boss.addVelocity(dx / len * speed, 0, dz / len * speed); - a.boss.setVelocity(a.boss.getVelocity().multiply(.82, 1.0, .82)); - a.boss.velocityModified = true; - } - - if (a.ticks % 10 == 0) { - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - a.boss.getX(), a.boss.getY() + .15, a.boss.getZ(), - 2 + a.phase, .25, .05, .25, .004); - } - } - - private static void tickPhaseArena(ServerWorld world, ArenaState a) { - if (a.phase < 2 || a.defeated || a.attack != Attack.NONE) { - if (a.hazardTicks > 0) { - a.hazardTicks = 0; - a.hazardPattern = -1; - } - return; - } - - int interval = switch (a.phase) { - case 2 -> 100; - case 3 -> 80; - default -> 60; - }; - int warning = switch (a.phase) { - case 2 -> 30; - case 3 -> 24; - default -> 20; - }; - - if (a.hazardTicks <= 0) { - if (a.ticks % interval != 0) return; - - // The arena hazard follows a phase-specific rhythm instead of a - // repeating global timer. This lets players learn what comes next. - a.hazardPattern++; - int patterns = a.phase == 3 ? 8 : 4; - if (a.hazardPattern >= patterns) a.hazardPattern = 0; - a.hazardTicks = warning; - - // Phase 2 alternates a quadrant warning with a safe recovery beat. - // Phase 3 walks the ring clockwise. Phase 4 always collapses inward. - if (a.phase == 2 && a.hazardPattern % 2 == 1) { - a.hazardTicks = warning + 10; - } - - world.playSound(null, CENTER, SoundEvents.BLOCK_SCULK_SENSOR_CLICKING, - SoundCategory.HOSTILE, .8f, 1.0f + a.phase * .12f); - } - - a.hazardTicks--; - - if (a.hazardTicks > 0) { - if (a.phase == 2) { - warningWedge(world, a.hazardPattern, a.hazardTicks, warning); - } else if (a.phase == 3) { - warningRingSegment(world, a.hazardPattern, a.hazardTicks, warning); - } else { - warningCollapseRing(world, a.hazardTicks, warning); - } - } else { - if (a.phase == 2) { - if (a.hazardPattern % 2 == 0) { - resolveWedge(world, a); - } else { - // A deliberate safe beat. The arena still signals the pattern, - // but there is no damage, giving players a readable reposition window. - world.playSound(null, CENTER, SoundEvents.BLOCK_SCULK_CATALYST_BLOOM, - SoundCategory.HOSTILE, .7f, 1.35f); - ring(world, .5, 80.25, .5, 20, ParticleTypes.SCULK_SOUL, 64); - } - } else if (a.phase == 3) { - resolveRingSegment(world, a); - } else { - resolveCollapseRing(world, a); - } - } - } - - private static void warningWedge(ServerWorld world, int sector, int remaining, int warning) { - double centerAngle = sector * Math.PI / 2.0 + Math.PI / 4.0; - double strength = .35 + (warning - remaining) / (double) warning * .65; - for (int r = 7; r <= 21; r += 2) { - for (int side = -2; side <= 2; side++) { - double angle = centerAngle + side * .045; - world.spawnParticles(ParticleTypes.SCULK_SOUL, - .5 + Math.cos(angle) * r, 80.25, - .5 + Math.sin(angle) * r, 1, .08, .02, .08, .004 + strength * .006); - } - } - } - - private static void warningRingSegment(ServerWorld world, int segment, int remaining, int warning) { - double start = segment * Math.PI / 4.0; - double end = start + Math.PI / 4.0; - for (double angle = start; angle <= end; angle += Math.PI / 32.0) { - for (int r = 8; r <= 20; r += 3) { - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - .5 + Math.cos(angle) * r, 80.25, - .5 + Math.sin(angle) * r, 1, .05, .02, .05, - .006 + (warning - remaining) * .0003); - } - } - } - - private static void warningCollapseRing(ServerWorld world, int remaining, int warning) { - double radius = 19.5 - (warning - remaining) * .12; - ring(world, .5, 80.25, .5, radius, ParticleTypes.REVERSE_PORTAL, 96); - ring(world, .5, 80.28, .5, 15.0, ParticleTypes.SCULK_SOUL, 64); - } - - private static void resolveWedge(ServerWorld world, ArenaState a) { - int sector = a.hazardPattern / 2; - // A cleansed pylon permanently creates a sanctuary in its quadrant. - // This turns the pylon objective into lasting arena control instead of - // merely being a temporary damage gate. - if ((a.activePylons & (1 << sector)) == 0) { - world.playSound(null, PYLONS[sector], SoundEvents.BLOCK_SCULK_CATALYST_BLOOM, - SoundCategory.HOSTILE, .8f, 1.5f); - ring(world, PYLONS[sector].getX() + .5, 80.3, PYLONS[sector].getZ() + .5, - 4.5, ParticleTypes.END_ROD, 32); - return; - } - - double centerAngle = sector * Math.PI / 2.0 + Math.PI / 4.0; - for (ServerPlayerEntity p : activePlayers(world, a)) { - double dx = p.getX() - .5; - double dz = p.getZ() - .5; - double radius = Math.sqrt(dx * dx + dz * dz); - double angle = Math.atan2(dz, dx); - double diff = Math.atan2(Math.sin(angle - centerAngle), Math.cos(angle - centerAngle)); - if (radius >= 6 && radius <= 22 && Math.abs(diff) < Math.PI / 4.0) { - p.damage(world.getDamageSources().magic(), 7); - p.addVelocity(-dx * .025, .18, -dz * .025); - p.velocityModified = true; - } - } - world.playSound(null, CENTER, SoundEvents.BLOCK_SCULK_CATALYST_BLOOM, - SoundCategory.HOSTILE, 1.1f, 1.1f); - ring(world, .5, 80.25, .5, 20, ParticleTypes.SCULK_SOUL, 96); - } - - private static void resolveRingSegment(ServerWorld world, ArenaState a) { - double start = a.hazardPattern * Math.PI / 4.0; - double end = start + Math.PI / 4.0; - for (ServerPlayerEntity p : activePlayers(world, a)) { - double dx = p.getX() - .5; - double dz = p.getZ() - .5; - double radius = Math.sqrt(dx * dx + dz * dz); - double angle = Math.atan2(dz, dx); - double diff = Math.atan2(Math.sin(angle - (start + end) / 2), - Math.cos(angle - (start + end) / 2)); - if (radius >= 8 && radius <= 21 && Math.abs(diff) < Math.PI / 9.0) { - p.damage(world.getDamageSources().magic(), 9); - } - } - world.playSound(null, CENTER, SoundEvents.BLOCK_END_PORTAL_SPAWN, - SoundCategory.HOSTILE, 1.0f, 1.35f); - ring(world, .5, 80.25, .5, 18, ParticleTypes.REVERSE_PORTAL, 96); - } - - private static void resolveCollapseRing(ServerWorld world, ArenaState a) { - for (ServerPlayerEntity p : activePlayers(world, a)) { - double dx = p.getX() - .5; - double dz = p.getZ() - .5; - double radius = Math.sqrt(dx * dx + dz * dz); - if (radius > 15.5 && radius <= 23) { - p.damage(world.getDamageSources().magic(), 11); - p.addVelocity(-dx * .035, .12, -dz * .035); - p.velocityModified = true; - } - } - world.playSound(null, CENTER, SoundEvents.ENTITY_WARDEN_HEARTBEAT, - SoundCategory.HOSTILE, 1.8f, .55f); - ring(world, .5, 80.25, .5, 19, ParticleTypes.EXPLOSION, 96); - } - - private static void beginAttack(ServerWorld world, ArenaState a) { - ServerPlayerEntity target = selectTarget(world, a); - if (target == null) return; - - Attack selected = switch (a.phase) { - case 1 -> switch (a.attackStep % 3) { - case 0, 1 -> Attack.VOID_CLEAVE; - default -> Attack.VOID_RAIN; - }; - case 2 -> switch (a.attackStep % 3) { - case 0 -> Attack.SCULK_RING; - case 1 -> Attack.VOID_RAIN; - default -> Attack.SCULK_RING; - }; - case 3 -> switch (a.attackStep % 3) { - case 0 -> Attack.NULL_DASH; - case 1 -> Attack.GRAVITY_WELL; - default -> Attack.VOID_RAIN; - }; - default -> switch (a.attackStep % 4) { - case 0 -> Attack.REALITY_TEAR; - case 1 -> Attack.GRAVITY_WELL; - case 2 -> Attack.COLLAPSE; - default -> Attack.REALITY_TEAR; - }; - }; - a.attack = selected; - a.attackStep++; - a.attackTarget = target.getUuid(); - a.attackX = target.getX(); - a.attackY = target.getY(); - a.attackZ = target.getZ(); - a.attackWindup = a.attack.windup; - a.boss.setVisualState(a.phase, attackVisualId(a.attack), 100, false); - int phaseDelay = switch (a.phase) { - case 1 -> 12; - case 2 -> 10; - case 3 -> 8; - default -> 6; - }; - a.nextAttackTick = a.ticks + a.attack.windup + a.attack.recovery + phaseDelay; - a.recoveryTicks = a.attack.recovery + phaseDelay; - - for (ServerPlayerEntity p : participants(world, a)) - p.sendMessage(Text.literal("NULL WARDEN // " + a.attack.name), true); - - world.playSound(null, a.boss.getBlockPos(), SoundEvents.ENTITY_WARDEN_HEARTBEAT, - SoundCategory.HOSTILE, 2.5f, .75f); - } - - private static ServerPlayerEntity selectTarget(ServerWorld world, ArenaState a) { - ServerPlayerEntity closest = null; - double closestDistance = Double.MAX_VALUE; - ServerPlayerEntity rotated = null; - ServerPlayerEntity cleanser = null; - int targetIndex = Math.floorMod(a.targetRotation, Math.max(1, a.activeParticipants.size())); - int index = 0; - - for (UUID id : a.activeParticipants) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(id); - if (p == null) continue; - - // Cleansing is the encounter's highest-commitment action. If someone - // is actively cleansing a live pylon, the Warden deliberately notices - // them and can pressure that player with the next telegraphed attack. - if (isCleansingPylon(world, a, p)) cleanser = p; - - if (index == targetIndex) rotated = p; - - double d = p.squaredDistanceTo(a.boss); - if (d < closestDistance) { - closestDistance = d; - closest = p; - } - index++; - } - - a.targetRotation++; - if (cleanser != null) return cleanser; - if (rotated != null) return rotated; - return closest; - } - - private static boolean isCleansingPylon(ServerWorld world, ArenaState a, ServerPlayerEntity player) { - if (!player.isSneaking() || a.activePylons == 0) return false; - - for (int i = 0; i < 4; i++) { - if ((a.activePylons & (1 << i)) == 0) continue; - BlockPos p = PYLONS[i]; - if (player.squaredDistanceTo( - p.getX() + .5, p.getY() + 1, p.getZ() + .5) <= 9) { - return true; - } - } - return false; - } - - private static ServerPlayerEntity target(ServerWorld world, ArenaState a) { - if (a.attackTarget != null) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(a.attackTarget); - if (p != null && a.activeParticipants.contains(p.getUuid())) return p; - } - return selectTarget(world, a); - } - - private static void telegraph(ServerWorld world, ArenaState a) { - ServerPlayerEntity t = target(world, a); - if (t == null) return; - int left = a.attackWindup; - double tx = a.attackX, ty = a.attackY, tz = a.attackZ; - switch (a.attack) { - case VOID_CLEAVE -> { - Vec3d f = a.boss.getRotationVector().normalize(); - for (int i = 0; i < 20; i++) { - double d = 1.5 + i * .25; - double spread = (i % 5 - 2) * .35; - world.spawnParticles(new DustParticleEffect( - new Vector3f(.65f, .1f, .9f), left < 7 ? 1.8f : 1.1f), - a.boss.getX() + f.x * d - f.z * spread, - a.boss.getY() + .15, - a.boss.getZ() + f.z * d + f.x * spread, - 1, 0, 0, 0, 0); - } - } - case SCULK_RING -> ring(world, a.boss.getX(), a.boss.getY() + .1, a.boss.getZ(), - 6, ParticleTypes.SCULK_SOUL, 64); - case VOID_RAIN -> { - for (UUID id : a.activeParticipants) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(id); - if (p != null) ring(world, p.getX(), p.getY() + .1, p.getZ(), - left < 7 ? 2.7 : 2.2, ParticleTypes.REVERSE_PORTAL, 36); - } - } - case NULL_DASH -> ring(world, t.getX(), t.getY() + .1, t.getZ(), - left < 7 ? 3.5 : 2.5, ParticleTypes.REVERSE_PORTAL, 48); - case GRAVITY_WELL, REALITY_TEAR, COLLAPSE -> - ring(world, t.getX(), t.getY() + .08, t.getZ(), a.attack.radius, - left < 7 ? ParticleTypes.EXPLOSION : ParticleTypes.REVERSE_PORTAL, - a.attack == Attack.COLLAPSE ? 72 : 48); - default -> {} - } - } - - private static void resolveAttack(ServerWorld world, ArenaState a) { - ServerPlayerEntity t = target(world, a); - if (t == null || a.boss == null) return; - - switch (a.attack) { - case VOID_CLEAVE -> { - Vec3d f = a.boss.getRotationVector().normalize(); - Vec3d delta = t.getPos().subtract(a.boss.getPos()); - double d = Math.sqrt(delta.x * delta.x + delta.z * delta.z); - double dot = d < .01 ? 1 : (f.x * delta.x + f.z * delta.z) / d; - if (d <= 5 && dot > .55) t.damage(world.getDamageSources().mobAttack(a.boss), 12); - shockwave(world, t, 3); - } - case SCULK_RING -> { - for (ServerPlayerEntity p : activePlayers(world, a)) { - double d = p.distanceTo(a.boss); - if (d > 4 && d < 8) p.damage(world.getDamageSources().mobAttack(a.boss), 13); - } - ring(world, a.boss.getX(), a.boss.getY() + .15, a.boss.getZ(), - 6, ParticleTypes.SCULK_SOUL, 80); - } - case VOID_RAIN -> { - ring(world, a.attackX, a.attackY + .1, a.attackZ, - 2.75, ParticleTypes.EXPLOSION, 56); - for (ServerPlayerEntity p : activePlayers(world, a)) { - if (p.squaredDistanceTo(a.attackX, p.getY(), a.attackZ) <= 7.56) { - p.damage(world.getDamageSources().mobAttack(a.boss), 10); - } - } - } - case NULL_DASH -> { - Vec3d from = a.boss.getPos(); - a.boss.teleport(t.getX(), t.getY(), t.getZ(), false); - for (ServerPlayerEntity p : activePlayers(world, a)) { - if (p.squaredDistanceTo(from.x, from.y, from.z) < 6.25 - || p.squaredDistanceTo(a.boss) < 12.25) - p.damage(world.getDamageSources().mobAttack(a.boss), 16); - } - shockwave(world, t, 3.5); - } - case GRAVITY_WELL -> { - pullTowardPoint(world, a, a.attackX, a.attackY, a.attackZ, .62, 12); - for (ServerPlayerEntity p : activePlayers(world, a)) - if (p.squaredDistanceTo(a.attackX, p.getY(), a.attackZ) < 12.25) - p.damage(world.getDamageSources().mobAttack(a.boss), 12); - ring(world, a.attackX, a.attackY + .15, a.attackZ, - 3.5, ParticleTypes.REVERSE_PORTAL, 64); - } - case REALITY_TEAR -> { - for (ServerPlayerEntity p : activePlayers(world, a)) - if (p.squaredDistanceTo(a.attackX, p.getY(), a.attackZ) < 16) - p.damage(world.getDamageSources().mobAttack(a.boss), 14); - world.playSound(null, BlockPos.ofFloored(a.attackX, a.attackY, a.attackZ), - SoundEvents.BLOCK_END_PORTAL_SPAWN, SoundCategory.HOSTILE, 1.6f, .7f); - } - case COLLAPSE -> { - for (ServerPlayerEntity p : activePlayers(world, a)) - if (p.squaredDistanceTo(a.attackX, p.getY(), a.attackZ) < 25) - p.damage(world.getDamageSources().mobAttack(a.boss), 20); - pullTowardPoint(world, a, a.attackX, a.attackY, a.attackZ, .82, 15); - ring(world, a.attackX, a.attackY + .15, a.attackZ, 9, - ParticleTypes.EXPLOSION, 96); - } - default -> {} - } - } - - private static void pullTowardPoint(ServerWorld world, ArenaState a, - double x, double y, double z, - double strength, double radius) { - for (ServerPlayerEntity p : activePlayers(world, a)) { - double dx = x - p.getX(); - double dz = z - p.getZ(); - double len = Math.sqrt(dx * dx + dz * dz); - if (len > .1 && len < radius) { - double scaled = Math.min(strength, .9) * (1.0 - len / radius); - p.addVelocity(dx / len * scaled, .08, dz / len * scaled); - p.velocityModified = true; - } - } - } - - private static void gravity(ServerWorld world, ArenaState a, double strength) { - pullTowardPoint(world, a, a.boss.getX(), a.boss.getY(), a.boss.getZ(), strength, 15); - } - - private static String phaseName(int phase) { - return switch (phase) { - case 1 -> "THE WATCHER"; - case 2 -> "THE AWAKENING"; - case 3 -> "THE FRACTURE"; - default -> "THE COLLAPSE"; - }; - } - - private static void phaseShift(ServerWorld world, ArenaState a) { - if (a.bar != null) { - a.bar.setColor(switch (a.phase) { - case 2 -> ServerBossBar.Color.PINK; - case 3 -> ServerBossBar.Color.PURPLE; - default -> ServerBossBar.Color.RED; - }); - } - - world.playSound(null, a.boss.getBlockPos(), SoundEvents.ENTITY_WARDEN_ROAR, - SoundCategory.HOSTILE, 3.5f, .45f); - world.spawnParticles(ParticleTypes.EXPLOSION, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), 22, 2.2, 2.0, 2.2, .06); - world.spawnParticles(ParticleTypes.SCULK_SOUL, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), 120, 5, 2.5, 5, .035); - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), 100, 4, 3, 4, .045); - String rule = switch (a.phase) { - case 2 -> "Phase 2: pylon fields pulse. Commit carefully while cleansing."; - case 3 -> "Phase 3: pylon fields pull you inward. Movement is the price of cleansing."; - default -> "Phase 4: all pylons resonate faster. Cleanse them before the arena overwhelms you."; - }; - for (ServerPlayerEntity p : participants(world, a)) { - p.sendMessage(Text.literal("NULL WARDEN // " + phaseName(a.phase)), true); - p.sendMessage(Text.literal("The pylons are feeding it. Sneak beside each one to cleanse it."), false); - p.sendMessage(Text.literal(rule), false); - } - } - - private static void summonEcho(ServerWorld world, ArenaState a) { - a.echoes.removeIf(id -> { - Entity e = world.getEntity(id); - return e == null || !e.isAlive(); - }); - if (a.echoes.size() >= Math.min(3, a.phase)) return; - - WardenEntity echo = EntityType.WARDEN.create(world); - if (echo == null) return; - double angle = a.ticks * .045; - echo.refreshPositionAndAngles(Math.cos(angle) * 11, 81, - Math.sin(angle) * 11, 0, 0); - echo.setCustomName(Text.literal("NULL ECHO")); - echo.setCustomNameVisible(false); - echo.setPersistent(); - - var max = echo.getAttributeInstance(EntityAttributes.GENERIC_MAX_HEALTH); - var dmg = echo.getAttributeInstance(EntityAttributes.GENERIC_ATTACK_DAMAGE); - if (max != null) max.setBaseValue(65); - if (dmg != null) dmg.setBaseValue(8); - echo.setHealth(65); - world.spawnEntity(echo); - a.echoes.add(echo.getUuid()); - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - echo.getX(), echo.getY() + 1, echo.getZ(), 90, .8, 1, .8, .06); - persist(world, a); - } - - private static void finish(ServerWorld world, ArenaState a) { - if (a.defeated) return; - a.defeated = true; - a.attack = Attack.NONE; - a.attackTarget = null; - a.attackX = a.attackY = a.attackZ = 0; - a.attackWindup = 0; - a.activePylons = 0; - a.victoryTicks = 0; - a.boss.setHealth(1); - a.boss.setInvulnerable(true); - a.boss.setAiDisabled(true); - a.boss.setVisualState(a.phase, 0, 0, true); - if (a.bar != null) a.bar.setVisible(false); - - world.playSound(null, a.boss.getBlockPos(), SoundEvents.ENTITY_WARDEN_DEATH, - SoundCategory.HOSTILE, 5, .45f); - world.spawnParticles(ParticleTypes.EXPLOSION, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), 48, 2, 2, 2, .08); - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), 420, 5, 4, 5, .1); - world.spawnParticles(ParticleTypes.SCULK_SOUL, - a.boss.getX(), a.boss.getY() + 1, a.boss.getZ(), 240, 4, 3, 4, .055); - for (int r = 3; r <= 12; r += 3) { - ring(world, a.boss.getX(), 81.0, a.boss.getZ(), r, - ParticleTypes.END_ROD, 64); - } - - rewardPending(world.getServer(), world, a); - buildReturnPortal(world, a); - persist(world, a); - } - - private static void rewardPending(MinecraftServer server, ServerWorld world, ArenaState a) { - for (UUID id : a.eligiblePlayers) { - ServerPlayerEntity p = server.getPlayerManager().getPlayer(id); - if (p != null && p.getServerWorld() == world) rewardIfEligible(p, a); - } - persist(world, a); - } - - private static void rewardIfEligible(ServerPlayerEntity p, ArenaState a) { - if (!a.eligiblePlayers.contains(p.getUuid())) return; - if (!a.rewardedPlayers.add(p.getUuid())) return; - p.getInventory().offerOrDrop(new ItemStack(ModItems.NULLBLADE)); - p.getInventory().offerOrDrop(new ItemStack(ModItems.NULL_RELIC)); - p.sendMessage(Text.literal("THE NULL WARDEN HAS FALLEN"), false); - p.sendMessage(Text.literal("A Null Realm reward has been claimed."), false); - } - - private static void reset(ServerWorld world, ArenaState a) { - cleanup(world, a); - removeReturnPortal(world); - clearSaved(world); - if (a.bar != null) for (UUID id : a.participants) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(id); - if (p != null) a.bar.removePlayer(p); - } - a.boss = null; - a.bossUuid = null; - a.bar = null; - a.participants.clear(); - a.activeParticipants.clear(); - a.rewardedPlayers.clear(); - a.eligiblePlayers.clear(); - a.joinTicks.clear(); - a.echoes.clear(); - a.defeated = false; - a.returnPortalBuilt = false; - a.phase = 1; - a.activePylons = 0; - a.cleansedPylons = 0; - a.pylonProgress = new int[4]; - a.attack = Attack.NONE; - a.attackTarget = null; - a.attackStep = 0; - } - - private static void cleanup(ServerWorld world, ArenaState a) { - if (a.bossUuid != null) { - Entity e = world.getEntity(a.bossUuid); - if (e != null) e.discard(); - } - for (UUID id : a.echoes) { - Entity e = world.getEntity(id); - if (e != null) e.discard(); - } - a.echoes.clear(); - } - - private static void buildArena(ServerWorld world) { - // A deliberately constructed ritual arena instead of a flat boss platform. - for (int x = -ARENA_RADIUS; x <= ARENA_RADIUS; x++) { - for (int z = -ARENA_RADIUS; z <= ARENA_RADIUS; z++) { - double d = Math.sqrt(x * x + z * z); - if (d > ARENA_RADIUS) continue; - - BlockPos floor = new BlockPos(x, 80, z); - if (d < 7) { - world.setBlockState(floor, Blocks.POLISHED_BLACKSTONE.getDefaultState()); - } else if (((Math.abs(x) + Math.abs(z)) % 4) == 0) { - world.setBlockState(floor, Blocks.CRYING_OBSIDIAN.getDefaultState()); - } else { - world.setBlockState(floor, Blocks.POLISHED_BLACKSTONE_BRICKS.getDefaultState()); - } - - for (int y = 81; y <= 100; y++) { - world.setBlockState(new BlockPos(x, y, z), Blocks.AIR.getDefaultState()); - } - } - } - - // Raised central ritual dais. - for (int x = -6; x <= 6; x++) { - for (int z = -6; z <= 6; z++) { - if (x * x + z * z <= 36) { - world.setBlockState(new BlockPos(x, 81, z), - Blocks.POLISHED_BLACKSTONE.getDefaultState()); - if (x * x + z * z <= 9) { - world.setBlockState(new BlockPos(x, 82, z), - Blocks.CRYING_OBSIDIAN.getDefaultState()); - } - } - } - } - - // Four monumental pylon towers with collars and floating caps. - for (int i = 0; i < 4; i++) { - BlockPos p = PYLONS[i]; - - for (int y = 0; y < 11; y++) { - world.setBlockState(p.add(0, y, 0), - Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - if (y >= 3 && y <= 8) { - world.setBlockState(p.add(1, y, 0), - Blocks.SCULK.getDefaultState()); - world.setBlockState(p.add(-1, y, 0), - Blocks.SCULK.getDefaultState()); - } - } - - for (int x = -2; x <= 2; x++) { - for (int z = -2; z <= 2; z++) { - if (Math.abs(x) == 2 || Math.abs(z) == 2) { - world.setBlockState(p.add(x, 11, z), - Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - } - } - } - world.setBlockState(p.up(12), Blocks.SCULK_CATALYST.getDefaultState()); - world.setBlockState(p.up(13), Blocks.SCULK.getDefaultState()); - } - - // Four outer monuments make the circular boundary read as an actual arena. - BlockPos[] corners = { - new BlockPos(19, 81, 19), new BlockPos(-19, 81, 19), - new BlockPos(-19, 81, -19), new BlockPos(19, 81, -19) - }; - for (BlockPos base : corners) { - for (int y = 0; y < 8; y++) { - world.setBlockState(base.add(0, y, 0), Blocks.POLISHED_BLACKSTONE_BRICKS.getDefaultState()); - if (y > 1) { - world.setBlockState(base.add(1, y, 0), Blocks.CRYING_OBSIDIAN.getDefaultState()); - world.setBlockState(base.add(-1, y, 0), Blocks.CRYING_OBSIDIAN.getDefaultState()); - } - } - world.setBlockState(base.up(8), Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - world.setBlockState(base.up(9), Blocks.SCULK_CATALYST.getDefaultState()); - } - - // Suspended Null fragments make the upper arena read as a broken - // chamber rather than an ordinary open cylinder. They are intentionally - // sparse so the boss remains the visual focal point. - int[][] fragments = { - {10, 93, 0, 2}, {-11, 96, 3, 1}, - {0, 94, 12, 3}, {4, 91, -14, 2}, - {-14, 89, -5, 1}, {15, 92, 6, 2} - }; - for (int[] f : fragments) { - BlockPos base = new BlockPos(f[0], f[1], f[2]); - int size = f[3]; - for (int dx = -size; dx <= size; dx++) { - for (int dz = -size; dz <= size; dz++) { - if (Math.abs(dx) + Math.abs(dz) > size + 1) continue; - Block block = ((dx + dz) & 1) == 0 - ? Blocks.REINFORCED_DEEPSLATE - : Blocks.CRYING_OBSIDIAN; - world.setBlockState(base.add(dx, 0, dz), block.getDefaultState()); - } - } - world.setBlockState(base.up(), Blocks.SCULK.getDefaultState()); - } - - // Four diagonal floor scars point toward the central dais. These become - // visual navigation landmarks when the arena starts filling with hazards. - for (int diagonal = 0; diagonal < 4; diagonal++) { - int sx = diagonal == 0 || diagonal == 3 ? 1 : -1; - int sz = diagonal == 0 || diagonal == 1 ? 1 : -1; - for (int d = 8; d <= 18; d++) { - int x = Math.round(sx * d * .72f); - int z = Math.round(sz * d * .72f); - world.setBlockState(new BlockPos(x, 80, z), - d % 3 == 0 - ? Blocks.CRYING_OBSIDIAN.getDefaultState() - : Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - } - } - - // Cardinal arches frame the boss without closing the sky. - for (int[] dir : new int[][]{{1,0},{-1,0},{0,1},{0,-1}}) { - int dx = dir[0], dz = dir[1]; - for (int side = -3; side <= 3; side++) { - int x = dx * 22 + (dz != 0 ? side : 0); - int z = dz * 22 + (dx != 0 ? side : 0); - world.setBlockState(new BlockPos(x, 81, z), Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - world.setBlockState(new BlockPos(x, 82, z), Blocks.POLISHED_BLACKSTONE_BRICKS.getDefaultState()); - } - for (int y = 82; y <= 88; y++) { - world.setBlockState(new BlockPos(dx * 22, y, dz * 22), - Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - } - } - - buildRealmLandmarks(world); - } - - private static void tickRealmAtmosphere(ServerWorld world, ArenaState a) { - // The Null Realm moves even when the boss is not attacking. All effects - // here are cosmetic and sparse, so atmosphere never competes with combat. - int beat = a.ticks; - - // Slow drifting streams connect the arena to the broken horizon. - if (beat % 6 == 0) { - double phase = beat * 0.035; - int[][] anchors = { - {38, 80, 0}, {-40, 84, 12}, {8, 79, 39}, - {-14, 87, -41}, {31, 76, -30}, {-33, 88, -27} - }; - for (int i = 0; i < anchors.length; i++) { - if ((i + beat / 6) % 2 != 0) continue; - double angle = phase * (i % 2 == 0 ? 1.0 : -0.75) + i * 1.7; - double radius = 1.2 + Math.sin(phase * 1.7 + i) * .45; - double x = anchors[i][0] + Math.cos(angle) * radius; - double z = anchors[i][2] + Math.sin(angle) * radius; - double y = anchors[i][1] + .8 + Math.sin(phase * 2.0 + i) * 1.4; - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - x, y, z, 2, .05, .12, .05, .008); - } - } - - // Every few seconds one landmark "answers" the arena with a vertical - // pulse. The rotating index makes the horizon feel reactive rather than - // like six identical particle emitters firing at once. - if (beat % 120 == 0) { - int[][] towers = { - {38, 72, 0, 8}, {-40, 76, 12, 11}, - {8, 70, 39, 9}, {-14, 74, -41, 13}, - {31, 69, -30, 7}, {-33, 78, -27, 10} - }; - int index = Math.floorMod(beat / 120, towers.length); - int[] t = towers[index]; - double y = t[1] + t[3] + 1.0; - world.spawnParticles(ParticleTypes.SCULK_SOUL, - t[0] + .5, y, t[2] + .5, - 18, .35, .8, .35, .018); - ring(world, t[0] + .5, y, t[2] + .5, - 1.5 + (t[3] % 3) * .35, ParticleTypes.END_ROD, 24); - } - - // Rare low-frequency "Null wind" gives the dimension a soundscape without - // turning every tick into noise. The pitch changes with the current phase. - if (beat > 0 && beat % 300 == 0) { - float pitch = switch (a.phase) { - case 2 -> .72f; - case 3 -> .62f; - case 4 -> .52f; - default -> .82f; - }; - world.playSound(null, CENTER, - SoundEvents.BLOCK_SCULK_CATALYST_BLOOM, - SoundCategory.AMBIENT, .65f, pitch); - } - } - - private static void buildRealmLandmarks(ServerWorld world) { - // The Null Realm should have a horizon with silhouettes, not an empty End - // backdrop. These distant fragments stay outside the combat boundary. - int[][] towers = { - {38, 72, 0, 8}, {-40, 76, 12, 11}, - {8, 70, 39, 9}, {-14, 74, -41, 13}, - {31, 69, -30, 7}, {-33, 78, -27, 10} - }; - - for (int[] t : towers) { - BlockPos base = new BlockPos(t[0], t[1], t[2]); - int height = t[3]; - int radius = Math.max(1, height / 5); - - // Broken obelisk core. - for (int y = 0; y < height; y++) { - int wobble = ((y * 7 + Math.abs(t[0]) + Math.abs(t[2])) % 3) - 1; - BlockPos p = base.add(wobble, y, 0); - world.setBlockState(p, Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - if (y > 2 && y % 3 == 0) { - world.setBlockState(p.add(1, 0, 0), Blocks.CRYING_OBSIDIAN.getDefaultState()); - } - } - - // A fractured crown gives each silhouette a readable top. - int crownY = base.getY() + height; - for (int dx = -radius; dx <= radius; dx++) { - for (int dz = -radius; dz <= radius; dz++) { - if (Math.abs(dx) + Math.abs(dz) <= radius) { - world.setBlockState(new BlockPos(base.getX() + dx, crownY, base.getZ() + dz), - ((dx + dz) & 1) == 0 - ? Blocks.REINFORCED_DEEPSLATE.getDefaultState() - : Blocks.SCULK.getDefaultState()); - } - } - } - - // Hanging sculk strands make the structures feel suspended. - if (height >= 9) { - for (int y = crownY - 4; y < crownY; y++) { - if ((y + base.getX()) % 2 == 0) { - world.setBlockState(new BlockPos(base.getX() + radius + 1, y, base.getZ()), - Blocks.SCULK.getDefaultState()); - } - } - } - } - - // Four broken approach islands imply that the arena is the center of a - // much larger place without creating a second combat space. - int[][] islands = {{0, 42}, {0, -44}, {44, 4}, {-45, -6}}; - for (int[] island : islands) { - int cx = island[0], cz = island[1]; - for (int dx = -4; dx <= 4; dx++) { - for (int dz = -4; dz <= 4; dz++) { - if (dx * dx + dz * dz <= 18) { - int y = 73 + ((Math.abs(dx) + Math.abs(dz)) % 2); - world.setBlockState(new BlockPos(cx + dx, y, cz + dz), - (dx + dz) % 3 == 0 - ? Blocks.CRYING_OBSIDIAN.getDefaultState() - : Blocks.REINFORCED_DEEPSLATE.getDefaultState()); - } - } - } - world.setBlockState(new BlockPos(cx, 75, cz), Blocks.SCULK_CATALYST.getDefaultState()); - } - } - - private static void buildReturnPortal(ServerWorld world, ArenaState a) { - if (a.returnPortalBuilt) return; - for (int x = -1; x <= 1; x++) - for (int y = 0; y < 4; y++) - world.setBlockState(RETURN_PORTAL.add(x, y, 0), ModBlocks.VOID_PORTAL.getDefaultState()); - a.returnPortalBuilt = true; - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - 13.5, 83, 13.5, 160, 1.5, 2.2, 1.5, .12); - } - - private static void removeReturnPortal(ServerWorld world) { - for (int x = -1; x <= 1; x++) - for (int y = 0; y < 4; y++) { - BlockPos p = RETURN_PORTAL.add(x, y, 0); - if (world.getBlockState(p).isOf(ModBlocks.VOID_PORTAL)) - world.setBlockState(p, Blocks.AIR.getDefaultState()); - } - } - - private static void ring(ServerWorld world, double x, double y, double z, - double radius, ParticleEffect effect, int points) { - for (int i = 0; i < points; i++) { - double angle = i * Math.PI * 2 / points; - world.spawnParticles(effect, x + Math.cos(angle) * radius, y, - z + Math.sin(angle) * radius, 1, 0, 0, 0, 0); - } - } - - private static void shockwave(ServerWorld world, ServerPlayerEntity p, double radius) { - ring(world, p.getX(), p.getY() + .15, p.getZ(), radius, ParticleTypes.PORTAL, 80); - } - - private static Set activePlayers(ServerWorld world, ArenaState a) { - Set result = new HashSet<>(); - for (UUID id : a.activeParticipants) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(id); - if (p != null && p.getServerWorld() == world && p.isAlive()) result.add(p); - } - return result; - } - - private static Set participants(ServerWorld world, ArenaState a) { - Set result = new HashSet<>(); - for (UUID id : a.participants) { - ServerPlayerEntity p = world.getServer().getPlayerManager().getPlayer(id); - if (p != null && p.getServerWorld() == world) result.add(p); - } - return result; - } - - public static void saveReturnPoint(ServerPlayerEntity player, RegistryKey worldKey, - BlockPos pos, float yaw, float pitch) { - ServerWorld nullRealm = player.getServer().getWorld( - RegistryKey.of(net.minecraft.registry.RegistryKeys.WORLD, - net.minecraft.util.Identifier.of(dev.qynl.myfirstmod.MyFirstMod.MOD_ID, "null_realm"))); - if (nullRealm == null) return; - - NullWardenState data = saved(nullRealm); - data.returnPoints.put(player.getUuid(), new NullWardenState.ReturnPointData( - worldKey.getValue().toString(), pos.getX(), pos.getY(), pos.getZ(), yaw, pitch)); - data.dirty(); - } - - public static NullWardenState.ReturnPointData takeReturnPoint(ServerPlayerEntity player) { - NullWardenState data = saved(player.getServerWorld()); - NullWardenState.ReturnPointData point = data.returnPoints.remove(player.getUuid()); - data.dirty(); - return point; - } - - private enum Attack { - NONE(0, 0, 0, "IDLE"), - VOID_CLEAVE(24, 90, 5, "VOID CLEAVE"), - SCULK_RING(28, 92, 6, "SCULK RING"), - VOID_RAIN(30, 100, 2.5, "VOID RAIN"), - NULL_DASH(24, 86, 4, "NULL DASH"), - GRAVITY_WELL(26, 95, 7, "GRAVITY WELL"), - REALITY_TEAR(20, 72, 8, "REALITY TEAR"), - COLLAPSE(30, 70, 9, "REALITY COLLAPSE"); - - final int windup, recovery; - final double radius; - final String name; - - Attack(int windup, int recovery, double radius, String name) { - this.windup = windup; - this.recovery = recovery; - this.radius = radius; - this.name = name; - } - } - - private static final class ArenaState { - NullWardenEntity boss; - UUID bossUuid; - ServerBossBar bar; - final Set participants = new HashSet<>(); - final Set activeParticipants = new HashSet<>(); - final Set eligiblePlayers = new HashSet<>(); - final Set rewardedPlayers = new HashSet<>(); - final Set echoes = new HashSet<>(); - final Map joinTicks = new HashMap<>(); - int ticks, intro, phase = 1, idleTicks, nextAttackTick = 40, attackWindup; - int victoryTicks, targetRotation; - int hazardTicks, hazardPattern = -1; - int recoveryTicks, exposeTicks, attackStep; - int activePylons, cleansedPylons; - int[] pylonProgress = new int[4]; - double attackX, attackY, attackZ; - UUID attackTarget; - Attack attack = Attack.NONE; - boolean defeated, returnPortalBuilt; - } -} \ No newline at end of file diff --git a/src/main/java/dev/qynl/myfirstmod/boss/NullWardenState.java b/src/main/java/dev/qynl/myfirstmod/boss/NullWardenState.java deleted file mode 100644 index 2bb5381..0000000 --- a/src/main/java/dev/qynl/myfirstmod/boss/NullWardenState.java +++ /dev/null @@ -1,147 +0,0 @@ -package dev.qynl.myfirstmod.boss; - -import net.minecraft.datafixer.DataFixTypes; -import net.minecraft.nbt.NbtCompound; -import net.minecraft.nbt.NbtList; -import net.minecraft.registry.RegistryWrapper; -import net.minecraft.world.PersistentState; - -import java.util.HashMap; -import java.util.HashSet; -import java.util.Map; -import java.util.Set; -import java.util.UUID; - -public final class NullWardenState extends PersistentState { - public static final String ID = "myfirstmod_null_warden"; - - public UUID bossUuid; - public final Set participants = new HashSet<>(); - public final Set eligiblePlayers = new HashSet<>(); - public final Set rewardedPlayers = new HashSet<>(); - public final Set echoes = new HashSet<>(); - public final Map returnPoints = new HashMap<>(); - public boolean defeated; - public boolean rewarded; - public boolean returnPortalBuilt; - public int phase = 1; - public int activePylons; - public int cleansedPylons; - public int[] pylonProgress = new int[4]; - - public static Type type() { - return new Type<>( - NullWardenState::new, - (nbt, lookup) -> fromNbt(nbt), - DataFixTypes.SAVED_DATA_RAIDS - ); - } - - private static NullWardenState fromNbt(NbtCompound nbt) { - NullWardenState state = new NullWardenState(); - - if (nbt.contains("BossUuid")) { - try { - state.bossUuid = UUID.fromString(nbt.getString("BossUuid")); - } catch (IllegalArgumentException ignored) { - } - } - - state.defeated = nbt.getBoolean("Defeated"); - state.rewarded = nbt.getBoolean("Rewarded"); - state.returnPortalBuilt = nbt.getBoolean("ReturnPortalBuilt"); - state.phase = Math.max(1, nbt.getInt("Phase")); - state.activePylons = nbt.getInt("ActivePylons"); - state.cleansedPylons = nbt.getInt("CleansedPylons") & 0xF; - if (nbt.contains("PylonProgress", 9)) { - NbtList progress = nbt.getList("PylonProgress", 3); - for (int i = 0; i < Math.min(4, progress.size()); i++) { - state.pylonProgress[i] = Math.max(0, Math.min(50, progress.getInt(i))); - } - } - - readUuidList(nbt, "Participants", state.participants); - readUuidList(nbt, "EligiblePlayers", state.eligiblePlayers); - readUuidList(nbt, "RewardedPlayers", state.rewardedPlayers); - readUuidList(nbt, "Echoes", state.echoes); - - NbtList returns = nbt.getList("ReturnPoints", 10); - for (int i = 0; i < returns.size(); i++) { - NbtCompound entry = returns.getCompound(i); - try { - UUID uuid = UUID.fromString(entry.getString("Player")); - state.returnPoints.put(uuid, new ReturnPointData( - entry.getString("World"), - entry.getInt("X"), - entry.getInt("Y"), - entry.getInt("Z"), - entry.getFloat("Yaw"), - entry.getFloat("Pitch") - )); - } catch (IllegalArgumentException ignored) { - } - } - - return state; - } - - private static void readUuidList(NbtCompound nbt, String key, Set target) { - NbtList list = nbt.getList(key, 8); - for (int i = 0; i < list.size(); i++) { - try { - target.add(UUID.fromString(list.getString(i))); - } catch (IllegalArgumentException ignored) { - } - } - } - - private static void writeUuidList(NbtCompound nbt, String key, Set source) { - NbtList list = new NbtList(); - for (UUID uuid : source) list.add(net.minecraft.nbt.NbtString.of(uuid.toString())); - nbt.put(key, list); - } - - @Override - public NbtCompound writeNbt(NbtCompound nbt, RegistryWrapper.WrapperLookup registryLookup) { - if (bossUuid != null) nbt.putString("BossUuid", bossUuid.toString()); - - nbt.putBoolean("Defeated", defeated); - nbt.putBoolean("Rewarded", rewarded); - nbt.putBoolean("ReturnPortalBuilt", returnPortalBuilt); - nbt.putInt("Phase", phase); - nbt.putInt("ActivePylons", activePylons); - nbt.putInt("CleansedPylons", cleansedPylons); - NbtList progress = new NbtList(); - for (int value : pylonProgress) progress.add(net.minecraft.nbt.NbtInt.of(Math.max(0, Math.min(50, value)))); - nbt.put("PylonProgress", progress); - - writeUuidList(nbt, "Participants", participants); - writeUuidList(nbt, "EligiblePlayers", eligiblePlayers); - writeUuidList(nbt, "RewardedPlayers", rewardedPlayers); - writeUuidList(nbt, "Echoes", echoes); - - NbtList returns = new NbtList(); - for (Map.Entry entry : returnPoints.entrySet()) { - ReturnPointData point = entry.getValue(); - NbtCompound data = new NbtCompound(); - data.putString("Player", entry.getKey().toString()); - data.putString("World", point.worldId); - data.putInt("X", point.x); - data.putInt("Y", point.y); - data.putInt("Z", point.z); - data.putFloat("Yaw", point.yaw); - data.putFloat("Pitch", point.pitch); - returns.add(data); - } - nbt.put("ReturnPoints", returns); - - return nbt; - } - - public void dirty() { - markDirty(); - } - - public record ReturnPointData(String worldId, int x, int y, int z, float yaw, float pitch) { - } -} \ No newline at end of file diff --git a/src/main/java/dev/qynl/myfirstmod/item/ModItems.java b/src/main/java/dev/qynl/myfirstmod/item/ModItems.java deleted file mode 100644 index 9817bc5..0000000 --- a/src/main/java/dev/qynl/myfirstmod/item/ModItems.java +++ /dev/null @@ -1,25 +0,0 @@ -package dev.qynl.myfirstmod.item; - -import dev.qynl.myfirstmod.MyFirstMod; -import net.minecraft.item.Item; -import net.minecraft.item.ToolMaterial; -import net.minecraft.item.Items; -import net.minecraft.registry.Registries; -import net.minecraft.registry.Registry; -import net.minecraft.util.Identifier; - -public final class ModItems { - public static final Item NULL_RELIC = Registry.register( - Registries.ITEM, - Identifier.of(MyFirstMod.MOD_ID, "null_relic"), - new Item(new Item.Settings().maxCount(1)) - ); - - public static final Item NULLBLADE = Registry.register( - Registries.ITEM, - Identifier.of(MyFirstMod.MOD_ID, "nullblade"), - new NullbladeItem(new Item.Settings().maxCount(1).maxDamage(2031)) - ); - - public static void register() {} -} diff --git a/src/main/java/dev/qynl/myfirstmod/item/NullbladeItem.java b/src/main/java/dev/qynl/myfirstmod/item/NullbladeItem.java deleted file mode 100644 index 6677808..0000000 --- a/src/main/java/dev/qynl/myfirstmod/item/NullbladeItem.java +++ /dev/null @@ -1,99 +0,0 @@ -package dev.qynl.myfirstmod.item; - -import net.minecraft.entity.EquipmentSlot; -import net.minecraft.entity.LivingEntity; -import net.minecraft.entity.player.PlayerEntity; -import net.minecraft.item.ItemStack; -import net.minecraft.item.SwordItem; -import net.minecraft.item.ToolMaterial; -import net.minecraft.item.ToolMaterials; -import net.minecraft.particle.ParticleTypes; -import net.minecraft.server.world.ServerWorld; -import net.minecraft.sound.SoundCategory; -import net.minecraft.sound.SoundEvents; -import net.minecraft.util.TypedActionResult; -import net.minecraft.util.Hand; -import net.minecraft.util.math.Box; -import net.minecraft.world.World; - -public class NullbladeItem extends SwordItem { - public NullbladeItem(Settings settings) { - super(ToolMaterials.NETHERITE, settings); - } - - @Override - public TypedActionResult use(World world, PlayerEntity user, Hand hand) { - ItemStack stack = user.getStackInHand(hand); - - if (user.getItemCooldownManager().isCoolingDown(this)) { - return TypedActionResult.fail(stack); - } - - user.getItemCooldownManager().set(this, 70); - - if (!world.isClient && world instanceof ServerWorld serverWorld) { - double radius = 5.0; - Box area = user.getBoundingBox().expand(radius); - for (LivingEntity target : serverWorld.getEntitiesByClass( - LivingEntity.class, - area, - entity -> entity != user && entity.isAlive() - )) { - target.damage(serverWorld.getDamageSources().playerAttack(user), 8.0F); - target.addVelocity( - target.getX() - user.getX(), - 0.15, - target.getZ() - user.getZ() - ); - } - - for (int i = 0; i < 80; i++) { - double angle = i * Math.PI * 2.0 / 80.0; - serverWorld.spawnParticles( - ParticleTypes.REVERSE_PORTAL, - user.getX() + Math.cos(angle) * radius, - user.getY() + 0.6, - user.getZ() + Math.sin(angle) * radius, - 1, 0, 0, 0, 0 - ); - } - - serverWorld.playSound( - null, - user.getBlockPos(), - SoundEvents.ENTITY_ENDERMAN_TELEPORT, - SoundCategory.PLAYERS, - 1.4F, - 0.55F - ); - } - - return TypedActionResult.success(stack, world.isClient); - } - - @Override - public boolean postHit(ItemStack stack, LivingEntity target, LivingEntity attacker) { - if (attacker instanceof PlayerEntity player && !player.getWorld().isClient) { - target.addVelocity(0, 0.18, 0); - stack.damage(1, player, EquipmentSlot.MAINHAND); - } - return true; - } - - public static void tick(net.minecraft.server.MinecraftServer server) { - for (ServerWorld world : server.getWorlds()) { - for (PlayerEntity player : world.getPlayers()) { - ItemStack stack = player.getMainHandStack(); - if (stack.getItem() instanceof NullbladeItem && player.age % 4 == 0) { - world.spawnParticles( - ParticleTypes.REVERSE_PORTAL, - player.getX(), - player.getY() + 1, - player.getZ(), - 2, .25, .45, .25, .01 - ); - } - } - } - } -} diff --git a/src/main/java/dev/qynl/myfirstmod/portal/VoidPortalManager.java b/src/main/java/dev/qynl/myfirstmod/portal/VoidPortalManager.java deleted file mode 100644 index 13e1dec..0000000 --- a/src/main/java/dev/qynl/myfirstmod/portal/VoidPortalManager.java +++ /dev/null @@ -1,160 +0,0 @@ -package dev.qynl.myfirstmod.portal; - -import dev.qynl.myfirstmod.MyFirstMod; -import dev.qynl.myfirstmod.block.ModBlocks; -import dev.qynl.myfirstmod.boss.NullWardenManager; -import dev.qynl.myfirstmod.boss.NullWardenState; -import net.minecraft.block.Blocks; -import net.minecraft.entity.Entity; -import net.minecraft.particle.ParticleTypes; -import net.minecraft.registry.RegistryKey; -import net.minecraft.registry.RegistryKeys; -import net.minecraft.server.MinecraftServer; -import net.minecraft.server.network.ServerPlayerEntity; -import net.minecraft.server.world.ServerWorld; -import net.minecraft.sound.SoundCategory; -import net.minecraft.sound.SoundEvents; -import net.minecraft.text.Text; -import net.minecraft.util.Identifier; -import net.minecraft.util.math.BlockPos; -import net.minecraft.world.World; - -import java.util.HashMap; -import java.util.Map; -import java.util.UUID; - -public final class VoidPortalManager { - public static final RegistryKey NULL_REALM = - RegistryKey.of(RegistryKeys.WORLD, Identifier.of(MyFirstMod.MOD_ID, "null_realm")); - - private static final int TELEPORT_COOLDOWN = 80; - private static final Map COOLDOWNS = new HashMap<>(); - - private VoidPortalManager() {} - - public static boolean tryIgnite(ServerPlayerEntity player, BlockPos clicked) { - ServerWorld world = player.getServerWorld(); - - if (!nearSculk(world, clicked, 14) || !validSeal(world, clicked)) { - player.sendMessage(Text.literal("The seal is incomplete."), true); - return false; - } - - for (int y = 1; y <= 3; y++) { - for (int x = 1; x <= 2; x++) { - world.setBlockState(clicked.add(x, y, 0), ModBlocks.VOID_PORTAL.getDefaultState()); - } - } - - world.playSound(null, clicked, SoundEvents.BLOCK_RESPAWN_ANCHOR_CHARGE, - SoundCategory.BLOCKS, 1.6F, 0.55F); - world.spawnParticles(ParticleTypes.REVERSE_PORTAL, - clicked.getX() + 2.0, clicked.getY() + 2.5, clicked.getZ() + 0.5, - 180, 1.4, 2.0, 0.4, 0.12); - player.sendMessage(Text.literal("The ancient seal opens..."), true); - return true; - } - - private static boolean validSeal(ServerWorld world, BlockPos origin) { - for (int y = 0; y <= 4; y++) { - for (int x = 0; x <= 3; x++) { - boolean frame = y == 0 || y == 4 || x == 0 || x == 3; - if (frame && !world.getBlockState(origin.add(x, y, 0)) - .isOf(Blocks.REINFORCED_DEEPSLATE)) { - return false; - } - } - } - - for (int y = 1; y <= 3; y++) { - for (int x = 1; x <= 2; x++) { - if (!world.getBlockState(origin.add(x, y, 0)).isAir() - && !world.getBlockState(origin.add(x, y, 0)).isOf(ModBlocks.VOID_PORTAL)) { - return false; - } - } - } - - return true; - } - - private static boolean nearSculk(ServerWorld world, BlockPos center, int radius) { - for (BlockPos p : BlockPos.iterateOutwards(center, radius, 5, radius)) { - if (world.getBlockState(p).isOf(Blocks.SCULK_CATALYST)) return true; - } - return false; - } - - public static void tryEnter(Entity entity) { - if (!(entity instanceof ServerPlayerEntity player)) return; - - int cooldown = COOLDOWNS.getOrDefault(player.getUuid(), 0); - if (cooldown > 0) return; - - MinecraftServer server = player.getServer(); - if (server == null) return; - - if (player.getServerWorld().getRegistryKey().equals(NULL_REALM)) { - COOLDOWNS.put(player.getUuid(), TELEPORT_COOLDOWN); - returnPlayer(player); - return; - } - - ServerWorld target = server.getWorld(NULL_REALM); - if (target == null) { - player.sendMessage(Text.literal("The Null Realm cannot be reached."), true); - return; - } - - COOLDOWNS.put(player.getUuid(), TELEPORT_COOLDOWN); - - BlockPos returnPos = player.getBlockPos(); - RegistryKey returnWorld = player.getServerWorld().getRegistryKey(); - float returnYaw = player.getYaw(); - float returnPitch = player.getPitch(); - - // Return data is owned by the Null Realm encounter state, not the - // origin world's state. This makes returning work across dimensions - // and survives a server restart. - NullWardenManager.saveReturnPoint( - player, returnWorld, returnPos, returnYaw, returnPitch - ); - - player.teleport(target, 0.5, 82.0, 0.5, player.getYaw(), player.getPitch()); - NullWardenManager.enterArena(target, player); - } - - public static void tick(MinecraftServer server) { - COOLDOWNS.replaceAll((uuid, value) -> Math.max(0, value - 1)); - COOLDOWNS.entrySet().removeIf(entry -> entry.getValue() == 0); - - NullWardenManager.tick(server); - } - - public static void returnPlayer(ServerPlayerEntity player) { - MinecraftServer server = player.getServer(); - NullWardenState.ReturnPointData point = NullWardenManager.takeReturnPoint(player); - - ServerWorld destination = server.getOverworld(); - if (point != null) { - Identifier id = Identifier.tryParse(point.worldId()); - if (id != null) { - ServerWorld stored = server.getWorld(RegistryKey.of(RegistryKeys.WORLD, id)); - if (stored != null) destination = stored; - } - } - - BlockPos spawn = point == null - ? destination.getSpawnPos() - : new BlockPos(point.x(), point.y(), point.z()); - - player.teleport(destination, - spawn.getX() + 0.5, - spawn.getY() + 1.0, - spawn.getZ() + 0.5, - point == null ? player.getYaw() : point.yaw(), - point == null ? player.getPitch() : point.pitch()); - - player.sendMessage(Text.literal("The gate closes behind you."), true); - } -} diff --git a/src/main/resources/assets/myfirstmod/blockstates/void_portal.json b/src/main/resources/assets/myfirstmod/blockstates/void_portal.json deleted file mode 100644 index 741f76a..0000000 --- a/src/main/resources/assets/myfirstmod/blockstates/void_portal.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "variants": { - "": { - "model": "myfirstmod:block/void_portal" - } - } -} \ No newline at end of file diff --git a/src/main/resources/assets/myfirstmod/items/null_relic.json b/src/main/resources/assets/myfirstmod/items/null_relic.json deleted file mode 100644 index b991cbd..0000000 --- a/src/main/resources/assets/myfirstmod/items/null_relic.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "model": { - "type": "minecraft:model", - "model": "myfirstmod:item/null_relic" - } -} \ No newline at end of file diff --git a/src/main/resources/assets/myfirstmod/items/nullblade.json b/src/main/resources/assets/myfirstmod/items/nullblade.json deleted file mode 100644 index d17295e..0000000 --- a/src/main/resources/assets/myfirstmod/items/nullblade.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "model": { - "type": "minecraft:model", - "model": "myfirstmod:item/nullblade" - } -} \ No newline at end of file diff --git a/src/main/resources/assets/myfirstmod/lang/en_us.json b/src/main/resources/assets/myfirstmod/lang/en_us.json deleted file mode 100644 index 41a0bf0..0000000 --- a/src/main/resources/assets/myfirstmod/lang/en_us.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "item.myfirstmod.null_relic": "Heart of the Null", - "item.myfirstmod.nullblade": "Nullblade", - "block.myfirstmod.void_portal": "Void Gate" -} diff --git a/src/main/resources/assets/myfirstmod/models/block/void_portal.json b/src/main/resources/assets/myfirstmod/models/block/void_portal.json deleted file mode 100644 index 8385237..0000000 --- a/src/main/resources/assets/myfirstmod/models/block/void_portal.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "parent": "minecraft:block/cube_all", - "textures": { - "all": "minecraft:block/portal" - } -} \ No newline at end of file diff --git a/src/main/resources/assets/myfirstmod/models/item/null_relic.json b/src/main/resources/assets/myfirstmod/models/item/null_relic.json deleted file mode 100644 index a6f71e7..0000000 --- a/src/main/resources/assets/myfirstmod/models/item/null_relic.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "parent": "minecraft:item/generated", - "textures": { - "layer0": "minecraft:item/echo_shard" - } -} \ No newline at end of file diff --git a/src/main/resources/assets/myfirstmod/models/item/nullblade.json b/src/main/resources/assets/myfirstmod/models/item/nullblade.json deleted file mode 100644 index c66def6..0000000 --- a/src/main/resources/assets/myfirstmod/models/item/nullblade.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "textures": { - "blade": "minecraft:block/sculk", - "edge": "minecraft:block/crying_obsidian", - "core": "minecraft:block/amethyst_block", - "hilt": "minecraft:block/reinforced_deepslate" - }, - "elements": [ - {"from":[7,1,7],"to":[9,5,9],"faces":{"north":{"texture":"#hilt"},"south":{"texture":"#hilt"},"east":{"texture":"#hilt"},"west":{"texture":"#hilt"},"up":{"texture":"#hilt"},"down":{"texture":"#hilt"}}}, - {"from":[6.5,5,7.2],"to":[9.5,7,8.8],"faces":{"north":{"texture":"#core"},"south":{"texture":"#core"},"east":{"texture":"#hilt"},"west":{"texture":"#hilt"},"up":{"texture":"#core"},"down":{"texture":"#hilt"}}}, - {"from":[7.1,7,7.35],"to":[8.9,15,8.65],"faces":{"north":{"texture":"#blade"},"south":{"texture":"#blade"},"east":{"texture":"#edge"},"west":{"texture":"#edge"},"up":{"texture":"#blade"},"down":{"texture":"#blade"}}}, - {"from":[7.25,12,7.2],"to":[8.75,15.8,8.8],"faces":{"north":{"texture":"#core"},"south":{"texture":"#core"},"east":{"texture":"#edge"},"west":{"texture":"#edge"},"up":{"texture":"#core"},"down":{"texture":"#blade"}}}, - {"from":[7.4,15.5,7.35],"to":[8.6,16,8.65],"faces":{"north":{"texture":"#edge"},"south":{"texture":"#edge"},"east":{"texture":"#edge"},"west":{"texture":"#edge"},"up":{"texture":"#core"},"down":{"texture":"#edge"}}} - ], - "display": { - "thirdperson_righthand":{"rotation":[0,90,-25],"translation":[0,1.5,0],"scale":[0.9,0.9,0.9]}, - "thirdperson_lefthand":{"rotation":[0,-90,25],"translation":[0,1.5,0],"scale":[0.9,0.9,0.9]}, - "firstperson_righthand":{"rotation":[0,-75,25],"translation":[0,3,0],"scale":[0.9,0.9,0.9]}, - "firstperson_lefthand":{"rotation":[0,75,-25],"translation":[0,3,0],"scale":[0.9,0.9,0.9]}, - "gui":{"rotation":[15,-25,10],"translation":[0,0,0],"scale":[1.15,1.15,1.15]}, - "ground":{"rotation":[0,0,0],"translation":[0,1,0],"scale":[0.8,0.8,0.8]} - } -} \ No newline at end of file diff --git a/src/main/resources/data/myfirstmod/dimension/null_realm.json b/src/main/resources/data/myfirstmod/dimension/null_realm.json deleted file mode 100644 index 7f14487..0000000 --- a/src/main/resources/data/myfirstmod/dimension/null_realm.json +++ /dev/null @@ -1,11 +0,0 @@ -{ - "type": "myfirstmod:null_realm", - "generator": { - "type": "minecraft:noise", - "settings": "minecraft:end", - "biome_source": { - "type": "minecraft:fixed", - "biome": "minecraft:the_end" - } - } -} \ No newline at end of file diff --git a/src/main/resources/data/myfirstmod/dimension_type/null_realm.json b/src/main/resources/data/myfirstmod/dimension_type/null_realm.json deleted file mode 100644 index 4b9d089..0000000 --- a/src/main/resources/data/myfirstmod/dimension_type/null_realm.json +++ /dev/null @@ -1,24 +0,0 @@ -{ - "ultrawarm": false, - "natural": false, - "ambient_light": 0.05, - "infiniburn": "minecraft:infiniburn_overworld", - "respawn_anchor_works": false, - "has_skylight": false, - "bed_works": false, - "effects": "minecraft:the_end", - "min_y": -64, - "height": 384, - "logical_height": 384, - "coordinate_scale": 1.0, - "piglin_safe": true, - "has_raids": false, - "monster_spawn_block_light_limit": 0, - "monster_spawn_light_level": { - "type": "minecraft:uniform", - "value": { - "min_inclusive": 0, - "max_inclusive": 7 - } - } -} \ No newline at end of file diff --git a/src/main/resources/data/myfirstmod/loot_tables/entities/null_warden.json b/src/main/resources/data/myfirstmod/loot_tables/entities/null_warden.json deleted file mode 100644 index 62b949a..0000000 --- a/src/main/resources/data/myfirstmod/loot_tables/entities/null_warden.json +++ /dev/null @@ -1,14 +0,0 @@ -{ - "type": "minecraft:entity", - "pools": [ - { - "rolls": 1, - "entries": [ - { - "type": "minecraft:item", - "name": "myfirstmod:null_relic" - } - ] - } - ] -} \ No newline at end of file diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json deleted file mode 100644 index 3d59fe7..0000000 --- a/src/main/resources/fabric.mod.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "schemaVersion": 1, - "id": "myfirstmod", - "version": "${version}", - "name": "The Null Warden", - "description": "A handcrafted Ancient City boss encounter built around the Null Warden, its realm, and the Nullblade.", - "authors": ["Qynl"], - "contact": { - "sources": "https://github.com/Qynl/MyFirstMod" - }, - "license": "MIT", - "environment": "*", - "entrypoints": { - "main": [ - "dev.qynl.myfirstmod.MyFirstMod" - ], - "client": [ - "dev.qynl.myfirstmod.MyFirstModClient" - ] - }, - "depends": { - "fabricloader": ">=0.16.10", - "minecraft": "~1.21.1", - "java": ">=21", - "fabric-api": "*" - } -} \ No newline at end of file From e8cf677b90b5d6d06da39e9f095d416eb458213a Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:40:04 +0000 Subject: [PATCH 02/24] FabricRacers: cars, AI drivers, circuits and a full race engine Complete rewrite of the mod on Minecraft 1.21.1 / Fabric: - cars: arcade-sim physics (server authoritative), damage, paint, nine chassis - tuning: seven upgrade systems with parts, garage screen and villager trade economy - AI drivers: Catmull-Rom racing line, corner speed, overtaking, drift, boost, mistakes - circuits: five generated stock tracks plus a build wand and a laps-to-track recorder - race engine: grids, countdown, checkpoints, laps, standings, pit stops, prizes, profiles - assets: procedural textures, generated structures, full translations, asset validator Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 34 + .gitignore | 27 + LICENSE | 21 + README.md | 185 +++ build.gradle | 75 ++ gradle.properties | 14 + gradle/wrapper/gradle-wrapper.jar | Bin 0 -> 48966 bytes gradle/wrapper/gradle-wrapper.properties | 7 + gradlew | 248 ++++ gradlew.bat | 98 ++ settings.gradle | 12 + .../qynl/racers/client/ClientRaceState.java | 259 ++++ .../racers/client/FabricRacersClient.java | 57 + .../qynl/racers/client/StopwatchClient.java | 36 + .../dev/qynl/racers/client/hud/RaceHud.java | 181 +++ .../qynl/racers/client/render/CarMesh.java | 296 +++++ .../racers/client/render/CarRenderer.java | 99 ++ .../racers/client/screen/GarageScreen.java | 133 ++ .../racers/client/screen/RacePanelScreen.java | 144 +++ .../client/screen/TrackSelectScreen.java | 98 ++ .../java/dev/qynl/racers/FabricRacers.java | 86 ++ .../java/dev/qynl/racers/ai/AiDriver.java | 344 ++++++ .../java/dev/qynl/racers/ai/RacingLine.java | 216 ++++ .../dev/qynl/racers/block/RacerBlocks.java | 197 +++ .../java/dev/qynl/racers/car/AiCarEntity.java | 26 + .../java/dev/qynl/racers/car/CarControls.java | 100 ++ .../java/dev/qynl/racers/car/CarData.java | 164 +++ .../java/dev/qynl/racers/car/CarEntity.java | 1081 +++++++++++++++++ .../java/dev/qynl/racers/car/CarSpec.java | 46 + .../java/dev/qynl/racers/car/CarStats.java | 66 + .../java/dev/qynl/racers/car/CarType.java | 113 ++ .../java/dev/qynl/racers/car/PaintColors.java | 145 +++ .../dev/qynl/racers/car/PlayerCarEntity.java | 19 + .../java/dev/qynl/racers/car/SurfaceType.java | 92 ++ src/main/java/dev/qynl/racers/car/Tune.java | 48 + .../dev/qynl/racers/command/RaceCommands.java | 508 ++++++++ .../qynl/racers/event/CarServerEvents.java | 71 ++ .../java/dev/qynl/racers/item/CarItem.java | 132 ++ .../java/dev/qynl/racers/item/ToolItems.java | 348 ++++++ .../dev/qynl/racers/net/ModNetworking.java | 289 +++++ .../dev/qynl/racers/net/ServerActions.java | 342 ++++++ .../java/dev/qynl/racers/race/RaceEntry.java | 65 + .../java/dev/qynl/racers/race/RaceFormat.java | 49 + .../dev/qynl/racers/race/RaceManager.java | 264 ++++ .../dev/qynl/racers/race/RaceSession.java | 548 +++++++++ .../dev/qynl/racers/registry/ModBlocks.java | 69 ++ .../qynl/racers/registry/ModComponents.java | 100 ++ .../dev/qynl/racers/registry/ModEntities.java | 36 + .../dev/qynl/racers/registry/ModItems.java | 208 ++++ .../dev/qynl/racers/registry/ModSounds.java | 18 + .../java/dev/qynl/racers/shop/PartShop.java | 92 ++ .../dev/qynl/racers/stats/RacerProfile.java | 157 +++ .../dev/qynl/racers/stats/StatsManager.java | 275 +++++ .../java/dev/qynl/racers/track/Track.java | 140 +++ .../dev/qynl/racers/track/TrackManager.java | 428 +++++++ .../dev/qynl/racers/track/TrackPlacement.java | 173 +++ .../dev/qynl/racers/track/TrackRecorder.java | 288 +++++ .../dev/qynl/racers/track/TrackStructure.java | 376 ++++++ src/main/java/dev/qynl/racers/util/Cars.java | 64 + .../java/dev/qynl/racers/util/Interp.java | 51 + .../java/dev/qynl/racers/util/Sounds.java | 22 + .../java/dev/qynl/racers/util/Stopwatch.java | 118 ++ .../fabricracers/blockstates/asphalt.json | 7 + .../blockstates/asphalt_cracked.json | 7 + .../fabricracers/blockstates/boost_pad.json | 7 + .../blockstates/checkpoint_marker.json | 10 + .../fabricracers/blockstates/fake_ice.json | 7 + .../fabricracers/blockstates/fake_slime.json | 7 + .../fabricracers/blockstates/finish_line.json | 7 + .../assets/fabricracers/blockstates/kerb.json | 10 + .../fabricracers/blockstates/pit_lane.json | 7 + .../fabricracers/blockstates/road_block.json | 7 + .../blockstates/safety_barrier.json | 7 + .../fabricracers/blockstates/start_gate.json | 7 + .../fabricracers/blockstates/station.json | 7 + .../assets/fabricracers/lang/en_us.json | 144 +++ .../fabricracers/models/block/asphalt.json | 6 + .../models/block/asphalt_cracked.json | 6 + .../fabricracers/models/block/boost_pad.json | 6 + .../models/block/checkpoint_marker_off.json | 6 + .../models/block/checkpoint_marker_on.json | 6 + .../fabricracers/models/block/fake_ice.json | 7 + .../fabricracers/models/block/fake_slime.json | 7 + .../models/block/finish_line.json | 6 + .../fabricracers/models/block/kerb_red.json | 6 + .../fabricracers/models/block/kerb_white.json | 6 + .../fabricracers/models/block/pit_lane.json | 6 + .../fabricracers/models/block/road_block.json | 6 + .../models/block/safety_barrier.json | 6 + .../fabricracers/models/block/start_gate.json | 6 + .../fabricracers/models/block/station.json | 6 + .../fabricracers/models/item/aero_wing.json | 6 + .../fabricracers/models/item/asphalt.json | 3 + .../models/item/asphalt_cracked.json | 3 + .../fabricracers/models/item/boost_nitro.json | 6 + .../fabricracers/models/item/boost_pad.json | 3 + .../models/item/brakes_carbon.json | 6 + .../fabricracers/models/item/car_drift.json | 6 + .../fabricracers/models/item/car_f1.json | 6 + .../fabricracers/models/item/car_hotrod.json | 6 + .../fabricracers/models/item/car_hyper.json | 6 + .../fabricracers/models/item/car_kart.json | 6 + .../fabricracers/models/item/car_rally.json | 6 + .../fabricracers/models/item/car_shadow.json | 6 + .../fabricracers/models/item/car_sport.json | 6 + .../fabricracers/models/item/car_truck.json | 6 + .../fabricracers/models/item/carrot_key.json | 6 + .../models/item/chassis_plate.json | 6 + .../models/item/checkpoint_marker.json | 3 + .../models/item/engine_turbo.json | 6 + .../fabricracers/models/item/fake_ice.json | 3 + .../fabricracers/models/item/fake_slime.json | 3 + .../fabricracers/models/item/finish_line.json | 3 + .../fabricracers/models/item/gearbox_dct.json | 6 + .../assets/fabricracers/models/item/kerb.json | 3 + .../fabricracers/models/item/paint_black.json | 6 + .../fabricracers/models/item/paint_blue.json | 6 + .../fabricracers/models/item/paint_green.json | 6 + .../models/item/paint_orange.json | 6 + .../models/item/paint_purple.json | 6 + .../fabricracers/models/item/paint_red.json | 6 + .../fabricracers/models/item/paint_white.json | 6 + .../models/item/paint_yellow.json | 6 + .../fabricracers/models/item/pit_lane.json | 3 + .../fabricracers/models/item/race_pass.json | 6 + .../fabricracers/models/item/road_block.json | 3 + .../models/item/safety_barrier.json | 3 + .../fabricracers/models/item/start_gate.json | 3 + .../fabricracers/models/item/station.json | 3 + .../fabricracers/models/item/stopwatch.json | 6 + .../models/item/tires_street.json | 6 + .../fabricracers/models/item/track_wand.json | 6 + .../models/item/trophy_bronze.json | 6 + .../fabricracers/models/item/trophy_gold.json | 6 + .../models/item/trophy_silver.json | 6 + .../fabricracers/models/item/wrench.json | 6 + .../fabricracers/textures/block/asphalt.png | Bin 0 -> 409 bytes .../textures/block/asphalt_cracked.png | Bin 0 -> 407 bytes .../fabricracers/textures/block/boost_pad.png | Bin 0 -> 207 bytes .../textures/block/boost_pad.png.mcmeta | 6 + .../textures/block/checkpoint_marker.png | Bin 0 -> 132 bytes .../fabricracers/textures/block/fake_ice.png | Bin 0 -> 263 bytes .../textures/block/fake_slime.png | Bin 0 -> 203 bytes .../textures/block/finish_line.png | Bin 0 -> 142 bytes .../fabricracers/textures/block/kerb_red.png | Bin 0 -> 126 bytes .../textures/block/kerb_white.png | Bin 0 -> 127 bytes .../fabricracers/textures/block/pit_lane.png | Bin 0 -> 368 bytes .../textures/block/road_block.png | Bin 0 -> 419 bytes .../textures/block/safety_barrier.png | Bin 0 -> 116 bytes .../textures/block/start_gate.png | Bin 0 -> 231 bytes .../textures/block/start_gate.png.mcmeta | 6 + .../fabricracers/textures/block/station.png | Bin 0 -> 279 bytes .../textures/entity/car/drift.png | Bin 0 -> 2081 bytes .../fabricracers/textures/entity/car/f1.png | Bin 0 -> 2138 bytes .../textures/entity/car/flame.png | Bin 0 -> 167 bytes .../textures/entity/car/hotrod.png | Bin 0 -> 2136 bytes .../textures/entity/car/hyper.png | Bin 0 -> 2087 bytes .../fabricracers/textures/entity/car/kart.png | Bin 0 -> 2101 bytes .../textures/entity/car/phantom.png | Bin 0 -> 2121 bytes .../textures/entity/car/rally.png | Bin 0 -> 2151 bytes .../textures/entity/car/shade.png | Bin 0 -> 2039 bytes .../textures/entity/car/shadow.png | Bin 0 -> 2019 bytes .../textures/entity/car/sport.png | Bin 0 -> 2108 bytes .../textures/entity/car/template.png | Bin 0 -> 2102 bytes .../textures/entity/car/truck.png | Bin 0 -> 2064 bytes .../textures/entity/car/wraith.png | Bin 0 -> 2016 bytes .../fabricracers/textures/item/aero_wing.png | Bin 0 -> 123 bytes .../textures/item/boost_nitro.png | Bin 0 -> 128 bytes .../textures/item/brakes_carbon.png | Bin 0 -> 151 bytes .../fabricracers/textures/item/car_drift.png | Bin 0 -> 165 bytes .../fabricracers/textures/item/car_f1.png | Bin 0 -> 145 bytes .../fabricracers/textures/item/car_hotrod.png | Bin 0 -> 165 bytes .../fabricracers/textures/item/car_hyper.png | Bin 0 -> 170 bytes .../fabricracers/textures/item/car_kart.png | Bin 0 -> 151 bytes .../fabricracers/textures/item/car_rally.png | Bin 0 -> 152 bytes .../fabricracers/textures/item/car_shadow.png | Bin 0 -> 170 bytes .../fabricracers/textures/item/car_sport.png | Bin 0 -> 165 bytes .../fabricracers/textures/item/car_truck.png | Bin 0 -> 166 bytes .../textures/item/carbon_weave.png | Bin 0 -> 106 bytes .../fabricracers/textures/item/carrot_key.png | Bin 0 -> 170 bytes .../textures/item/chassis_plate.png | Bin 0 -> 114 bytes .../textures/item/engine_turbo.png | Bin 0 -> 158 bytes .../fabricracers/textures/item/engine_v8.png | Bin 0 -> 125 bytes .../textures/item/gearbox_dct.png | Bin 0 -> 117 bytes .../textures/item/gearbox_sequential.png | Bin 0 -> 129 bytes .../textures/item/ground_effect.png | Bin 0 -> 114 bytes .../textures/item/paint_black.png | Bin 0 -> 132 bytes .../fabricracers/textures/item/paint_blue.png | Bin 0 -> 132 bytes .../textures/item/paint_green.png | Bin 0 -> 132 bytes .../textures/item/paint_orange.png | Bin 0 -> 133 bytes .../textures/item/paint_purple.png | Bin 0 -> 133 bytes .../fabricracers/textures/item/paint_red.png | Bin 0 -> 132 bytes .../textures/item/paint_white.png | Bin 0 -> 133 bytes .../textures/item/paint_yellow.png | Bin 0 -> 133 bytes .../fabricracers/textures/item/race_guide.png | Bin 0 -> 121 bytes .../fabricracers/textures/item/race_pass.png | Bin 0 -> 122 bytes .../fabricracers/textures/item/roll_cage.png | Bin 0 -> 126 bytes .../fabricracers/textures/item/stopwatch.png | Bin 0 -> 165 bytes .../fabricracers/textures/item/tires_hard.png | Bin 0 -> 149 bytes .../textures/item/tires_medium.png | Bin 0 -> 149 bytes .../fabricracers/textures/item/tires_soft.png | Bin 0 -> 149 bytes .../textures/item/tires_street.png | Bin 0 -> 149 bytes .../fabricracers/textures/item/track_wand.png | Bin 0 -> 134 bytes .../textures/item/trophy_bronze.png | Bin 0 -> 129 bytes .../textures/item/trophy_gold.png | Bin 0 -> 130 bytes .../textures/item/trophy_silver.png | Bin 0 -> 130 bytes .../fabricracers/textures/item/wrench.png | Bin 0 -> 116 bytes .../loot_table/blocks/asphalt.json | 20 + .../loot_table/blocks/asphalt_cracked.json | 20 + .../loot_table/blocks/boost_pad.json | 20 + .../loot_table/blocks/checkpoint_marker.json | 20 + .../loot_table/blocks/fake_ice.json | 20 + .../loot_table/blocks/fake_slime.json | 20 + .../loot_table/blocks/finish_line.json | 20 + .../fabricracers/loot_table/blocks/kerb.json | 20 + .../loot_table/blocks/pit_lane.json | 20 + .../loot_table/blocks/road_block.json | 20 + .../loot_table/blocks/safety_barrier.json | 20 + .../loot_table/blocks/start_gate.json | 20 + .../loot_table/blocks/station.json | 20 + .../data/fabricracers/recipe/asphalt.json | 19 + .../data/fabricracers/recipe/boost_nitro.json | 18 + .../data/fabricracers/recipe/carrot_key.json | 19 + .../data/fabricracers/recipe/paint_black.json | 15 + .../data/fabricracers/recipe/paint_blue.json | 15 + .../data/fabricracers/recipe/paint_green.json | 15 + .../fabricracers/recipe/paint_orange.json | 15 + .../fabricracers/recipe/paint_purple.json | 15 + .../data/fabricracers/recipe/paint_red.json | 15 + .../data/fabricracers/recipe/paint_white.json | 15 + .../fabricracers/recipe/paint_yellow.json | 15 + .../fabricracers/recipe/safety_barrier.json | 16 + .../data/fabricracers/recipe/stopwatch.json | 18 + .../fabricracers/recipe/tires_street.json | 18 + .../data/fabricracers/recipe/track_wand.json | 20 + .../data/fabricracers/recipe/wrench.json | 18 + .../structure/tracks/asphalt_park.nbt | Bin 0 -> 37054 bytes .../structure/tracks/chaos_junction.nbt | Bin 0 -> 29458 bytes .../structure/tracks/drift_arena.nbt | Bin 0 -> 15702 bytes .../structure/tracks/kart_circuit.nbt | Bin 0 -> 12930 bytes .../structure/tracks/speed_oval.nbt | Bin 0 -> 42178 bytes .../fabricracers/tags/block/race_blocks.json | 18 + .../fabricracers/tags/item/paint_cans.json | 13 + .../data/fabricracers/tracks/_index.json | 9 + .../fabricracers/tracks/asphalt_park.json | 163 +++ .../fabricracers/tracks/chaos_junction.json | 133 ++ .../data/fabricracers/tracks/drift_arena.json | 111 ++ .../fabricracers/tracks/kart_circuit.json | 111 ++ .../data/fabricracers/tracks/speed_oval.json | 121 ++ .../tags/block/mineable/pickaxe.json | 14 + .../minecraft/tags/block/mineable/shovel.json | 6 + src/main/resources/fabric.mod.json | 34 + src/main/resources/icon.png | Bin 0 -> 480 bytes tools/__pycache__/track_lib.cpython-311.pyc | Bin 0 -> 52420 bytes tools/gen_textures.py | 1052 ++++++++++++++++ tools/gen_tracks.py | 389 ++++++ tools/preview_items.png | Bin 0 -> 21581 bytes tools/previews/asphalt_park.png | Bin 0 -> 2681 bytes tools/previews/chaos_junction.png | Bin 0 -> 2346 bytes tools/previews/drift_arena.png | Bin 0 -> 825 bytes tools/previews/kart_circuit.png | Bin 0 -> 1015 bytes tools/previews/speed_oval.png | Bin 0 -> 2855 bytes tools/track_lib.py | 689 +++++++++++ tools/validate_assets.py | 463 +++++++ 264 files changed, 14426 insertions(+) create mode 100644 .github/workflows/build.yml create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 build.gradle create mode 100644 gradle.properties create mode 100644 gradle/wrapper/gradle-wrapper.jar create mode 100644 gradle/wrapper/gradle-wrapper.properties create mode 100755 gradlew create mode 100644 gradlew.bat create mode 100644 settings.gradle create mode 100644 src/client/java/dev/qynl/racers/client/ClientRaceState.java create mode 100644 src/client/java/dev/qynl/racers/client/FabricRacersClient.java create mode 100644 src/client/java/dev/qynl/racers/client/StopwatchClient.java create mode 100644 src/client/java/dev/qynl/racers/client/hud/RaceHud.java create mode 100644 src/client/java/dev/qynl/racers/client/render/CarMesh.java create mode 100644 src/client/java/dev/qynl/racers/client/render/CarRenderer.java create mode 100644 src/client/java/dev/qynl/racers/client/screen/GarageScreen.java create mode 100644 src/client/java/dev/qynl/racers/client/screen/RacePanelScreen.java create mode 100644 src/client/java/dev/qynl/racers/client/screen/TrackSelectScreen.java create mode 100644 src/main/java/dev/qynl/racers/FabricRacers.java create mode 100644 src/main/java/dev/qynl/racers/ai/AiDriver.java create mode 100644 src/main/java/dev/qynl/racers/ai/RacingLine.java create mode 100644 src/main/java/dev/qynl/racers/block/RacerBlocks.java create mode 100644 src/main/java/dev/qynl/racers/car/AiCarEntity.java create mode 100644 src/main/java/dev/qynl/racers/car/CarControls.java create mode 100644 src/main/java/dev/qynl/racers/car/CarData.java create mode 100644 src/main/java/dev/qynl/racers/car/CarEntity.java create mode 100644 src/main/java/dev/qynl/racers/car/CarSpec.java create mode 100644 src/main/java/dev/qynl/racers/car/CarStats.java create mode 100644 src/main/java/dev/qynl/racers/car/CarType.java create mode 100644 src/main/java/dev/qynl/racers/car/PaintColors.java create mode 100644 src/main/java/dev/qynl/racers/car/PlayerCarEntity.java create mode 100644 src/main/java/dev/qynl/racers/car/SurfaceType.java create mode 100644 src/main/java/dev/qynl/racers/car/Tune.java create mode 100644 src/main/java/dev/qynl/racers/command/RaceCommands.java create mode 100644 src/main/java/dev/qynl/racers/event/CarServerEvents.java create mode 100644 src/main/java/dev/qynl/racers/item/CarItem.java create mode 100644 src/main/java/dev/qynl/racers/item/ToolItems.java create mode 100644 src/main/java/dev/qynl/racers/net/ModNetworking.java create mode 100644 src/main/java/dev/qynl/racers/net/ServerActions.java create mode 100644 src/main/java/dev/qynl/racers/race/RaceEntry.java create mode 100644 src/main/java/dev/qynl/racers/race/RaceFormat.java create mode 100644 src/main/java/dev/qynl/racers/race/RaceManager.java create mode 100644 src/main/java/dev/qynl/racers/race/RaceSession.java create mode 100644 src/main/java/dev/qynl/racers/registry/ModBlocks.java create mode 100644 src/main/java/dev/qynl/racers/registry/ModComponents.java create mode 100644 src/main/java/dev/qynl/racers/registry/ModEntities.java create mode 100644 src/main/java/dev/qynl/racers/registry/ModItems.java create mode 100644 src/main/java/dev/qynl/racers/registry/ModSounds.java create mode 100644 src/main/java/dev/qynl/racers/shop/PartShop.java create mode 100644 src/main/java/dev/qynl/racers/stats/RacerProfile.java create mode 100644 src/main/java/dev/qynl/racers/stats/StatsManager.java create mode 100644 src/main/java/dev/qynl/racers/track/Track.java create mode 100644 src/main/java/dev/qynl/racers/track/TrackManager.java create mode 100644 src/main/java/dev/qynl/racers/track/TrackPlacement.java create mode 100644 src/main/java/dev/qynl/racers/track/TrackRecorder.java create mode 100644 src/main/java/dev/qynl/racers/track/TrackStructure.java create mode 100644 src/main/java/dev/qynl/racers/util/Cars.java create mode 100644 src/main/java/dev/qynl/racers/util/Interp.java create mode 100644 src/main/java/dev/qynl/racers/util/Sounds.java create mode 100644 src/main/java/dev/qynl/racers/util/Stopwatch.java create mode 100644 src/main/resources/assets/fabricracers/blockstates/asphalt.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/asphalt_cracked.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/boost_pad.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/checkpoint_marker.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/fake_ice.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/fake_slime.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/finish_line.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/kerb.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/pit_lane.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/road_block.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/safety_barrier.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/start_gate.json create mode 100644 src/main/resources/assets/fabricracers/blockstates/station.json create mode 100644 src/main/resources/assets/fabricracers/lang/en_us.json create mode 100644 src/main/resources/assets/fabricracers/models/block/asphalt.json create mode 100644 src/main/resources/assets/fabricracers/models/block/asphalt_cracked.json create mode 100644 src/main/resources/assets/fabricracers/models/block/boost_pad.json create mode 100644 src/main/resources/assets/fabricracers/models/block/checkpoint_marker_off.json create mode 100644 src/main/resources/assets/fabricracers/models/block/checkpoint_marker_on.json create mode 100644 src/main/resources/assets/fabricracers/models/block/fake_ice.json create mode 100644 src/main/resources/assets/fabricracers/models/block/fake_slime.json create mode 100644 src/main/resources/assets/fabricracers/models/block/finish_line.json create mode 100644 src/main/resources/assets/fabricracers/models/block/kerb_red.json create mode 100644 src/main/resources/assets/fabricracers/models/block/kerb_white.json create mode 100644 src/main/resources/assets/fabricracers/models/block/pit_lane.json create mode 100644 src/main/resources/assets/fabricracers/models/block/road_block.json create mode 100644 src/main/resources/assets/fabricracers/models/block/safety_barrier.json create mode 100644 src/main/resources/assets/fabricracers/models/block/start_gate.json create mode 100644 src/main/resources/assets/fabricracers/models/block/station.json create mode 100644 src/main/resources/assets/fabricracers/models/item/aero_wing.json create mode 100644 src/main/resources/assets/fabricracers/models/item/asphalt.json create mode 100644 src/main/resources/assets/fabricracers/models/item/asphalt_cracked.json create mode 100644 src/main/resources/assets/fabricracers/models/item/boost_nitro.json create mode 100644 src/main/resources/assets/fabricracers/models/item/boost_pad.json create mode 100644 src/main/resources/assets/fabricracers/models/item/brakes_carbon.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_drift.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_f1.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_hotrod.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_hyper.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_kart.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_rally.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_shadow.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_sport.json create mode 100644 src/main/resources/assets/fabricracers/models/item/car_truck.json create mode 100644 src/main/resources/assets/fabricracers/models/item/carrot_key.json create mode 100644 src/main/resources/assets/fabricracers/models/item/chassis_plate.json create mode 100644 src/main/resources/assets/fabricracers/models/item/checkpoint_marker.json create mode 100644 src/main/resources/assets/fabricracers/models/item/engine_turbo.json create mode 100644 src/main/resources/assets/fabricracers/models/item/fake_ice.json create mode 100644 src/main/resources/assets/fabricracers/models/item/fake_slime.json create mode 100644 src/main/resources/assets/fabricracers/models/item/finish_line.json create mode 100644 src/main/resources/assets/fabricracers/models/item/gearbox_dct.json create mode 100644 src/main/resources/assets/fabricracers/models/item/kerb.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_black.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_blue.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_green.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_orange.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_purple.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_red.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_white.json create mode 100644 src/main/resources/assets/fabricracers/models/item/paint_yellow.json create mode 100644 src/main/resources/assets/fabricracers/models/item/pit_lane.json create mode 100644 src/main/resources/assets/fabricracers/models/item/race_pass.json create mode 100644 src/main/resources/assets/fabricracers/models/item/road_block.json create mode 100644 src/main/resources/assets/fabricracers/models/item/safety_barrier.json create mode 100644 src/main/resources/assets/fabricracers/models/item/start_gate.json create mode 100644 src/main/resources/assets/fabricracers/models/item/station.json create mode 100644 src/main/resources/assets/fabricracers/models/item/stopwatch.json create mode 100644 src/main/resources/assets/fabricracers/models/item/tires_street.json create mode 100644 src/main/resources/assets/fabricracers/models/item/track_wand.json create mode 100644 src/main/resources/assets/fabricracers/models/item/trophy_bronze.json create mode 100644 src/main/resources/assets/fabricracers/models/item/trophy_gold.json create mode 100644 src/main/resources/assets/fabricracers/models/item/trophy_silver.json create mode 100644 src/main/resources/assets/fabricracers/models/item/wrench.json create mode 100644 src/main/resources/assets/fabricracers/textures/block/asphalt.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/asphalt_cracked.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/boost_pad.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/boost_pad.png.mcmeta create mode 100644 src/main/resources/assets/fabricracers/textures/block/checkpoint_marker.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/fake_ice.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/fake_slime.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/finish_line.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/kerb_red.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/kerb_white.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/pit_lane.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/road_block.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/safety_barrier.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/start_gate.png create mode 100644 src/main/resources/assets/fabricracers/textures/block/start_gate.png.mcmeta create mode 100644 src/main/resources/assets/fabricracers/textures/block/station.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/drift.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/f1.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/flame.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/hotrod.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/hyper.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/kart.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/phantom.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/rally.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/shade.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/shadow.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/sport.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/template.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/truck.png create mode 100644 src/main/resources/assets/fabricracers/textures/entity/car/wraith.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/aero_wing.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/boost_nitro.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/brakes_carbon.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_drift.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_f1.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_hotrod.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_hyper.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_kart.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_rally.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_shadow.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_sport.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/car_truck.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/carbon_weave.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/carrot_key.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/chassis_plate.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/engine_turbo.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/engine_v8.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/gearbox_dct.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/gearbox_sequential.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/ground_effect.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_black.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_blue.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_green.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_orange.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_purple.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_red.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_white.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/paint_yellow.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/race_guide.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/race_pass.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/roll_cage.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/stopwatch.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/tires_hard.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/tires_medium.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/tires_soft.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/tires_street.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/track_wand.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/trophy_bronze.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/trophy_gold.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/trophy_silver.png create mode 100644 src/main/resources/assets/fabricracers/textures/item/wrench.png create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/asphalt.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/asphalt_cracked.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/boost_pad.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/checkpoint_marker.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/fake_ice.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/fake_slime.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/finish_line.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/kerb.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/pit_lane.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/road_block.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/safety_barrier.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/start_gate.json create mode 100644 src/main/resources/data/fabricracers/loot_table/blocks/station.json create mode 100644 src/main/resources/data/fabricracers/recipe/asphalt.json create mode 100644 src/main/resources/data/fabricracers/recipe/boost_nitro.json create mode 100644 src/main/resources/data/fabricracers/recipe/carrot_key.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_black.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_blue.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_green.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_orange.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_purple.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_red.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_white.json create mode 100644 src/main/resources/data/fabricracers/recipe/paint_yellow.json create mode 100644 src/main/resources/data/fabricracers/recipe/safety_barrier.json create mode 100644 src/main/resources/data/fabricracers/recipe/stopwatch.json create mode 100644 src/main/resources/data/fabricracers/recipe/tires_street.json create mode 100644 src/main/resources/data/fabricracers/recipe/track_wand.json create mode 100644 src/main/resources/data/fabricracers/recipe/wrench.json create mode 100644 src/main/resources/data/fabricracers/structure/tracks/asphalt_park.nbt create mode 100644 src/main/resources/data/fabricracers/structure/tracks/chaos_junction.nbt create mode 100644 src/main/resources/data/fabricracers/structure/tracks/drift_arena.nbt create mode 100644 src/main/resources/data/fabricracers/structure/tracks/kart_circuit.nbt create mode 100644 src/main/resources/data/fabricracers/structure/tracks/speed_oval.nbt create mode 100644 src/main/resources/data/fabricracers/tags/block/race_blocks.json create mode 100644 src/main/resources/data/fabricracers/tags/item/paint_cans.json create mode 100644 src/main/resources/data/fabricracers/tracks/_index.json create mode 100644 src/main/resources/data/fabricracers/tracks/asphalt_park.json create mode 100644 src/main/resources/data/fabricracers/tracks/chaos_junction.json create mode 100644 src/main/resources/data/fabricracers/tracks/drift_arena.json create mode 100644 src/main/resources/data/fabricracers/tracks/kart_circuit.json create mode 100644 src/main/resources/data/fabricracers/tracks/speed_oval.json create mode 100644 src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json create mode 100644 src/main/resources/data/minecraft/tags/block/mineable/shovel.json create mode 100644 src/main/resources/fabric.mod.json create mode 100644 src/main/resources/icon.png create mode 100644 tools/__pycache__/track_lib.cpython-311.pyc create mode 100644 tools/gen_textures.py create mode 100644 tools/gen_tracks.py create mode 100644 tools/preview_items.png create mode 100644 tools/previews/asphalt_park.png create mode 100644 tools/previews/chaos_junction.png create mode 100644 tools/previews/drift_arena.png create mode 100644 tools/previews/kart_circuit.png create mode 100644 tools/previews/speed_oval.png create mode 100644 tools/track_lib.py create mode 100644 tools/validate_assets.py diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..d3793f5 --- /dev/null +++ b/.github/workflows/build.yml @@ -0,0 +1,34 @@ +name: Build Mod + +on: + push: + branches-ignore: [] + pull_request: + workflow_dispatch: + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Java 21 + uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "21" + + - name: Validate assets + run: python3 tools/validate_assets.py + + - name: Build + run: ./gradlew build --no-daemon --stacktrace + + - name: Upload Mod JAR + uses: actions/upload-artifact@v4 + with: + name: fabricracers + path: build/libs/*.jar + if-no-files-found: error diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d9f1a9f --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# Gradle +.gradle/ +build/ +out/ + +# Fabric / Minecraft run directories +run/ +remappedSrc/ + +# IDEs +.idea/ +*.iml +*.ipr +*.iws +.vscode/ +.settings/ +.classpath +.project +bin/ + +# OS +.DS_Store +Thumbs.db + +# Local scratch +/tmp/ +*.log diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..129cea9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Qynl + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..12f5a01 --- /dev/null +++ b/README.md @@ -0,0 +1,185 @@ +# FabricRacers + +**A complete racing game inside Minecraft** - build a circuit anywhere, tune your +car, and race it solo, against AI drivers, or against your friends. + +Everything is original content for Minecraft **1.21.1** on Fabric: cars you can +drive and tune, an AI that actually races, five stock circuits plus a track +builder, a full race engine (grid, countdown, laps, checkpoints, standings, +pit stops, prizes) and a driver profile that remembers every lap you set. + +--- + +## What you get + +### Cars +* Nine chassis: Go-Kart, Rally Hatch, Sport Coupe, Hypercar, Formula Racer, + Baja Truck, Drift Machine, Hot Rod and the secret *Shadow Prototype*. +* Arcade-sim physics: engine, brakes, drag, lateral grip limits, a bicycle-model + steering box, handbrake slides, weight transfer, drift scoring and real + crashing. Cars are **server authoritative** - the client only sends input. +* Damage: crashes bend the car and dim the lights, tyres wear with abuse, + and a wrecked engine needs a wrench. +* Paint: 16 colours, eight of them craftable as paint cans. + +### Tuning +Seven systems, each up to level 4, fitted in the **garage** (`G` while sitting +in your car): Engine, Gearbox, Brakes, Tyres, Aero, Chassis and Nitro. Upgrades +are bought from villager mechanics with the parts they sell, won as prizes, or +found in the world. + +### AI drivers that actually race +The AI follows a smoothing-spline racing line with its own steering model: +* looks ahead further the faster it goes, brakes for corners from the curvature, +* uses the handbrake to rotate into tight corners (drift-capable cars), +* picks a side and overtakes slower cars instead of ploughing into them, +* uses boost on the straights, makes skill-scaled mistakes, +* re-joins the track when it ends up on the grass, reverses out when stuck, +* rubber-bands gently so a race stays a race. + +Hand your own car to the AI (`/race ai on`) and it will lap the circuit while +you watch - handy for testing a layout. + +### Circuits +* **Five stock circuits** ship with the mod, generated by a real track builder + (`tools/gen_tracks.py`): Asphalt Park, Kart Circuit, Speed Oval, Chaos + Junction and a Drift Arena. +* The **Track Wand** (`/race wand`) opens the catalogue, lets you rotate the + layout by 90ยฐ, and builds it where you are standing - complete with kerbs, + barriers, grandstands, an animated start gate, checkpoints and a pit lane. +* **Record your own**: `/race record`, drive a lap, then `/race record` again. + The mod bakes the lap you drove into a real circuit - kerbs, barriers, grid + and checkpoints included - and adds it to the catalogue. +* Placement is smart: overlap checks, rotation, and a `/race remove` that only + clears blocks the track actually placed (your edits survive). + +### The race engine +Grid, five-light countdown, lap timing, per-checkpoint progress, live +standings, gap-to-ahead/behind, pit stop counting, fastest lap, DNF handling +when a player leaves, AI take-over after the flag so cars drive themselves home, +and a results ceremony with trophies and emeralds for the podium. + +### Stats and progression +Every racer has a profile: races, wins, podiums, DNFs, laps, overtakes, drift +points, distance, top speed, per-track personal bests, cars placed, circuits +recorded, trophies. Titles go from **Walk-on** to **Legend**. Everything is +stored per world in `fabricracers/stats/`. + +--- + +## Controls + +| Key | Action | +| --- | --- | +| `W` / `S` | throttle / brake & reverse | +| `A` / `D` | steer | +| `Space` | handbrake (drift) | +| `Left Ctrl` | boost | +| `G` | garage (while sitting in a car) | +| `T` | race control / circuit catalogue | +| `H` | toggle the race HUD | + +The HUD shows speed, boost, drift score, lap counter, timing, live standings, +the countdown and every message the race engine sends you. + +--- + +## Commands + +Everything the mod does is scriptable - all commands are under `/race`: + +``` +/race car [type] get a car (or /race car sport ) +/race park put the car you are in back in your inventory +/race repair fix the car you are sitting in +/race ai [on|off] hand your car to the AI +/race ai skill 0.2 .. 1.0 set AI difficulty +/race tracks list every circuit and placed track +/race wand get the Track Wand +/race place [rot] build a circuit where you stand +/race remove remove the nearest placed circuit +/race record [width] [barriers] + record a lap and turn it into a circuit +/race start [ai] [laps] [skill] + start a race where you are sitting +/race join | leave | stop manage the race you are in +/race info | standings what is happening right now +/race stats [player] your racing record +/race top [track] per-track leaderboards +/race paint repaint the car you are in +/race drift current drift score and boost charge +``` + +--- + +## Making a circuit in 30 seconds + +1. Drive somewhere flat, or fly - tracks build under you. +2. `/race place kart_circuit 0` (see `/race tracks` for the ids). +3. A grandstand, kerbs and a start gantry appear. Walk onto the grid. +4. Get in a car, type `/race start 5 3 0.7` - 5 AI, 3 laps, 70% difficulty. + +Prefer to build your own? `/race record`, drive one clean lap, `/race record` +again. Your lap becomes a circuit complete with checkpoints, and you can build +as many copies of it as you like. + +--- + +## Installation + +1. Install **Fabric Loader** for Minecraft 1.21.1 and drop + [Fabric API](https://modrinth.com/mod/fabric-api) in `mods/`. +2. Drop the FabricRacers jar in `mods/` too. +3. Works on a dedicated server (`environment: *`), on LAN worlds and in + single player. + +--- + +## Building from source + +```bash +./gradlew build # also runs the asset validator +python3 tools/gen_textures.py # regenerate every PNG (no art files in git) +python3 tools/gen_tracks.py # regenerate the five stock circuits +python3 tools/validate_assets.py +``` + +* `tools/gen_textures.py` - standard-library PNG generator for all block, item + and car-skin textures plus the mod icon. +* `tools/gen_tracks.py` + `tools/track_lib.py` - the circuit generator: a small + track-design toolkit (road stamping, kerbs, pit lanes, grandstands, gantries, + checkpoints, previews) that writes both the structure NBT and its JSON + definition. +* `tools/validate_assets.py` - run by `gradlew check` and CI: it parses every + JSON file, walks the blockstate/model graph to make sure every texture exists, + reads the generated structures back and cross-checks them against the Java + registries, and fails on missing translations. + +The client car model is hand-rolled geometry (`client/render/CarMesh.java`) +mapped onto a 64ร—64 atlas per car, so all nine cars share one renderer and one +mesh - see the layout table at the top of `tools/gen_textures.py`. + +## Layout + +``` +src/main/java/dev/qynl/racers/ + car/ CarEntity, physics, data components, tuning + ai/ AiDriver + RacingLine (Catmull-Rom racing line tools) + track/ Track, TrackStructure, TrackPlacement, TrackManager, TrackRecorder + race/ RaceFormat, RaceManager, RaceSession (the race engine) + stats/ RacerProfile + StatsManager (per world JSON persistence) + net/ ModNetworking (payloads) + ServerActions (every client request) + item/ CarItem + the toolbox + block/ asphalt, kerbs, barriers, gates, checkpoints, boost pads + registry/ blocks, items, entities, components, creative tab + shop/ villager trades (parts and cars) + command/ /race command tree +src/client/java/dev/qynl/racers/client/ + render/ CarMesh + CarRenderer + hud/ RaceHud (speed, laps, standings, countdown) + screen/ Garage, Race Control, Circuit Catalogue +``` + +## License + +MIT - see [LICENSE](LICENSE). diff --git a/build.gradle b/build.gradle new file mode 100644 index 0000000..2f9afec --- /dev/null +++ b/build.gradle @@ -0,0 +1,75 @@ +plugins { + id 'fabric-loom' version '1.9.2' + id 'maven-publish' +} + +version = project.mod_version +group = project.maven_group + +base { + archivesName = project.archives_base_name +} + +repositories { + maven { + name = "Fabric" + url = "https://maven.fabricmc.net/" + } + mavenCentral() +} + +loom { + splitEnvironmentSourceSets() + + mods { + "fabricracers" { + sourceSet sourceSets.main + sourceSet sourceSets.client + } + } +} + +dependencies { + minecraft "com.mojang:minecraft:${project.minecraft_version}" + mappings "net.fabricmc:yarn:${project.yarn_mappings}:v2" + modImplementation "net.fabricmc:fabric-loader:${project.loader_version}" + modImplementation "net.fabricmc.fabric-api:fabric-api:${project.fabric_api_version}" +} + +processResources { + inputs.property "version", project.version + + filesMatching("fabric.mod.json") { + expand "version": project.version + } +} + +tasks.withType(JavaCompile).configureEach { + it.options.release = 21 + it.options.encoding = "UTF-8" +} + +java { + withSourcesJar() + sourceCompatibility = JavaVersion.VERSION_21 + targetCompatibility = JavaVersion.VERSION_21 +} + +jar { + from("LICENSE") { + rename { "${it}_${project.base.archivesName.get()}" } + } +} + +// Sanity checks that run during `gradle build` - catches broken JSON, missing +// translations, dangling asset references and broken track structures long +// before the game does. +tasks.register('validateAssets', Exec) { + group = 'verification' + description = 'Validates all JSON assets, textures, track structures and translations.' + commandLine 'python3', 'tools/validate_assets.py' +} + +tasks.named('check') { + dependsOn 'validateAssets' +} diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..2d0cb6f --- /dev/null +++ b/gradle.properties @@ -0,0 +1,14 @@ +org.gradle.jvmargs=-Xmx2G +org.gradle.parallel=true +org.gradle.configuration-cache=false + +# Minecraft / Fabric (1.21.1 toolchain, Java 21) +minecraft_version=1.21.1 +yarn_mappings=1.21.1+build.3 +loader_version=0.16.10 +fabric_api_version=0.116.17+1.21.1 + +# Mod metadata +mod_version=1.0.0 +maven_group=dev.qynl +archives_base_name=fabricracers diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000000000000000000000000000000000000..d997cfc60f4cff0e7451d19d49a82fa986695d07 GIT binary patch literal 48966 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

`Lem|V$A>eR0uN8Y&sdjtu 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

1bvi{DS7E@q1{$_8->K{_OWv2 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 literal 0 HcmV?d00001 diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..ca025c8 --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,7 @@ +distributionBase=GRADLE_USER_HOME +distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14-bin.zip +networkTimeout=10000 +validateDistributionUrl=true +zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..739907d --- /dev/null +++ b/gradlew @@ -0,0 +1,248 @@ +#!/bin/sh + +# +# Copyright ยฉ 2015 the original authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# +# SPDX-License-Identifier: Apache-2.0 +# + +############################################################################## +# +# Gradle start up script for POSIX generated by Gradle. +# +# Important for running: +# +# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is +# noncompliant, but you have some other compliant shell such as ksh or +# bash, then to run this script, type that shell name before the whole +# command line, like: +# +# ksh Gradle +# +# Busybox and similar reduced shells will NOT work, because this script +# requires all of these POSIX shell features: +# * functions; +# * expansions ยซ$varยป, ยซ${var}ยป, ยซ${var:-default}ยป, ยซ${var+SET}ยป, +# ยซ${var#prefix}ยป, ยซ${var%suffix}ยป, and ยซ$( cmd )ยป; +# * compound commands having a testable exit status, especially ยซcaseยป; +# * various built-in commands including ยซcommandยป, ยซsetยป, and ยซulimitยป. +# +# Important for patching: +# +# (2) This script targets any POSIX shell, so it avoids extensions provided +# by Bash, Ksh, etc; in particular arrays are avoided. +# +# The "traditional" practice of packing multiple parameters into a +# space-separated string is a well documented source of bugs and security +# problems, so this is (mostly) avoided, by progressively accumulating +# options in "$@", and eventually passing that to Java. +# +# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, +# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; +# see the in-line comments for details. +# +# There are tweaks for specific operating systems such as AIX, CygWin, +# Darwin, MinGW, and NonStop. +# +# (3) This script is generated from the Groovy template +# https://github.com/gradle/gradle/blob/2d6327017519d23b96af35865dc997fcb544fb40/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# within the Gradle project. +# +# You can find Gradle at https://github.com/gradle/gradle/. +# +############################################################################## + +# Attempt to set APP_HOME + +# Resolve links: $0 may be a link +app_path=$0 + +# Need this for daisy-chained symlinks. +while + APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path + [ -h "$app_path" ] +do + ls=$( ls -ld "$app_path" ) + link=${ls#*' -> '} + case $link in #( + /*) app_path=$link ;; #( + *) app_path=$APP_HOME$link ;; + esac +done + +# This is normally unused +# shellcheck disable=SC2034 +APP_BASE_NAME=${0##*/} +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD=maximum + +warn () { + echo "$*" +} >&2 + +die () { + echo + echo "$*" + echo + exit 1 +} >&2 + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "$( uname )" in #( + CYGWIN* ) cygwin=true ;; #( + Darwin* ) darwin=true ;; #( + MSYS* | MINGW* ) msys=true ;; #( + NONSTOP* ) nonstop=true ;; +esac + + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD=$JAVA_HOME/jre/sh/java + else + JAVACMD=$JAVA_HOME/bin/java + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD=java + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +fi + +# Increase the maximum file descriptors if we can. +if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then + case $MAX_FD in #( + max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + MAX_FD=$( ulimit -H -n ) || + warn "Could not query maximum file descriptor limit" + esac + case $MAX_FD in #( + '' | soft) :;; #( + *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC2039,SC3045 + ulimit -n "$MAX_FD" || + warn "Could not set maximum file descriptor limit to $MAX_FD" + esac +fi + +# Collect all arguments for the java command, stacking in reverse order: +# * args from the command line +# * the main class name +# * -classpath +# * -D...appname settings +# * --module-path (only if needed) +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. + +# For Cygwin or MSYS, switch paths to Windows format before running java +if "$cygwin" || "$msys" ; then + APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) + + JAVACMD=$( cygpath --unix "$JAVACMD" ) + + # Now convert the arguments - kludge to limit ourselves to /bin/sh + for arg do + if + case $arg in #( + -*) false ;; # don't mess with options #( + /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath + [ -e "$t" ] ;; #( + *) false ;; + esac + then + arg=$( cygpath --path --ignore --mixed "$arg" ) + fi + # Roll the args list around exactly as many times as the number of + # args, so each arg winds up back in the position where it started, but + # possibly modified. + # + # NB: a `for` loop captures its iteration list before it begins, so + # changing the positional parameters here affects neither the number of + # iterations, nor the values presented in `arg`. + shift # remove old arg + set -- "$@" "$arg" # push replacement arg + done +fi + + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Collect all arguments for the java command: +# * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, +# and any embedded shellness will be escaped. +# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be +# treated as '${Hostname}' itself on the command line. + +set -- \ + "-Dorg.gradle.appname=$APP_BASE_NAME" \ + -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ + "$@" + +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + +# Use "xargs" to parse quoted args. +# +# With -n1 it outputs one arg per line, with the quotes and backslashes removed. +# +# In Bash we could simply go: +# +# readarray ARGS < <( xargs -n1 <<<"$var" ) && +# set -- "${ARGS[@]}" "$@" +# +# but POSIX shell has neither arrays nor command substitution, so instead we +# post-process each arg (as a line of input to sed) to backslash-escape any +# character that might be a shell metacharacter, then use eval to reverse +# that process (while maintaining the separation between arguments), and wrap +# the whole thing up as a single "set" statement. +# +# This will of course break if any of these variables contains a newline or +# an unmatched quote. +# + +eval "set -- $( + printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | + xargs -n1 | + sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | + tr '\n' ' ' + )" '"$@"' + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..39baf4d --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,98 @@ +@REM Copyright (c) Meta Platforms, Inc. and affiliates. +@REM +@REM This source code is licensed under the MIT license found in the +@REM LICENSE file in the root directory of this source tree. + +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem +@rem SPDX-License-Identifier: Apache-2.0 +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. 1>&2 +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 +echo. 1>&2 +echo Please set the JAVA_HOME variable in your environment to match the 1>&2 +echo location of your Java installation. 1>&2 + +goto fail + +:execute +@rem Setup the command line + + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/settings.gradle b/settings.gradle new file mode 100644 index 0000000..48d0454 --- /dev/null +++ b/settings.gradle @@ -0,0 +1,12 @@ +pluginManagement { + repositories { + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } + mavenCentral() + gradlePluginPortal() + } +} + +rootProject.name = 'fabricracers' diff --git a/src/client/java/dev/qynl/racers/client/ClientRaceState.java b/src/client/java/dev/qynl/racers/client/ClientRaceState.java new file mode 100644 index 0000000..5f447a4 --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/ClientRaceState.java @@ -0,0 +1,259 @@ +package dev.qynl.racers.client; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.option.Perspective; +import net.minecraft.client.network.ClientPlayerEntity; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtList; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.MathHelper; + +import dev.qynl.racers.car.CarControls; +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.util.Stopwatch; + +/** + * Everything the client keeps in memory: the latest HUD snapshot, the list of + * circuits the server offers, the tuning sheet, the player's profile, plus the + * driving input and camera handling. + */ +public final class ClientRaceState { + private ClientRaceState() { + } + + // --- hud ----------------------------------------------------------------- + public static ModNetworking.Hud hud; + public static long hudTime; + public static boolean hudEnabled = true; + + // --- tracks -------------------------------------------------------------- + public record TrackInfo(String id, String name, int sizeX, int sizeY, int sizeZ, + int checkpoints, int laps, int width, boolean stock) { + } + + public record PlacementInfo(String track, BlockPos anchor, int rotation) { + } + + public static final List tracks = new ArrayList<>(); + public static final List placements = new ArrayList<>(); + public static BlockPos wandAnchor; + public static int wandRotation; + + // --- garage -------------------------------------------------------------- + public static CarData garageData; + public static int garageEntityId = -1; + public static int garageHealth = 100; + public static boolean garageAi; + public static NbtCompound garageExtra = new NbtCompound(); + + // --- profile ------------------------------------------------------------- + public static NbtCompound profile = new NbtCompound(); + public static String notifyMessage = ""; + public static long notifyTime; + public static int recordState; + + private static int lastVehicleId = -1; + private static boolean thirdPersonForced; + + // ------------------------------------------------------------------ + // networking + // ------------------------------------------------------------------ + public static void setHud(ModNetworking.Hud payload) { + hud = payload; + hudTime = System.currentTimeMillis(); + } + + public static void handle(ModNetworking.ServerAction payload) { + MinecraftClient client = MinecraftClient.getInstance(); + switch (payload.action()) { + case ModNetworking.ACT_TRACK_LIST -> { + tracks.clear(); + NbtCompound data = payload.data(); + NbtList list = data.getList("tracks", 10); + for (int i = 0; i < list.size(); i++) { + NbtCompound entry = list.getCompound(i); + tracks.add(new TrackInfo(entry.getString("id"), entry.getString("name"), + entry.getInt("sx"), entry.getInt("sy"), entry.getInt("sz"), + entry.getInt("cps"), entry.getInt("laps"), entry.getInt("width"), + entry.getBoolean("stock"))); + } + if (data.contains("anchor")) { + NbtList anchor = data.getList("anchor", 3); + wandAnchor = new BlockPos(anchor.getInt(0), anchor.getInt(1), anchor.getInt(2)); + wandRotation = data.getInt("rotation"); + } + if (!tracks.isEmpty() && client.currentScreen == null) { + client.setScreen(new dev.qynl.racers.client.screen.TrackSelectScreen(Text + .translatable("screen.fabricracers.tracks"))); + } + } + case ModNetworking.ACT_PLACEMENTS -> { + placements.clear(); + NbtList list = payload.data().getList("list", 10); + for (int i = 0; i < list.size(); i++) { + NbtCompound entry = list.getCompound(i); + placements.add(new PlacementInfo(entry.getString("track"), + new BlockPos(entry.getInt("x"), entry.getInt("y"), entry.getInt("z")), + entry.getInt("rot"))); + } + } + case ModNetworking.ACT_GARAGE -> { + garageData = CarData.fromNbt(payload.data().getCompound("car")); + garageEntityId = payload.entityId(); + garageHealth = payload.data().getInt("health"); + garageAi = payload.data().getBoolean("ai"); + garageExtra = payload.data().getCompound("extra"); + if (client.currentScreen == null) { + client.setScreen(new dev.qynl.racers.client.screen.GarageScreen()); + } + } + case ModNetworking.ACT_PROFILE -> profile = payload.data(); + case ModNetworking.ACT_NOTIFY -> { + if (!"garage".equals(payload.text())) { + notifyMessage = payload.text(); + notifyTime = System.currentTimeMillis(); + } + } + case ModNetworking.ACT_PLACE_RESULT -> { + notifyMessage = payload.text(); + notifyTime = System.currentTimeMillis(); + } + case ModNetworking.ACT_RECORD_STATE -> { + recordState = payload.a(); + notifyMessage = payload.text(); + notifyTime = System.currentTimeMillis(); + } + default -> { + } + } + } + + public static void send(int action, int entityId, int a, int b, String text) { + if (MinecraftClient.getInstance().getNetworkHandler() == null) { + return; + } + net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking.send( + new ModNetworking.ClientAction(action, entityId, a, b, text)); + } + + public static void requestGarage() { + if (MinecraftClient.getInstance().player != null + && MinecraftClient.getInstance().player.getVehicle() instanceof CarEntity car) { + send(ModNetworking.REQ_GARAGE, car.getId(), 0, 0, ""); + } + } + + // ------------------------------------------------------------------ + // per tick: input + camera + // ------------------------------------------------------------------ + public static void tick(MinecraftClient client) { + ClientPlayerEntity player = client.player; + if (player == null) { + return; + } + while (FabricRacersClient.openGarage.wasPressed()) { + requestGarage(); + } + while (FabricRacersClient.openTracks.wasPressed()) { + if (!tracks.isEmpty()) { + client.setScreen(new dev.qynl.racers.client.screen.TrackSelectScreen( + Text.translatable("screen.fabricracers.tracks"))); + } else { + client.setScreen(new dev.qynl.racers.client.screen.RacePanelScreen( + Text.translatable("screen.fabricracers.panel"))); + } + } + while (FabricRacersClient.toggleHud.wasPressed()) { + hudEnabled = !hudEnabled; + notifyMessage = hudEnabled ? "HUD on" : "HUD off"; + notifyTime = System.currentTimeMillis(); + } + + // ---- driving ----------------------------------------------------- + if (player.getVehicle() instanceof CarEntity car) { + CarControls controls = new CarControls(); + controls.forward = client.options.forwardKey.isPressed(); + controls.back = client.options.backKey.isPressed(); + controls.left = client.options.leftKey.isPressed(); + controls.right = client.options.rightKey.isPressed(); + controls.handbrake = FabricRacersClient.handbrake.isPressed() || client.options.sneakKey.isPressed(); + controls.boost = FabricRacersClient.boost.isPressed(); + if (client.getNetworkHandler() != null) { + net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking.send( + new ModNetworking.CarControl(car.getId(), controls.mask())); + } + // chase camera: keep the player looking where the car is going + if (player.getUuid().equals(car.data().owner) || car.driverName().isEmpty() + || car.driverName().equals(player.getName().getString())) { + float delta = MathHelper.wrapDegrees(car.getYaw() - player.getYaw()); + player.setYaw(player.getYaw() + delta * 0.4F); + if (player == client.player) { + player.prevYaw += MathHelper.wrapDegrees((player.getYaw() - player.prevYaw)) * 0.6F; + } + } + if (lastVehicleId != car.getId()) { + lastVehicleId = car.getId(); + thirdPersonForced = false; + } + if (!thirdPersonForced) { + thirdPersonForced = true; + if (client.options.getPerspective() == Perspective.FIRST_PERSON) { + client.options.setPerspective(Perspective.THIRD_PERSON_BACK); + } + } + } else { + lastVehicleId = -1; + thirdPersonForced = false; + } + } + + // ------------------------------------------------------------------ + // helpers for the HUD + screens + // ------------------------------------------------------------------ + public static boolean hudFresh() { + return hud != null && System.currentTimeMillis() - hudTime < 1500L; + } + + public static long stopwatchElapsed() { + Stopwatch stopwatch = Stopwatch.items(); + return stopwatch.running() ? stopwatch.elapsed(System.currentTimeMillis()) : 0L; + } + + public static CarEntity nearbyCar(MinecraftClient client) { + if (client.player == null) { + return null; + } + if (client.player.getVehicle() instanceof CarEntity car) { + return car; + } + return dev.qynl.racers.util.Cars.carNear(client.player.getWorld(), client.player.getPos(), 6.0); + } + + public static void notify(String message) { + notifyMessage = message; + notifyTime = System.currentTimeMillis(); + } + + public static String formatMs(int millis) { + return Stopwatch.format(millis); + } + + public static String color(int rgb) { + return String.format("#%06X", rgb & 0xFFFFFF); + } + + public static Text localRaceLine() { + if (!hudFresh() || hud.state() < 0) { + return Text.translatable("screen.fabricracers.no_race").formatted(Formatting.GRAY); + } + return Text.literal(Optional.ofNullable(hud.trackName()).orElse("")).formatted(Formatting.GOLD); + } +} diff --git a/src/client/java/dev/qynl/racers/client/FabricRacersClient.java b/src/client/java/dev/qynl/racers/client/FabricRacersClient.java new file mode 100644 index 0000000..282b52f --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/FabricRacersClient.java @@ -0,0 +1,57 @@ +package dev.qynl.racers.client; + +import net.fabricmc.api.ClientModInitializer; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.keybinding.v1.KeyBindingHelper; +import net.fabricmc.fabric.api.client.networking.v1.ClientPlayNetworking; +import net.fabricmc.fabric.api.client.rendering.v1.EntityRendererRegistry; +import net.fabricmc.fabric.api.client.rendering.v1.HudRenderCallback; +import net.minecraft.client.option.KeyBinding; +import net.minecraft.client.util.InputUtil; +import org.lwjgl.glfw.GLFW; + +import dev.qynl.racers.client.hud.RaceHud; +import dev.qynl.racers.client.render.CarRenderer; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.registry.ModEntities; + +/** Client bootstrap: renderers, key binds, HUD and the network receiver. */ +public class FabricRacersClient implements ClientModInitializer { + public static KeyBinding openGarage; + public static KeyBinding openTracks; + public static KeyBinding handbrake; + public static KeyBinding boost; + public static KeyBinding toggleHud; + + @Override + public void onInitializeClient() { + EntityRendererRegistry.register(ModEntities.PLAYER_CAR, CarRenderer::new); + EntityRendererRegistry.register(ModEntities.AI_CAR, CarRenderer::new); + + openGarage = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.fabricracers.garage", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_G, + "category.fabricracers.racing")); + openTracks = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.fabricracers.tracks", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_T, + "category.fabricracers.racing")); + handbrake = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.fabricracers.handbrake", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_SPACE, + "category.fabricracers.racing")); + boost = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.fabricracers.boost", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_LEFT_CONTROL, + "category.fabricracers.racing")); + toggleHud = KeyBindingHelper.registerKeyBinding(new KeyBinding( + "key.fabricracers.hud", InputUtil.Type.KEYSYM, GLFW.GLFW_KEY_H, + "category.fabricracers.racing")); + + ClientPlayNetworking.registerGlobalReceiver(ModNetworking.ServerAction.ID, + (payload, context) -> context.client().execute( + () -> ClientRaceState.handle(payload))); + ClientPlayNetworking.registerGlobalReceiver(ModNetworking.Hud.ID, + (payload, context) -> context.client().execute( + () -> ClientRaceState.setHud(payload))); + + ClientTickEvents.END_CLIENT_TICK.register(ClientRaceState::tick); + HudRenderCallback.EVENT.register(RaceHud::render); + } +} diff --git a/src/client/java/dev/qynl/racers/client/StopwatchClient.java b/src/client/java/dev/qynl/racers/client/StopwatchClient.java new file mode 100644 index 0000000..e1e5a5c --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/StopwatchClient.java @@ -0,0 +1,36 @@ +package dev.qynl.racers.client; + +import net.minecraft.client.MinecraftClient; + +import dev.qynl.racers.registry.ModComponents; +import dev.qynl.racers.util.Stopwatch; + +/** Client side bridge so the stopwatch item can start/stop/save lap times. */ +public final class StopwatchClient { + private StopwatchClient() { + } + + public static void toggle() { + Stopwatch stopwatch = Stopwatch.items(); + if (stopwatch.running()) { + var player = MinecraftClient.getInstance().player; + if (player != null && player.getVehicle() instanceof dev.qynl.racers.car.CarEntity car) { + stopwatch.stop(System.currentTimeMillis()); + var laps = stopwatch.laps(); + long lap = laps.isEmpty() ? stopwatch.elapsed(System.currentTimeMillis()) + : laps.get(laps.size() - 1); + if (player.getMainHandStack().getItem() instanceof dev.qynl.racers.item.ToolItems.Stopwatch) { + ModComponents.setLapTime(player.getMainHandStack(), lap); + } + } else { + stopwatch.stop(System.currentTimeMillis()); + } + } else { + stopwatch.toggle(); + } + } + + public static void lap() { + Stopwatch.items().split(System.currentTimeMillis()); + } +} diff --git a/src/client/java/dev/qynl/racers/client/hud/RaceHud.java b/src/client/java/dev/qynl/racers/client/hud/RaceHud.java new file mode 100644 index 0000000..3bbcbff --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/hud/RaceHud.java @@ -0,0 +1,181 @@ +package dev.qynl.racers.client.hud; + +import net.minecraft.client.MinecraftClient; +import net.minecraft.client.font.TextRenderer; +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.render.RenderTickCounter; + +import dev.qynl.racers.client.ClientRaceState; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.util.Stopwatch; + +/** + * The racing overlay: speed, gears, boost, drift meter, lap timing, live + * standings, countdown lights and the results banner. + */ +public final class RaceHud { + private static final int PANEL = 0xB0101014; + private static final int ACCENT = 0xFFF2C42A; + private static final int GREEN = 0xFF4FD05A; + private static final int RED = 0xFFDC3B36; + private static final int CYAN = 0xFF3CC8DC; + + private RaceHud() { + } + + public static void render(DrawContext context, RenderTickCounter tickCounter) { + MinecraftClient client = MinecraftClient.getInstance(); + if (client.player == null || client.options.hudHidden || !ClientRaceState.hudEnabled) { + return; + } + ModNetworking.Hud hud = ClientRaceState.hud; + int width = context.getScaledWindowWidth(); + int height = context.getScaledWindowHeight(); + TextRenderer font = client.textRenderer; + + if (hud != null && ClientRaceState.hudFresh()) { + if (hud.state() >= 0) { + racePanel(context, font, hud, width); + standings(context, font, hud, width); + if (hud.state() == 0 && hud.countdown() > 0) { + countdown(context, font, hud, width, height); + } + } + bottomCluster(context, font, hud, width, height); + if (!hud.message().isEmpty()) { + banner(context, font, hud.message(), width, height); + } + } + + // stopwatch (client side timing) + long elapsed = ClientRaceState.stopwatchElapsed(); + if (elapsed > 0) { + String text = "STOPWATCH " + Stopwatch.format(elapsed); + int textWidth = font.getWidth(text); + int x = width - textWidth - 8; + int y = height - 60; + context.fill(x - 4, y - 3, width - 4, y + 11, PANEL); + context.drawText(font, text, x, y, ACCENT, true); + } + + // toast style notifications + if (!ClientRaceState.notifyMessage.isEmpty() + && System.currentTimeMillis() - ClientRaceState.notifyTime < 4200L) { + String message = ClientRaceState.notifyMessage; + int textWidth = font.getWidth(message); + int x = (width - textWidth) / 2; + int y = height - 92; + context.fill(x - 6, y - 4, x + textWidth + 6, y + 12, PANEL); + context.drawText(font, message, x, y, 0xFFFFFFFF, true); + } + } + + private static void racePanel(DrawContext context, TextRenderer font, ModNetworking.Hud hud, + int width) { + String position = hud.total() > 1 ? "P" + hud.position() + "/" + hud.total() : ""; + String lap = "LAP " + hud.lap() + "/" + hud.laps(); + String time = "TIME " + Stopwatch.format(hud.timeMs()); + String lapTime = "LAP " + Stopwatch.format(hud.lapTimeMs()); + String best = "BEST " + Stopwatch.format(hud.bestLapMs()); + + int panelWidth = 168; + context.fill(6, 6, 6 + panelWidth, 68, PANEL); + context.drawText(font, hud.trackName(), 12, 12, ACCENT, true); + context.drawText(font, lap, 12, 26, 0xFFFFFFFF, true); + if (!position.isEmpty()) { + context.drawText(font, position, 12 + font.getWidth(lap) + 10, 26, CYAN, true); + } + context.drawText(font, time, 12, 40, 0xFFDDDDDD, true); + context.drawText(font, hud.bestLapMs() > 0 ? best : lapTime, 12, 52, + hud.bestLapMs() > 0 ? GREEN : 0xFFBBBBBB, true); + } + + private static void standings(DrawContext context, TextRenderer font, ModNetworking.Hud hud, + int width) { + if (hud.standings().isEmpty()) { + return; + } + String[] lines = hud.standings().split("\n"); + int panelWidth = 150; + int panelHeight = 8 + lines.length * 11; + int x = width - panelWidth - 6; + context.fill(x, 6, width - 6, 6 + panelHeight, PANEL); + context.drawText(font, "STANDINGS", x + 6, 11, ACCENT, true); + int y = 24; + for (String line : lines) { + context.drawText(font, font.trimToWidth(line, panelWidth - 12), x + 6, y, 0xFFEEEEEE, true); + y += 11; + } + } + + private static void countdown(DrawContext context, TextRenderer font, ModNetworking.Hud hud, + int width, int height) { + String text = String.valueOf(Math.max(1, hud.countdown())); + context.getMatrices().push(); + context.getMatrices().translate(width / 2.0F, height / 2.0F - 40.0F, 0.0F); + context.getMatrices().scale(5.0F, 5.0F, 1.0F); + int textWidth = font.getWidth(text); + context.drawText(font, text, -textWidth / 2, -font.fontHeight / 2, + hud.countdown() <= 1 ? GREEN : RED, true); + context.getMatrices().pop(); + String hint = "Hold nothing - brakes off at GO"; + context.drawText(font, hint, (width - font.getWidth(hint)) / 2, height / 2 + 26, + 0xFFDDDDDD, true); + } + + private static void banner(DrawContext context, TextRenderer font, String message, int width, + int height) { + context.getMatrices().push(); + context.getMatrices().translate(width / 2.0F, height / 2.0F - 90.0F, 0.0F); + context.getMatrices().scale(2.0F, 2.0F, 1.0F); + int textWidth = font.getWidth(message); + context.drawText(font, message, -textWidth / 2, 0, ACCENT, true); + context.getMatrices().pop(); + } + + private static void bottomCluster(DrawContext context, TextRenderer font, ModNetworking.Hud hud, + int width, int height) { + // speed + String speed = String.valueOf(hud.speedKmh()); + context.getMatrices().push(); + context.getMatrices().translate(width / 2.0F, height - 58.0F, 0.0F); + context.getMatrices().scale(2.4F, 2.4F, 1.0F); + int speedWidth = font.getWidth(speed); + context.drawText(font, speed, -speedWidth / 2, 0, 0xFFFFFFFF, true); + context.getMatrices().pop(); + String unit = "km/h"; + context.drawText(font, unit, (width - font.getWidth(unit)) / 2, height - 32, 0xFFAAAAAA, true); + + // boost bar + int barWidth = 120; + int barX = (width - barWidth) / 2; + int barY = height - 24; + context.fill(barX - 1, barY - 1, barX + barWidth + 1, barY + 7, PANEL); + int boostWidth = (int) (barWidth * clamp(hud.boost())); + context.fill(barX, barY, barX + boostWidth, barY + 6, + hud.flag(ModNetworking.Hud.FLAG_BOOSTING) ? CYAN : 0xFF3C7CDC); + if (hud.driftScore() > 0) { + String drift = "DRIFT " + hud.driftScore(); + context.drawText(font, drift, barX + barWidth + 8, barY - 2, + hud.flag(ModNetworking.Hud.FLAG_DRIFTING) ? ACCENT : 0xFFAAAAAA, true); + } + if (hud.flag(ModNetworking.Hud.FLAG_BOOSTING)) { + String boost = "BOOST!"; + context.drawText(font, boost, barX - font.getWidth(boost) - 8, barY - 2, CYAN, true); + } + if (hud.flag(ModNetworking.Hud.FLAG_DAMAGED)) { + String warning = "ENGINE DAMAGE"; + context.drawText(font, warning, (width - font.getWidth(warning)) / 2, height - 12, + RED, true); + } + if (hud.flag(ModNetworking.Hud.FLAG_FINISHED)) { + String finished = "RACE OVER - AI is bringing the car home"; + context.drawText(font, finished, (width - font.getWidth(finished)) / 2, 76, GREEN, true); + } + } + + private static double clamp(double value) { + return Math.max(0.0, Math.min(1.0, value)); + } + +} diff --git a/src/client/java/dev/qynl/racers/client/render/CarMesh.java b/src/client/java/dev/qynl/racers/client/render/CarMesh.java new file mode 100644 index 0000000..5004f54 --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/render/CarMesh.java @@ -0,0 +1,296 @@ +package dev.qynl.racers.client.render; + +import org.joml.Matrix4f; + +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.util.math.MathHelper; + +import dev.qynl.racers.car.CarSpec; +import dev.qynl.racers.car.CarType; + +/** + * Hand written car geometry. + * + *

Instead of box-UV models the mod uses a small custom mesh whose faces are + * mapped onto the hand painted 64x64 car atlas (see tools/gen_textures.py and + * the layout constants below). That keeps the cars a single quad batch each and + * lets the body be tinted with the player's paint while decals stay textured. + * + *

Model space: +Z is forward, +X is the car's left, +Y is up. All numbers are + * in blocks. + */ +public final class CarMesh { + // --- atlas regions (must match tools/gen_textures.py) ------------------- + public static final Uv BODY_TOP = new Uv(0, 0, 32, 16); + public static final Uv BODY_SIDE = new Uv(0, 16, 32, 32); + public static final Uv BODY_FRONT = new Uv(0, 32, 16, 44); + public static final Uv BODY_BACK = new Uv(16, 32, 32, 44); + public static final Uv GLASS = new Uv(32, 32, 48, 48); + public static final Uv DETAIL = new Uv(48, 32, 64, 48); + public static final Uv WHEEL_SIDE = new Uv(32, 0, 48, 16); + public static final Uv WHEEL_TREAD = new Uv(32, 16, 48, 32); + public static final Uv WHEEL_HUB = new Uv(48, 0, 64, 16); + public static final Uv EXTRA = new Uv(48, 16, 64, 32); + public static final Uv ICON = new Uv(0, 48, 32, 64); + public static final Uv TIRE_ICON = new Uv(32, 48, 48, 64); + + /** A rectangle of the atlas in 0..1 texture space. */ + public record Uv(float u0, float v0, float u1, float v1) { + public Uv shrink(float amount) { + return new Uv(u0 + amount, v0 + amount, u1 - amount, v1 - amount); + } + + public Uv sub(float x0, float y0, float x1, float y1, float width, float height) { + float w = u1 - u0; + float h = v1 - v0; + return new Uv(u0 + w * (x0 / width), v0 + h * (y0 / height), + u0 + w * (x1 / width), v0 + h * (y1 / height)); + } + } + + private CarMesh() { + } + + // ------------------------------------------------------------------ + // primitives + // ------------------------------------------------------------------ + /** A box with one texture rectangle per face. */ + public static void box(VertexConsumer vc, Matrix4f matrix, float x0, float y0, float z0, + float x1, float y1, float z1, float red, float green, float blue, + float alpha, int light, Uv top, Uv bottom, Uv south, Uv north, + Uv east, Uv west) { + // top (+Y) + quad(vc, matrix, x0, y1, z1, x0, y1, z0, x1, y1, z0, x1, y1, z1, + top, red, green, blue, alpha, light, 0.0F, 1.0F, 0.0F); + // bottom (-Y) + quad(vc, matrix, x0, y0, z0, x0, y0, z1, x1, y0, z1, x1, y0, z0, + bottom, red, green, blue, alpha, light, 0.0F, -1.0F, 0.0F); + // south / front (+Z) + quad(vc, matrix, x1, y1, z1, x0, y1, z1, x0, y0, z1, x1, y0, z1, + south, red, green, blue, alpha, light, 0.0F, 0.0F, 1.0F); + // north / back (-Z) + quad(vc, matrix, x0, y1, z0, x1, y1, z0, x1, y0, z0, x0, y0, z0, + north, red, green, blue, alpha, light, 0.0F, 0.0F, -1.0F); + // east / left (+X) + quad(vc, matrix, x1, y1, z0, x1, y1, z1, x1, y0, z1, x1, y0, z0, + east, red, green, blue, alpha, light, 1.0F, 0.0F, 0.0F); + // west / right (-X) + quad(vc, matrix, x0, y1, z1, x0, y1, z0, x0, y0, z0, x0, y0, z1, + west, red, green, blue, alpha, light, -1.0F, 0.0F, 0.0F); + } + + /** A box with the same rectangle on every face. */ + public static void boxAll(VertexConsumer vc, Matrix4f matrix, float x0, float y0, float z0, + float x1, float y1, float z1, float red, float green, float blue, + float alpha, int light, Uv uv) { + box(vc, matrix, x0, y0, z0, x1, y1, z1, red, green, blue, alpha, light, + uv, uv, uv, uv, uv, uv); + } + + private static void quad(VertexConsumer vc, Matrix4f matrix, float x0, float y0, float z0, + float x1, float y1, float z1, float x2, float y2, float z2, + float x3, float y3, float z3, Uv uv, float red, float green, + float blue, float alpha, int light, float nx, float ny, float nz) { + // texture V grows downwards, so the first vertex pair sits at uv.v1 of the rect + vertex(vc, matrix, x0, y0, z0, uv.u0(), uv.v1(), red, green, blue, alpha, light, nx, ny, nz); + vertex(vc, matrix, x1, y1, z1, uv.u0(), uv.v0(), red, green, blue, alpha, light, nx, ny, nz); + vertex(vc, matrix, x2, y2, z2, uv.u1(), uv.v0(), red, green, blue, alpha, light, nx, ny, nz); + vertex(vc, matrix, x3, y3, z3, uv.u1(), uv.v1(), red, green, blue, alpha, light, nx, ny, nz); + } + + private static void vertex(VertexConsumer vc, Matrix4f matrix, float x, float y, float z, + float u, float v, float red, float green, float blue, float alpha, + int light, float nx, float ny, float nz) { + vc.vertex(matrix, x, y, z) + .color(red, green, blue, alpha) + .texture(u, v) + .overlay(net.minecraft.client.render.OverlayTexture.DEFAULT_UV) + .light(light) + .normal(nx, ny, nz); + } + + /** A wheel: an octagonal prism aligned along X, with tread, sidewall and hub. */ + public static void wheel(VertexConsumer vc, Matrix4f matrix, float x, float y, float z, + float radius, float width, float spin, int light) { + int sides = 8; + float half = width / 2.0F; + float[] sy = new float[sides]; + float[] sz = new float[sides]; + for (int i = 0; i < sides; i++) { + double angle = (i / (double) sides) * Math.PI * 2.0 + spin; + sy[i] = (float) Math.sin(angle) * radius; + sz[i] = (float) Math.cos(angle) * radius; + } + float tu0 = WHEEL_TREAD.u0(); + float tu1 = WHEEL_TREAD.u1(); + float tv0 = WHEEL_TREAD.v0(); + float tv1 = WHEEL_TREAD.v1(); + float su0 = WHEEL_SIDE.u0(); + float sv0 = WHEEL_SIDE.v0(); + float sw = WHEEL_SIDE.u1() - WHEEL_SIDE.u0(); + float sh = WHEEL_SIDE.v1() - WHEEL_SIDE.v0(); + float hu0 = WHEEL_HUB.u0(); + float hv0 = WHEEL_HUB.v0(); + float hw = WHEEL_HUB.u1() - WHEEL_HUB.u0(); + float hh = WHEEL_HUB.v1() - WHEEL_HUB.v0(); + + for (int i = 0; i < sides; i++) { + int j = (i + 1) % sides; + float uA = tu0 + (tu1 - tu0) * (i / (float) sides); + float uB = tu0 + (tu1 - tu0) * ((i + 1) / (float) sides); + float ny = (sy[i] + sy[j]) / (2.0F * radius); + float nz = (sz[i] + sz[j]) / (2.0F * radius); + // tread band + vertex(vc, matrix, x - half, y + sy[i], z + sz[i], uA, tv1, 1, 1, 1, 1, light, ny, nz); + vertex(vc, matrix, x + half, y + sy[i], z + sz[i], uA, tv0, 1, 1, 1, 1, light, ny, nz); + vertex(vc, matrix, x + half, y + sy[j], z + sz[j], uB, tv0, 1, 1, 1, 1, light, ny, nz); + vertex(vc, matrix, x - half, y + sy[j], z + sz[j], uB, tv1, 1, 1, 1, 1, light, ny, nz); + + float sideU = 0.5F, sideV = 0.5F; + float edgeU = 0.5F + 0.5F * (sz[i] / radius); + float edgeV = 0.5F - 0.5F * (sy[i] / radius); + float edgeU2 = 0.5F + 0.5F * (sz[j] / radius); + float edgeV2 = 0.5F - 0.5F * (sy[j] / radius); + // outer sidewall (rim art, projected like a disc) + vertex(vc, matrix, x + half, y, z, su0 + sw * sideU, sv0 + sh * sideV, 1, 1, 1, 1, light, 1, 0, 0); + vertex(vc, matrix, x + half, y + sy[i], z + sz[i], su0 + sw * edgeU, sv0 + sh * edgeV, + 1, 1, 1, 1, light, 1, 0, 0); + vertex(vc, matrix, x + half, y + sy[j], z + sz[j], su0 + sw * edgeU2, sv0 + sh * edgeV2, + 1, 1, 1, 1, light, 1, 0, 0); + vertex(vc, matrix, x + half, y, z, su0 + sw * sideU, sv0 + sh * sideV, 1, 1, 1, 1, light, 1, 0, 0); + // inner face (hub) + vertex(vc, matrix, x - half, y + sy[j], z + sz[j], hu0 + hw * edgeU2, hv0 + hh * edgeV2, + 1, 1, 1, 1, light, -1, 0, 0); + vertex(vc, matrix, x - half, y + sy[i], z + sz[i], hu0 + hw * edgeU, hv0 + hh * edgeV, + 1, 1, 1, 1, light, -1, 0, 0); + vertex(vc, matrix, x - half, y, z, hu0 + hw * 0.5F, hv0 + hh * 0.5F, 1, 1, 1, 1, light, -1, 0, 0); + vertex(vc, matrix, x - half, y, z, hu0 + hw * 0.5F, hv0 + hh * 0.5F, 1, 1, 1, 1, light, -1, 0, 0); + } + } + + // ------------------------------------------------------------------ + // full car + // ------------------------------------------------------------------ + /** + * Draws one whole car. + * + * @param bodyColor tint applied to the body panels (the player's paint) + * @param trimColor tint applied to the decal/trim panels + * @param steer front wheel steer angle in degrees + * @param spin wheel spin in radians + * @param showDriver draw the AI driver + helmet + */ + public static void car(VertexConsumer vc, Matrix4f matrix, CarType type, CarSpec spec, + int bodyColor, int trimColor, float roll, float pitch, + float steer, float spin, boolean showDriver, int light, + float damage) { + float half = spec.width() / 2.0F; + float length = spec.length() / 2.0F; + float wheelRadius = spec.wheelRadius(); + float wheelWidth = Math.max(0.16F, spec.width() * 0.13F); + float lift = spec.bodyLift(); + + float br = ((bodyColor >> 16) & 0xFF) / 255.0F; + float bg = ((bodyColor >> 8) & 0xFF) / 255.0F; + float bb = (bodyColor & 0xFF) / 255.0F; + float tr = ((trimColor >> 16) & 0xFF) / 255.0F; + float tg = ((trimColor >> 8) & 0xFF) / 255.0F; + float tb = (trimColor & 0xFF) / 255.0F; + + // --- wheels first (they sit under the body) ---------------------- + float axleFront = length - wheelRadius - 0.05F; + float axleRear = -length + wheelRadius + 0.05F; + float axleY = wheelRadius; + float trackHalf = half - wheelWidth * 0.55F; + float frontSteer = (float) Math.toRadians(steer); + + wheelWithSteer(vc, matrix, -trackHalf, axleY, axleFront, wheelRadius, wheelWidth, spin, + frontSteer, light); + wheelWithSteer(vc, matrix, trackHalf, axleY, axleFront, wheelRadius, wheelWidth, spin, + frontSteer, light); + wheelWithSteer(vc, matrix, -trackHalf, axleY, axleRear, wheelRadius, wheelWidth, spin, 0.0F, light); + wheelWithSteer(vc, matrix, trackHalf, axleY, axleRear, wheelRadius, wheelWidth, spin, 0.0F, light); + if (spec.style() == CarSpec.BodyStyle.TRUCK) { + wheelWithSteer(vc, matrix, -trackHalf, axleY, axleRear + wheelRadius * 2.2F, wheelRadius, + wheelWidth, spin, 0.0F, light); + wheelWithSteer(vc, matrix, trackHalf, axleY, axleRear + wheelRadius * 2.2F, wheelRadius, + wheelWidth, spin, 0.0F, light); + } + + // --- body -------------------------------------------------------- + float bodyTop = lift + spec.height() * 0.62F; + float hullBottom = wheelRadius * 0.55F; + box(vc, matrix, -half, hullBottom, -length, half, bodyTop, length, + br, bg, bb, 1.0F, light, BODY_TOP, BODY_SIDE, BODY_FRONT, BODY_BACK, + BODY_SIDE, BODY_SIDE); + + // --- cabin / glass ------------------------------------------------ + if (spec.cockpitWindow()) { + float cabinFront = length * (spec.style() == CarSpec.BodyStyle.TRUCK ? 0.55F : 0.15F); + float cabinBack = -length * 0.55F; + float cabinTop = bodyTop + spec.height() * 0.42F; + float inset = half * 0.88F; + box(vc, matrix, -inset, bodyTop - 0.02F, cabinBack, inset, cabinTop, cabinFront, + br, bg, bb, 1.0F, light, BODY_TOP, BODY_SIDE, GLASS, GLASS, GLASS, GLASS); + } else { + // open wheel racer: airbox + head rest + boxAll(vc, matrix, -half * 0.34F, bodyTop, -length * 0.42F, half * 0.34F, + bodyTop + spec.height() * 0.34F, -length * 0.05F, tr, tg, tb, 1.0F, light, EXTRA); + } + + // --- front wing / splitter ---------------------------------------- + if (spec.openWheels()) { + boxAll(vc, matrix, -half * 1.05F, wheelRadius * 0.55F, length * 0.92F, + half * 1.05F, wheelRadius * 0.85F, length * 1.06F, tr, tg, tb, 1.0F, light, EXTRA); + boxAll(vc, matrix, -half * 0.75F, bodyTop + spec.height() * 0.45F, -length * 1.02F, + half * 0.75F, bodyTop + spec.height() * 0.6F, -length * 0.86F, tr, tg, tb, 1.0F, + light, EXTRA); + } + + // --- spoiler (from the aero tuning level) ------------------------- + float spoiler = lift + spec.height() * 0.95F; + boxAll(vc, matrix, -half * 0.98F, spoiler, -length * 0.98F, half * 0.98F, spoiler + 0.12F, + -length * 0.80F, 0.16F, 0.16F, 0.18F, 1.0F, light, EXTRA); + + // --- lights ------------------------------------------------------- + float lightGlow = damage > 0.5F ? 0.35F : 1.0F; + boxAll(vc, matrix, -half * 0.94F, lift + spec.height() * 0.22F, length - 0.02F, + -half * 0.5F, lift + spec.height() * 0.38F, length + 0.02F, + 1.0F, 0.95F * lightGlow, 0.7F * lightGlow, 1.0F, light, DETAIL); + boxAll(vc, matrix, half * 0.5F, lift + spec.height() * 0.22F, length - 0.02F, + half * 0.94F, lift + spec.height() * 0.38F, length + 0.02F, + 1.0F, 0.95F * lightGlow, 0.7F * lightGlow, 1.0F, light, DETAIL); + + // --- exhausts / boost flame --------------------------------------- + float exhaustY = wheelRadius * 0.8F; + boxAll(vc, matrix, -half * 0.6F, exhaustY - 0.09F, -length - 0.12F, + -half * 0.35F, exhaustY + 0.03F, -length + 0.06F, 0.75F, 0.75F, 0.78F, 1.0F, + light, EXTRA); + boxAll(vc, matrix, half * 0.35F, exhaustY - 0.09F, -length - 0.12F, + half * 0.6F, exhaustY + 0.03F, -length + 0.06F, 0.75F, 0.75F, 0.78F, 1.0F, + light, EXTRA); + + // --- driver (AI cars only: human drivers are rendered by Minecraft) - + if (showDriver) { + float seat = bodyTop; + float headY = seat + 0.30F; + boxAll(vc, matrix, -0.16F, seat - 0.02F, -0.2F, 0.16F, seat + 0.26F, 0.18F, + 0.22F, 0.24F, 0.32F, 1.0F, light, DETAIL); + boxAll(vc, matrix, -0.14F, headY + 0.16F, -0.16F, 0.14F, headY + 0.44F, 0.14F, + 0.90F, 0.90F, 0.94F, 1.0F, light, EXTRA); + // visor + boxAll(vc, matrix, -0.12F, headY + 0.26F, 0.13F, 0.12F, headY + 0.38F, 0.16F, + 0.20F, 0.45F, 0.85F, 1.0F, light, GLASS); + } + } + + private static void wheelWithSteer(VertexConsumer vc, Matrix4f matrix, float x, float y, float z, + float radius, float width, float spin, float steer, int light) { + org.joml.Matrix4f wheel = new org.joml.Matrix4f(matrix); + wheel.translate(x, y, z); + if (steer != 0.0F) { + wheel.rotate(steer, 0.0F, 1.0F, 0.0F); + } + wheel(vc, wheel, 0.0F, 0.0F, 0.0F, radius, width, spin, light); + } +} diff --git a/src/client/java/dev/qynl/racers/client/render/CarRenderer.java b/src/client/java/dev/qynl/racers/client/render/CarRenderer.java new file mode 100644 index 0000000..b059712 --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/render/CarRenderer.java @@ -0,0 +1,99 @@ +package dev.qynl.racers.client.render; + +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import org.joml.Matrix4f; + +import net.minecraft.client.render.RenderLayer; +import net.minecraft.client.render.VertexConsumer; +import net.minecraft.client.render.VertexConsumerProvider; +import net.minecraft.client.render.entity.EntityRenderer; +import net.minecraft.client.render.entity.EntityRendererFactory; +import net.minecraft.client.util.math.MatrixStack; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.RotationAxis; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.car.CarSpec; + +/** + * Renders any car: body panels tinted with the owner's paint, wheels that steer + * and spin, a driver for AI cars and a dimmed front light once the engine is + * damaged. + */ +public class CarRenderer extends EntityRenderer { + private final Map state = new HashMap<>(); + + public CarRenderer(EntityRendererFactory.Context context) { + super(context); + this.shadowRadius = 1.2F; + } + + @Override + public Identifier getTexture(CarEntity entity) { + return FabricRacers.id("textures/entity/car/" + entity.carType().textureName() + ".png"); + } + + @Override + public void render(CarEntity entity, float yaw, float tickDelta, MatrixStack matrices, + VertexConsumerProvider vertexConsumers, int light) { + CarSpec spec = entity.carType().spec(); + float bodyYaw = MathHelper.lerpAngleDegrees(tickDelta, entity.prevYaw, entity.getYaw()); + float[] spin = state.computeIfAbsent(entity.getUuid(), id -> new float[3]); + float speed = (float) entity.getSpeed(); + + // wheel spin: integrate the travelled distance + float radius = spec.wheelRadius(); + spin[0] += speed * tickDelta * 0.05F / Math.max(0.1F, radius); + if (spin[0] > (float) (Math.PI * 2.0)) { + spin[0] -= (float) (Math.PI * 2.0); + } + // body roll follows the slip angle, pitch follows acceleration + float targetRoll = MathHelper.clamp((float) (-entity.getSlip() * entity.getSteerFactor() * 0.34F), + -0.2F, 0.2F); + float targetPitch = MathHelper.clamp(spin[1] * 0.0F + (spin[2] - speed) * -0.006F, -0.16F, 0.16F); + spin[2] = speed; + spin[1] += (targetRoll - spin[1]) * 0.2F; + float roll = spin[1]; + float pitch = targetPitch; + + matrices.push(); + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(-bodyYaw)); + matrices.multiply(RotationAxis.POSITIVE_Z.rotationDegrees(roll * 57.2958F)); + matrices.multiply(RotationAxis.POSITIVE_X.rotationDegrees(pitch * 57.2958F)); + + VertexConsumer consumer = vertexConsumers.getBuffer( + RenderLayer.getEntityCutoutNoCull(getTexture(entity))); + Matrix4f matrix = matrices.peek().getPositionMatrix(); + + int bodyColor = entity.data().primaryColor; + int trimColor = entity.data().secondaryColor; + boolean showDriver = entity.isAiDriving(); + float damage = 1.0F - entity.healthFraction(); + + CarMesh.car(consumer, matrix, entity.carType(), spec, + bodyColor, trimColor, roll, pitch, + (float) (entity.getSteerFactor() * 30.0), spin[0], showDriver, light, damage); + matrices.pop(); + + // boost flame: a separate translucent pass so it blooms over the road + if (entity.isBoosting()) { + matrices.push(); + matrices.multiply(RotationAxis.POSITIVE_Y.rotationDegrees(-bodyYaw)); + VertexConsumer flame = vertexConsumers.getBuffer(RenderLayer.getEntityTranslucentCull( + FabricRacers.id("textures/entity/car/flame.png"))); + Matrix4f flameMatrix = matrices.peek().getPositionMatrix(); + float wobble = (float) Math.sin((entity.getWorld().getTime() + tickDelta) * 0.9) * 0.06F; + CarMesh.boxAll(flame, flameMatrix, + -0.26F - wobble, 0.22F, -spec.length() / 2.0F - 0.9F - wobble * 2.0F, + 0.26F + wobble, 0.52F, -spec.length() / 2.0F - 0.2F, + 1.0F, 0.62F, 0.18F, 0.75F, light, CarMesh.EXTRA); + matrices.pop(); + } + spin[2] = speed; + } +} diff --git a/src/client/java/dev/qynl/racers/client/screen/GarageScreen.java b/src/client/java/dev/qynl/racers/client/screen/GarageScreen.java new file mode 100644 index 0000000..ad78803 --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/screen/GarageScreen.java @@ -0,0 +1,133 @@ +package dev.qynl.racers.client.screen; + +import java.util.List; + +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarStats; +import dev.qynl.racers.car.CarType; +import dev.qynl.racers.car.PaintColors; +import dev.qynl.racers.car.Tune; +import dev.qynl.racers.client.ClientRaceState; +import dev.qynl.racers.net.ModNetworking; + +/** + * The garage: tune every system, repaint, hand the car to the AI, repair it. + * + *

Everything is a plain client screen; the server validates ownership and + * consumes the upgrade parts, so no inventory handler is involved. + */ +public class GarageScreen extends Screen { + private static final int PANEL = 0xE0101014; + + public GarageScreen() { + super(Text.translatable("screen.fabricracers.garage")); + } + + @Override + protected void init() { + super.init(); + CarData data = ClientRaceState.garageData; + if (data == null) { + close(); + return; + } + int x = width / 2 - 150; + int y = 76; + int index = 0; + for (Tune tune : Tune.values()) { + int level = data.level(tune); + int row = y + index * 22; + addDrawableChild(ButtonWidget.builder( + Text.literal("+ " + tune.displayName), button -> { + ClientRaceState.send(ModNetworking.REQ_TUNE, + ClientRaceState.garageEntityId, tune.ordinal(), 0, ""); + }).dimensions(x + 178, row, 62, 20).build()).active = level < tune.maxLevel; + index++; + } + + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.paint_primary"), + button -> ClientRaceState.send(ModNetworking.REQ_PAINT, + ClientRaceState.garageEntityId, 0, PaintColors.next(data.primaryColor(), 1), "")) + .dimensions(x, y + 168, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.paint_secondary"), + button -> ClientRaceState.send(ModNetworking.REQ_PAINT, + ClientRaceState.garageEntityId, 1, PaintColors.next(data.secondaryColor(), 1), "")) + .dimensions(x + 124, y + 168, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.repair"), + button -> ClientRaceState.send(ModNetworking.REQ_REPAIR, + ClientRaceState.garageEntityId, 0, 0, "")) + .dimensions(x, y + 192, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(ClientRaceState.garageAi + ? Text.translatable("screen.fabricracers.ai_off") + : Text.translatable("screen.fabricracers.ai_on"), + button -> ClientRaceState.send(ModNetworking.REQ_AI_TOGGLE, + ClientRaceState.garageEntityId, ClientRaceState.garageAi ? 0 : 1, 0, "")) + .dimensions(x + 124, y + 192, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("gui.done"), button -> close()) + .dimensions(width / 2 - 50, height - 30, 100, 20).build()); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + renderBackground(context, mouseX, mouseY, delta); + CarData data = ClientRaceState.garageData; + context.drawCenteredTextWithShadow(textRenderer, title, width / 2, 12, 0xFFF2C42A); + if (data == null) { + super.render(context, mouseX, mouseY, delta); + return; + } + int x = width / 2 - 150; + int y = 76; + CarType type = data.type; + CarStats stats = data.stats(); + + context.drawCenteredTextWithShadow(textRenderer, type.label(), width / 2, 26, 0xFFFFFFFF); + context.drawCenteredTextWithShadow(textRenderer, + Text.translatable("screen.fabricracers.stats", + String.format("%.0f", stats.topSpeedKmh()), + String.format("%.1f", stats.power()), + String.format("%.1f", stats.grip()), + ClientRaceState.garageHealth + "%"), width / 2, 38, 0xFFAAAAAA); + context.drawCenteredTextWithShadow(textRenderer, + Text.translatable("screen.fabricracers.hint_parts"), width / 2, 50, 0xFF888888); + + int index = 0; + for (Tune tune : Tune.values()) { + int row = y + index * 22; + int level = data.level(tune); + context.fill(x - 4, row - 2, x + 172, row + 18, PANEL); + context.drawTextWithShadow(textRenderer, tune.displayName, x, row + 2, 0xFFFFFFFF); + context.drawTextWithShadow(textRenderer, + Text.literal(level + "/" + tune.maxLevel).formatted(Formatting.GOLD), + x + 74, row + 2, 0xFFF2C42A); + int barX = x + 100; + context.fill(barX, row + 4, barX + 70, row + 14, 0xFF202028); + int filled = (int) (70.0F * level / tune.maxLevel); + context.fill(barX, row + 4, barX + filled, row + 14, 0xFF4FD05A); + int cost = ClientRaceState.garageExtra.getInt(tune.id); + int owned = ClientRaceState.garageExtra.getInt(tune.id + "_owned"); + context.drawTextWithShadow(textRenderer, + Text.literal(level >= tune.maxLevel ? "MAX" + : cost + "x (" + owned + ")"), barX - 0, row + 18 - 22 + 16, 0xFFAAAAAA); + index++; + } + + context.drawTextWithShadow(textRenderer, + Text.translatable("screen.fabricracers.colors", + PaintColors.nameOf(data.primaryColor), + PaintColors.nameOf(data.secondaryColor)), + x, y + 148, 0xFFDDDDDD); + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean shouldPause() { + return false; + } +} diff --git a/src/client/java/dev/qynl/racers/client/screen/RacePanelScreen.java b/src/client/java/dev/qynl/racers/client/screen/RacePanelScreen.java new file mode 100644 index 0000000..54b6b28 --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/screen/RacePanelScreen.java @@ -0,0 +1,144 @@ +package dev.qynl.racers.client.screen; + +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.nbt.NbtList; + +import dev.qynl.racers.client.ClientRaceState; +import dev.qynl.racers.net.ModNetworking; + +/** Race control: start an event, join the grid, leave, look at records. */ +public class RacePanelScreen extends Screen { + private int laps = 3; + private int ai = 5; + private double skill = 0.72; + + public RacePanelScreen(Text title) { + super(title); + } + + @Override + protected void init() { + super.init(); + int x = width / 2 - 120; + int y = 60; + addDrawableChild(ButtonWidget.builder( + Text.translatable("screen.fabricracers.start_race", laps, ai, + skillName()), button -> { + int packed = ((int) Math.round(skill * 100.0)) | (ai << 8); + ClientRaceState.send(ModNetworking.REQ_HOST_RACE, 0, laps, packed, ""); + close(); + }) + .dimensions(x, y + 60, 240, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.laps", laps), + button -> { + laps = laps >= 10 ? 1 : laps + 1; + clearAndInit(); + }) + .dimensions(x, y, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.ai_count", ai), + button -> { + ai = ai >= 10 ? 0 : ai + 1; + clearAndInit(); + }) + .dimensions(x + 124, y, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.skill", skillName()), + button -> { + skill = skill >= 0.95 ? 0.4 : skill + 0.15; + clearAndInit(); + }) + .dimensions(x, y + 24, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.records"), + button -> ClientRaceState.send(ModNetworking.REQ_STATS, 0, 0, 0, "")) + .dimensions(x + 124, y + 24, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.join_grid"), + button -> ClientRaceState.send(ModNetworking.REQ_JOIN_RACE, 0, 0, 0, "")) + .dimensions(x, y + 88, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.leave_race"), + button -> ClientRaceState.send(ModNetworking.REQ_LEAVE_RACE, 0, 0, 0, "")) + .dimensions(x + 124, y + 88, 116, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("gui.done"), button -> close()) + .dimensions(width / 2 - 50, height - 30, 100, 20).build()); + } + + private String skillName() { + if (skill >= 0.95) { + return "Alien"; + } + if (skill >= 0.8) { + return "Pro"; + } + if (skill >= 0.6) { + return "Club"; + } + return "Rookie"; + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + renderBackground(context, mouseX, mouseY, delta); + context.drawCenteredTextWithShadow(textRenderer, title, width / 2, 14, 0xFFF2C42A); + context.drawCenteredTextWithShadow(textRenderer, + Text.translatable("screen.fabricracers.panel_hint"), width / 2, 30, 0xFFAAAAAA); + + ModNetworking.Hud hud = ClientRaceState.hud; + if (hud != null && hud.state() >= 0) { + context.drawCenteredTextWithShadow(textRenderer, + Text.literal(hud.trackName() + " - Lap " + hud.lap() + "/" + hud.laps() + + " - P" + hud.position() + "/" + hud.total()), width / 2, 44, + 0xFFFFFFFF); + } else { + context.drawCenteredTextWithShadow(textRenderer, + Text.translatable("screen.fabricracers.not_racing").formatted(Formatting.GRAY), + width / 2, 44, 0xFF888888); + } + + // profile card + int y = 152; + context.drawTextWithShadow(textRenderer, Text.translatable("screen.fabricracers.profile"), + width / 2 - 120, y, 0xFFF2C42A); + y += 12; + String[] rows = { + "Races: " + ClientRaceState.profile.getInt("races"), + "Wins: " + ClientRaceState.profile.getInt("wins") + " Podiums: " + + ClientRaceState.profile.getInt("podiums"), + "Laps: " + ClientRaceState.profile.getInt("laps") + " Overtakes: " + + ClientRaceState.profile.getInt("overtakes"), + "Distance: " + String.format("%.0f", ClientRaceState.profile.getDouble("distance")) + + " blocks Top speed: " + + String.format("%.0f", ClientRaceState.profile.getDouble("topSpeed")) + " km/h", + "Drift points: " + ClientRaceState.profile.getInt("drift") + " Trophies: " + + ClientRaceState.profile.getInt("trophies"), + "Cars placed: " + ClientRaceState.profile.getInt("cars") + + " Tracks recorded: " + ClientRaceState.profile.getInt("tracks"), + "Title: " + ClientRaceState.profile.getString("title"), + }; + for (String row : rows) { + context.drawTextWithShadow(textRenderer, Text.literal(row), width / 2 - 120, y, 0xFFDDDDDD); + y += 11; + } + NbtList bestLaps = ClientRaceState.profile.getList("bestLaps", 10); + if (!bestLaps.isEmpty()) { + context.drawTextWithShadow(textRenderer, Text.translatable("screen.fabricracers.best_laps"), + width / 2 + 20, 152, 0xFFF2C42A); + int lineY = 164; + for (int i = 0; i < Math.min(10, bestLaps.size()); i++) { + var entry = bestLaps.getCompound(i); + context.drawTextWithShadow(textRenderer, + Text.literal(entry.getString("track") + " " + + dev.qynl.racers.util.Stopwatch.format(entry.getLong("ms"))), + width / 2 + 20, lineY, 0xFFBBBBBB); + lineY += 11; + } + } + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean shouldPause() { + return false; + } +} diff --git a/src/client/java/dev/qynl/racers/client/screen/TrackSelectScreen.java b/src/client/java/dev/qynl/racers/client/screen/TrackSelectScreen.java new file mode 100644 index 0000000..2cf60ae --- /dev/null +++ b/src/client/java/dev/qynl/racers/client/screen/TrackSelectScreen.java @@ -0,0 +1,98 @@ +package dev.qynl.racers.client.screen; + +import java.util.List; + +import net.minecraft.client.gui.DrawContext; +import net.minecraft.client.gui.screen.Screen; +import net.minecraft.client.gui.widget.ButtonWidget; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import dev.qynl.racers.client.ClientRaceState; +import dev.qynl.racers.net.ModNetworking; + +/** + * The circuit catalogue shown by the track wand: pick a layout, pick a + * rotation, build it. Also the entry point for recording your own. + */ +public class TrackSelectScreen extends Screen { + private int page; + private int rotation; + private static final int PER_PAGE = 8; + + public TrackSelectScreen(Text title) { + super(title); + this.rotation = ClientRaceState.wandRotation; + } + + @Override + protected void init() { + super.init(); + List tracks = ClientRaceState.tracks; + int pages = Math.max(1, (tracks.size() + PER_PAGE - 1) / PER_PAGE); + page = Math.min(page, pages - 1); + int start = page * PER_PAGE; + int y = 60; + for (int i = start; i < Math.min(tracks.size(), start + PER_PAGE); i++) { + ClientRaceState.TrackInfo info = tracks.get(i); + int rowY = y + (i - start) * 22; + addDrawableChild(ButtonWidget.builder( + Text.literal("Place " + info.name()), button -> { + ClientRaceState.send(ModNetworking.REQ_PLACE_TRACK, 0, rotation, 0, info.id()); + close(); + }).dimensions(width / 2 - 150, rowY, 120, 20).build()); + } + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.rotate", + rotation), button -> { + rotation = (rotation + 90) % 360; + clearAndInit(); + }) + .dimensions(width / 2 - 24, height - 58, 120, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.record"), + button -> { + ClientRaceState.send(ModNetworking.REQ_RECORD_TRACK, 0, 7, 0, ""); + close(); + }) + .dimensions(width / 2 - 150, height - 58, 120, 20).build()); + addDrawableChild(ButtonWidget.builder(Text.literal("<"), button -> { + page = Math.max(0, page - 1); + clearAndInit(); + }).dimensions(width / 2 - 150, height - 34, 40, 20).build()).active = page > 0; + addDrawableChild(ButtonWidget.builder(Text.literal(">"), button -> { + page++; + clearAndInit(); + }).dimensions(width / 2 - 104, height - 34, 40, 20).build()) + .active = (page + 1) * PER_PAGE < ClientRaceState.tracks.size(); + addDrawableChild(ButtonWidget.builder(Text.translatable("gui.cancel"), button -> close()) + .dimensions(width / 2 + 90, height - 34, 60, 20).build()); + } + + @Override + public void render(DrawContext context, int mouseX, int mouseY, float delta) { + renderBackground(context, mouseX, mouseY, delta); + context.drawCenteredTextWithShadow(textRenderer, title, width / 2, 14, 0xFFF2C42A); + String anchor = ClientRaceState.wandAnchor == null ? "?" + : ClientRaceState.wandAnchor.getX() + ", " + ClientRaceState.wandAnchor.getY() + + ", " + ClientRaceState.wandAnchor.getZ(); + context.drawCenteredTextWithShadow(textRenderer, + Text.translatable("screen.fabricracers.anchor", anchor, rotation), width / 2, 28, + 0xFFAAAAAA); + int start = page * PER_PAGE; + for (int i = start; i < Math.min(ClientRaceState.tracks.size(), start + PER_PAGE); i++) { + ClientRaceState.TrackInfo info = ClientRaceState.tracks.get(i); + int rowY = 60 + (i - start) * 22; + context.drawTextWithShadow(textRenderer, + Text.literal(info.sizeX() + "x" + info.sizeZ() + " " + info.checkpoints() + + " checkpoints " + info.laps() + " laps " + info.width() + " wide" + + (info.stock() ? "" : " [player]")).formatted( + info.stock() ? Formatting.GRAY : Formatting.GREEN), + width / 2 - 20, rowY + 6, 0xFFAAAAAA); + } + super.render(context, mouseX, mouseY, delta); + } + + @Override + public boolean shouldPause() { + return false; + } +} diff --git a/src/main/java/dev/qynl/racers/FabricRacers.java b/src/main/java/dev/qynl/racers/FabricRacers.java new file mode 100644 index 0000000..f34226b --- /dev/null +++ b/src/main/java/dev/qynl/racers/FabricRacers.java @@ -0,0 +1,86 @@ +package dev.qynl.racers; + +import net.fabricmc.api.ModInitializer; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerLifecycleEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.networking.v1.ServerPlayConnectionEvents; +import net.minecraft.util.Identifier; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +import dev.qynl.racers.command.RaceCommands; +import dev.qynl.racers.event.CarServerEvents; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.race.RaceManager; +import dev.qynl.racers.registry.ModBlocks; +import dev.qynl.racers.registry.ModComponents; +import dev.qynl.racers.registry.ModEntities; +import dev.qynl.racers.registry.ModItems; +import dev.qynl.racers.registry.ModSounds; +import dev.qynl.racers.shop.PartShop; +import dev.qynl.racers.stats.StatsManager; +import dev.qynl.racers.track.TrackManager; + +/** + * FabricRacers - a complete racing game inside Minecraft. + * + *

Feature map (see README.md for the player facing docs): + *

    + *
  • {@link dev.qynl.racers.car.CarEntity} - arcade/semi-physical car physics with + * gears, grip limits, drifting, boost, damage and tuning levels.
  • + *
  • {@link dev.qynl.racers.ai.AiDriver} - AI drivers that follow a racing line, + * brake for corners, overtake, avoid traffic, rubber-band and make mistakes.
  • + *
  • {@link dev.qynl.racers.track.TrackManager} - tracks are handled through + * placeable track "anchors" backed by vanilla structure files.
  • + *
  • {@link dev.qynl.racers.race.RaceManager} - the race engine: formats, grids, + * countdown, checkpoints, laps, standings, pit stops, prizes and world records.
  • + *
+ */ +public class FabricRacers implements ModInitializer { + public static final String MOD_ID = "fabricracers"; + public static final Logger LOGGER = LoggerFactory.getLogger("FabricRacers"); + + public static Identifier id(String path) { + return Identifier.of(MOD_ID, path); + } + + @Override + public void onInitialize() { + ModBlocks.init(); + ModItems.init(); + ModEntities.init(); + ModComponents.init(); + ModSounds.init(); + ModNetworking.init(); + PartShop.init(); + + CarServerEvents.init(); + RaceCommands.init(); + + // --- world lifecycle ------------------------------------------------- + ServerLifecycleEvents.SERVER_STARTED.register(server -> { + StatsManager.get(server).load(); + TrackManager.get(server).load(); + }); + ServerLifecycleEvents.SERVER_STOPPING.register(server -> { + StatsManager.get(server).save(); + TrackManager.get(server).save(); + RaceManager.get(server).shutdown(); + }); + + ServerTickEvents.END_SERVER_TICK.register(server -> { + RaceManager.get(server).tick(); + TrackManager.get(server).tick(); + }); + + ServerPlayConnectionEvents.JOIN.register((handler, sender, server) -> { + StatsManager.get(server).onJoin(handler.getPlayer()); + TrackManager.get(server).syncTo(handler.getPlayer()); + RaceManager.get(server).onJoin(handler.getPlayer()); + }); + ServerPlayConnectionEvents.DISCONNECT.register((handler, server) -> + RaceManager.get(server).onDisconnect(handler.getPlayer())); + + LOGGER.info("FabricRacers loaded - start your engines."); + } +} diff --git a/src/main/java/dev/qynl/racers/ai/AiDriver.java b/src/main/java/dev/qynl/racers/ai/AiDriver.java new file mode 100644 index 0000000..2c9654c --- /dev/null +++ b/src/main/java/dev/qynl/racers/ai/AiDriver.java @@ -0,0 +1,344 @@ +package dev.qynl.racers.ai; + +import java.util.List; +import java.util.Random; + +import net.minecraft.entity.Entity; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; + +import dev.qynl.racers.car.CarControls; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.car.CarStats; +import dev.qynl.racers.race.RaceEntry; +import dev.qynl.racers.race.RaceSession; +import dev.qynl.racers.track.TrackPlacement; + +/** + * The AI driver. One instance per car, stateless between ticks except for its + * small memory of where it is on the line, how long it has been stuck and + * whether it is currently in the middle of a mistake. + * + *

Skill (0..1) controls three things: how far ahead it looks, how close to + * the grip limit it dares to corner, and how often it makes a mistake. Rubber + * banding is optional and per race. + */ +public class AiDriver { + private final Random random = new Random(); + private int lineIndex; + private int lastLineIndex; + private int stuckTicks; + private int reverseTicks; + private int mistakeTicks; + private double mistakeSteer; + private int mistakeCooldown = 60; + private int boostHold; + private int overtakeTicks; + private double overtakeOffset; + private Vec3d roamTarget; + private int roamTicks; + private int targetRefresh; + + public CarControls tick(CarEntity car) { + CarControls controls = new CarControls(); + if (car.isRemoved() || car.isBroken()) { + controls.handbrake = true; + return controls; + } + RaceSession session = sessionFor(car); + TrackPlacement placement = session == null ? null : session.placement(); + if (placement != null && placement.line() != null) { + raceLine(car, session, placement, controls); + } else { + roam(car, controls); + } + return controls; + } + + private RaceSession sessionFor(CarEntity car) { + if (car.getWorld().isClient()) { + return null; + } + var manager = car.raceManager(); + return manager == null ? null : manager.sessionFor(car); + } + + // ------------------------------------------------------------------ + // racing + // ------------------------------------------------------------------ + private void raceLine(CarEntity car, RaceSession session, TrackPlacement placement, + CarControls controls) { + RacingLine line = placement.line(); + double skill = car.aiSkill(); + double speed = Math.abs(car.getSpeed()); + int nearest = line.nearestIndex(car.getX(), car.getZ(), lineIndex); + if (nearest != lastLineIndex) { + lineIndex = nearest; + lastLineIndex = nearest; + } + + // --- how far ahead to look -------------------------------------- + double look = 7.0 + speed * (0.45 + 0.25 * skill); + if (mistakeTicks > 0) { + look *= 0.6; + } + int targetIndex = line.indexAhead(lineIndex, look); + double lateral = 0.0; + + // --- overtaking: pick a side when someone is in the way ---------- + RaceEntry me = session == null ? null : session.entryFor(car); + if (overtakeTicks > 0) { + overtakeTicks--; + lateral = overtakeOffset; + } else { + CarEntity ahead = carAhead(car, 14.0); + if (ahead != null && Math.abs(car.getSpeed()) > 3.0) { + double side = chooseSide(car, line, ahead); + if (side != 0.0) { + overtakeOffset = side * Math.min(2.6, line.halfWidth(lineIndex) * 0.55); + overtakeTicks = 40 + random.nextInt(30); + lateral = overtakeOffset; + } + } + } + if (lateral != 0.0) { + targetIndex = line.offsetIndex(targetIndex, lateral); + } + Vec3d target = line.pointAt(targetIndex); + + // --- steering ---------------------------------------------------- + double desiredYaw = RacingLine.angleTo(car.getX(), car.getZ(), target.x, target.z); + double error = RacingLine.yawError(desiredYaw, car.getYaw()); + double steerGain = 0.055 + 0.035 * (1.0 - skill); + double steer = MathHelper.clamp(error * steerGain, -1.0, 1.0); + if (error > 60.0) { + steer = Math.signum(error); + } + if (mistakeTicks > 0) { + steer = MathHelper.clamp(steer + mistakeSteer, -1.0, 1.0); + } + controls.analogSteer = steer; + + // --- targets ------------------------------------------------------ + CarStats stats = car.stats(); + double brakeDistance = 6.0 + speed * speed / Math.max(3.0, stats.brakeForce() * 0.85); + double curvature = Math.max(line.maxCurvatureAhead(lineIndex, brakeDistance), 1e-4); + double maxLateral = stats.grip() * (0.72 + 0.26 * skill); + double cornerSpeed = Math.sqrt(maxLateral / curvature); + double targetSpeed = Math.min(stats.topSpeed() * (0.86 + 0.14 * skill), cornerSpeed); + + // do not ask for more than the road can give + targetSpeed = Math.max(4.0, targetSpeed); + + // rubber banding keeps the pack close and the races fun + if (me != null && session != null && car.aiRubberBand()) { + targetSpeed *= session.rubberBand(car, me); + } + if (mistakeTicks > 0) { + targetSpeed *= 0.72; + } + + // --- throttle / brake -------------------------------------------- + if (speed < targetSpeed * 0.97) { + controls.analogThrottle = Math.min(1.0, 0.55 + 0.45 * skill); + controls.forward = true; + } else if (speed > targetSpeed * 1.05) { + controls.analogThrottle = 0.0; + controls.back = true; + } else { + controls.analogThrottle = 0.35; + } + // trail braking: ease off when the nose is already turning hard + if (Math.abs(steer) > 0.75 && speed > targetSpeed * 0.9) { + controls.analogThrottle = 0.0; + controls.back = true; + } + // handbrake drifts when the corner is tight and the driver is good + if (skill > 0.62 && curvature > 0.055 && speed > 8.0 && random.nextInt(40) == 0) { + controls.handbrake = true; + } + // boost on the straights + if (boostHold > 0) { + boostHold--; + controls.boost = true; + } else if (curvature < 0.012 && car.getBoostCharge() > 0.55 && speed > stats.topSpeed() * 0.5 + && random.nextInt(35) == 0) { + boostHold = 25 + random.nextInt(25); + } + + // --- avoidance ---------------------------------------------------- + if (aheadBlocked(car, 5.0) || carAhead(car, 4.5) != null) { + controls.forward = false; + controls.analogThrottle = 0.0; + controls.back = speed > 6.0; + } + + // --- mistakes ----------------------------------------------------- + if (mistakeCooldown > 0) { + mistakeCooldown--; + } else if (mistakeTicks <= 0) { + double chance = (1.0 - skill) * 0.09 + 0.004; + if (random.nextDouble() < chance) { + mistakeTicks = 12 + random.nextInt(22); + mistakeSteer = (random.nextDouble() - 0.5) * 0.65; + mistakeCooldown = 60 + random.nextInt(220); + } + } + if (mistakeTicks > 0) { + mistakeTicks--; + } + + // --- stuck / recovery --------------------------------------------- + if (car.stuckTicks() > 26) { + reverseTicks = 22 + random.nextInt(20); + } + if (reverseTicks > 0) { + reverseTicks--; + controls.forward = false; + controls.analogThrottle = 0.0; + controls.back = true; + controls.analogSteer = -steer; + } + } + + /** Finds a car close ahead in the driving direction. */ + private CarEntity carAhead(CarEntity car, double range) { + Vec3d forward = Vec3d.fromPolar(0.0F, car.getYaw()); + List cars = car.getWorld().getEntitiesByClass(CarEntity.class, + car.getBoundingBox().expand(range, 3.0, range), other -> other != car); + CarEntity closest = null; + double best = range * range; + for (CarEntity other : cars) { + Vec3d delta = other.getPos().subtract(car.getPos()); + double along = delta.dotProduct(forward); + if (along < 0.5 || along > range) { + continue; + } + double lateral = Math.abs(delta.x * -forward.z + delta.z * forward.x); + if (lateral > 2.6) { + continue; + } + double d = delta.horizontalLengthSquared(); + if (d < best) { + best = d; + closest = other; + } + } + return closest; + } + + private double chooseSide(CarEntity car, RacingLine line, CarEntity ahead) { + int index = line.nearestIndex(car.getX(), car.getZ(), lineIndex); + double half = line.halfWidth(index); + if (half < 2.2) { + return 0.0; + } + double lateral = ahead.getX() * 0.0; + Vec3d forward = Vec3d.fromPolar(0.0F, car.getYaw()); + Vec3d right = new Vec3d(-forward.z, 0.0, forward.x); + Vec3d delta = ahead.getPos().subtract(car.getPos()); + double offset = delta.dotProduct(right); + // go around the side the other car is NOT on + return offset > 0.0 ? -1.0 : 1.0; + } + + /** Cheap forward probe: is there a wall right in front of us? */ + private boolean aheadBlocked(CarEntity car, double distance) { + World world = car.getWorld(); + Vec3d forward = Vec3d.fromPolar(0.0F, car.getYaw()); + for (double d = 1.6; d <= distance; d += 1.2) { + BlockPos pos = BlockPos.ofFloored(car.getX() + forward.x * d, car.getY() + 0.3, + car.getZ() + forward.z * d); + var state = world.getBlockState(pos); + if (!state.isAir() && state.isSolidBlock(world, pos)) { + return true; + } + } + return false; + } + + // ------------------------------------------------------------------ + // roaming: drive around when there is no track at all + // ------------------------------------------------------------------ + private void roam(CarEntity car, CarControls controls) { + World world = car.getWorld(); + if (roamTarget == null || roamTicks-- <= 0 + || car.getPos().distanceTo(roamTarget) < 6.0) { + pickRoamTarget(car, world); + } + if (roamTarget == null) { + controls.handbrake = true; + return; + } + double desiredYaw = RacingLine.angleTo(car.getX(), car.getZ(), roamTarget.x, roamTarget.z); + double error = RacingLine.yawError(desiredYaw, car.getYaw()); + controls.analogSteer = MathHelper.clamp(error * 0.05, -1.0, 1.0); + + boolean blocked = aheadBlocked(car, 7.0); + if (blocked) { + controls.analogSteer = car.getYaw() > 0 ? -1.0 : 1.0; + if (Math.abs(car.getSpeed()) < 5.0) { + // try a bit of a shuffle to get free + controls.forward = random.nextBoolean(); + controls.back = !controls.forward; + } + if (random.nextInt(20) == 0) { + pickRoamTarget(car, world); + } + return; + } + double limit = car.stats().topSpeed() * 0.55; + double speed = Math.abs(car.getSpeed()); + if (speed < limit) { + controls.forward = true; + controls.analogThrottle = 0.7; + } + if (Math.abs(error) > 45.0 && speed > 12.0) { + controls.back = true; + controls.forward = false; + } + if (car.stuckTicks() > 30) { + reverseTicks = 25; + } + if (reverseTicks > 0) { + reverseTicks--; + controls.back = true; + controls.forward = false; + controls.analogSteer = -controls.analogSteer; + } + } + + private void pickRoamTarget(CarEntity car, World world) { + for (int attempt = 0; attempt < 12; attempt++) { + double angle = random.nextDouble() * Math.PI * 2.0; + double distance = 40.0 + random.nextDouble() * 60.0; + double x = car.getX() + Math.cos(angle) * distance; + double z = car.getZ() + Math.sin(angle) * distance; + BlockPos pos = BlockPos.ofFloored(x, car.getY(), z); + var ground = world.getBlockState(pos.down()); + if (ground.isAir()) { + for (int y = 0; y < 12; y++) { + if (!world.getBlockState(pos.down(y)).isAir()) { + pos = pos.down(y); + break; + } + } + } + if (!world.getBlockState(pos.down()).isAir() + && world.getBlockState(pos).isAir() && world.getBlockState(pos.up()).isAir()) { + roamTarget = new Vec3d(x, pos.getY(), z); + roamTicks = 260 + random.nextInt(200); + return; + } + } + roamTicks = 120; + roamTarget = null; + } + + /** Used by the renderer to draw the AI thinking about its line. */ + public int lineIndex() { + return lineIndex; + } +} diff --git a/src/main/java/dev/qynl/racers/ai/RacingLine.java b/src/main/java/dev/qynl/racers/ai/RacingLine.java new file mode 100644 index 0000000..f0970ba --- /dev/null +++ b/src/main/java/dev/qynl/racers/ai/RacingLine.java @@ -0,0 +1,216 @@ +package dev.qynl.racers.ai; + +import java.util.List; + +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; + +/** + * A closed racing line: the smooth path the AI drivers aim at, in world + * coordinates, with per-point curvature and width. + * + *

Lines are built once per track from its checkpoints (which the track + * format stores in driving order) and then cached by {@link dev.qynl.racers.track.Track}. + */ +public final class RacingLine { + private final double[] xs; + private final double[] zs; + private final double[] curv; + private final double[] half; + private final int size; + + private RacingLine(double[] xs, double[] zs, double[] curv, double[] half) { + this.xs = xs; + this.zs = zs; + this.curv = curv; + this.half = half; + this.size = xs.length; + } + + /** Builds a loop from ordered waypoints, resampled to ~1m spacing. */ + public static RacingLine fromWaypoints(List waypoints, double halfWidth, int samples) { + int n = waypoints.size(); + if (n < 3) { + return null; + } + double[] px = new double[n]; + double[] pz = new double[n]; + for (int i = 0; i < n; i++) { + px[i] = waypoints.get(i)[0]; + pz[i] = waypoints.get(i)[1]; + } + // Catmull-Rom through the loop + java.util.ArrayList dense = new java.util.ArrayList<>(); + for (int i = 0; i < n; i++) { + double[] p0 = {px[(i - 1 + n) % n], pz[(i - 1 + n) % n]}; + double[] p1 = {px[i], pz[i]}; + double[] p2 = {px[(i + 1) % n], pz[(i + 1) % n]}; + double[] p3 = {px[(i + 2) % n], pz[(i + 2) % n]}; + for (int s = 0; s < samples; s++) { + double t = s / (double) samples; + double t2 = t * t; + double t3 = t2 * t; + double x = 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t + + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3); + double z = 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t + + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3); + dense.add(new double[]{x, z}); + } + } + // resample to 1 block spacing + java.util.ArrayList out = new java.util.ArrayList<>(); + double carry = 0.0; + for (int i = 0; i < dense.size(); i++) { + double[] a = dense.get(i); + double[] b = dense.get((i + 1) % dense.size()); + double seg = Math.hypot(b[0] - a[0], b[1] - a[1]); + double travelled = carry; + while (travelled < seg) { + double f = seg <= 1e-6 ? 0.0 : travelled / seg; + out.add(new double[]{a[0] + (b[0] - a[0]) * f, a[1] + (b[1] - a[1]) * f}); + travelled += 1.0; + } + carry = travelled - seg; + } + if (out.size() < 8) { + return null; + } + int m = out.size(); + double[] xs = new double[m]; + double[] zs = new double[m]; + double[] halfArr = new double[m]; + for (int i = 0; i < m; i++) { + xs[i] = out.get(i)[0]; + zs[i] = out.get(i)[1]; + halfArr[i] = halfWidth; + } + double[] curv = computeCurvature(xs, zs); + return new RacingLine(xs, zs, curv, halfArr); + } + + private static double[] computeCurvature(double[] xs, double[] zs) { + int m = xs.length; + double[] k = new double[m]; + for (int i = 0; i < m; i++) { + int a = (i - 3 + m) % m; + int b = (i + 3) % m; + double x1 = xs[b] - xs[i]; + double z1 = zs[b] - zs[i]; + double x2 = xs[i] - xs[a]; + double z2 = zs[i] - zs[a]; + double cross = x1 * z2 - z1 * x2; + double d1 = Math.hypot(x1, z1); + double d2 = Math.hypot(x2, z2); + double d3 = Math.hypot(x1 - x2, z1 - z2); + double area = d1 * d2 * d3; + k[i] = area < 1e-4 ? 0.0 : Math.abs(cross * 2.0 / area); + } + // light smoothing so a single noisy sample cannot make the AI brake-check + double[] smooth = new double[m]; + for (int i = 0; i < m; i++) { + double sum = 0.0; + for (int o = -2; o <= 2; o++) { + sum += k[(i + o + m) % m]; + } + smooth[i] = sum / 5.0; + } + return smooth; + } + + public int size() { + return size; + } + + public double x(int index) { + return xs[Math.floorMod(index, size)]; + } + + public double z(int index) { + return zs[Math.floorMod(index, size)]; + } + + public double curvature(int index) { + return curv[Math.floorMod(index, size)]; + } + + public double halfWidth(int index) { + return half[Math.floorMod(index, size)]; + } + + public Vec3d pointAt(int index) { + return new Vec3d(x(index), 0.0, z(index)); + } + + /** Index of the closest point, searching around {@code hint}. */ + public int nearestIndex(double x, double z, int hint) { + if (size == 0) { + return 0; + } + int best = Math.floorMod(hint, size); + double bestDist = Double.MAX_VALUE; + int window = Math.max(12, size / 4); + for (int o = -window; o <= window; o++) { + int i = Math.floorMod(best + o, size); + double d = Math.hypot(xs[i] - x, zs[i] - z); + if (d < bestDist) { + bestDist = d; + best = i; + } + } + return best; + } + + /** Index roughly {@code distance} blocks ahead of {@code from}. */ + public int indexAhead(int from, double distance) { + return Math.floorMod(from + (int) Math.round(distance), size); + } + + /** Strongest curvature within the next {@code distance} blocks. */ + public double maxCurvatureAhead(int from, double distance) { + int steps = Math.max(1, (int) Math.round(distance)); + double max = 0.0; + for (int i = 0; i <= steps; i++) { + max = Math.max(max, curvature(from + i)); + } + return max; + } + + public double yaw(int index) { + int next = Math.floorMod(index + 2, size); + int prev = Math.floorMod(index - 2, size); + double dx = xs[next] - xs[prev]; + double dz = zs[next] - zs[prev]; + return Math.toDegrees(Math.atan2(-dx, dz)); + } + + /** Lateral offset (clamped to the road) of a point, used for overtaking lines. */ + public int offsetIndex(int index, double lateral) { + int next = Math.floorMod(index + 3, size); + int prev = Math.floorMod(index - 3, size); + double tx = xs[next] - xs[prev]; + double tz = zs[next] - zs[prev]; + double len = Math.max(0.001, Math.hypot(tx, tz)); + double rx = -tz / len; + double rz = tx / len; + double px = xs[Math.floorMod(index, size)] + rx * lateral; + double pz = zs[Math.floorMod(index, size)] + rz * lateral; + // return the closest index to the offset point (cheap: we are within ~2 blocks) + return nearestIndex(px, pz, index); + } + + public static double distance(double x1, double z1, double x2, double z2) { + return Math.hypot(x1 - x2, z1 - z2); + } + + public static double angleTo(double fromX, double fromZ, double toX, double toZ) { + double dx = toX - fromX; + double dz = toZ - fromZ; + return Math.toDegrees(Math.atan2(-dx, dz)); + } + + public static double yawError(double desired, double current) { + return MathHelper.wrapDegrees(desired - current); + } +} diff --git a/src/main/java/dev/qynl/racers/block/RacerBlocks.java b/src/main/java/dev/qynl/racers/block/RacerBlocks.java new file mode 100644 index 0000000..8fc44ef --- /dev/null +++ b/src/main/java/dev/qynl/racers/block/RacerBlocks.java @@ -0,0 +1,197 @@ +package dev.qynl.racers.block; + +import java.util.Locale; + +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.block.enums.NoteBlockInstrument; +import net.minecraft.entity.Entity; +import net.minecraft.item.ItemPlacementContext; +import net.minecraft.sound.BlockSoundGroup; +import net.minecraft.state.StateManager; +import net.minecraft.state.property.BooleanProperty; +import net.minecraft.state.property.EnumProperty; +import net.minecraft.state.property.Properties; +import net.minecraft.util.StringIdentifiable; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; +import net.minecraft.world.WorldView; + +/** + * All of the mod's building blocks. + * + *

Most of them are plain decorative blocks used to paint a circuit (asphalt, + * kerbs, barriers, pit lane, ...). The interesting ones implement the little + * bits of gameplay: checkpoint markers light up while a race is running, the + * start gantry is animated, boost pads launch cars forward, and fake ice/slime + * behave exactly like their names suggest. + */ +public final class RacerBlocks { + private RacerBlocks() { + } + + public enum KerbColor implements StringIdentifiable { + RED("red"), + WHITE("white"); + + private final String name; + + KerbColor(String name) { + this.name = name; + } + + @Override + public String asString() { + return name; + } + } + + /** Plain tarmac. */ + public static class Asphalt extends Block { + public Asphalt(Settings settings) { + super(settings); + } + } + + /** Tarmac with a painted centre line. */ + public static class RoadLine extends Block { + public RoadLine(Settings settings) { + super(settings); + } + } + + /** Checkered start/finish line. */ + public static class FinishLine extends Block { + public FinishLine(Settings settings) { + super(settings); + } + } + + /** Pit lane tarmac - no lap timing, but full grip. */ + public static class PitLane extends Block { + public PitLane(Settings settings) { + super(settings); + } + } + + /** Red/white kerb. */ + public static class Kerb extends Block { + public static final EnumProperty COLOR = + EnumProperty.of("color", KerbColor.class); + + public Kerb(Settings settings) { + super(settings); + setDefaultState(getDefaultState().with(COLOR, KerbColor.RED)); + } + + @Override + protected void appendProperties(StateManager.Builder builder) { + builder.add(COLOR); + } + } + + /** Armco / concrete safety barrier. */ + public static class SecurityBarrier extends Block { + public SecurityBarrier(Settings settings) { + super(settings); + } + } + + /** Animated start gantry block with its own lights. */ + public static class StartGate extends Block { + public StartGate(Settings settings) { + super(settings); + } + } + + /** Checkpoint light strip. Lights up while a race is live. */ + public static class CheckpointMarker extends Block { + public static final BooleanProperty LIT = Properties.LIT; + + public CheckpointMarker(Settings settings) { + super(settings); + setDefaultState(getDefaultState().with(LIT, false)); + } + + @Override + protected void appendProperties(StateManager.Builder builder) { + builder.add(LIT); + } + + @Override + public BlockState getPlacementState(ItemPlacementContext ctx) { + return getDefaultState().with(LIT, false); + } + + @Override + public void onBlockAdded(BlockState state, World world, BlockPos pos, BlockState oldState, boolean notify) { + super.onBlockAdded(state, world, pos, oldState, notify); + } + } + + /** Pit box marker: shows where a car can be serviced. */ + public static class Station extends Block { + public Station(Settings settings) { + super(settings); + } + } + + /** Boost pad: gives any car that drives over it a burst of speed. */ + public static class BoostPad extends Block { + public BoostPad(Settings settings) { + super(settings); + } + + @Override + public void onSteppedOn(World world, BlockPos pos, BlockState state, Entity entity) { + super.onSteppedOn(world, pos, state, entity); + } + + @Override + public void onEntityCollision(BlockState state, World world, BlockPos pos, Entity entity) { + if (!world.isClient && entity instanceof dev.qynl.racers.car.CarEntity car) { + car.hitBoostPad(pos); + } + } + } + + /** Decor only - slippery in looks and in physics. */ + public static class FakeIce extends Block { + public FakeIce(Settings settings) { + super(settings); + } + } + + /** Bouncy decor. */ + public static class FakeSlime extends Block { + public FakeSlime(Settings settings) { + super(settings); + } + + @Override + public void onLandedUpon(World world, BlockState state, BlockPos pos, Entity entity, float fallDistance) { + if (entity instanceof dev.qynl.racers.car.CarEntity car) { + car.hitBounceBlock(0.55); + return; + } + super.onLandedUpon(world, state, pos, entity, fallDistance); + } + } + + // -- settings helpers --------------------------------------------------- + public static Settings tarmac() { + return Block.Settings.create().strength(1.2F, 6.0F) + .sounds(BlockSoundGroup.STONE).mapColor(net.minecraft.block.MapColor.BLACK); + } + + public static Settings barrier() { + return Block.Settings.create().strength(2.5F, 12.0F).requiresTool() + .sounds(BlockSoundGroup.IRON); + } + + public static Settings decor() { + return Block.Settings.create().strength(1.0F, 6.0F).sounds(BlockSoundGroup.STONE); + } +} diff --git a/src/main/java/dev/qynl/racers/car/AiCarEntity.java b/src/main/java/dev/qynl/racers/car/AiCarEntity.java new file mode 100644 index 0000000..a46495a --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/AiCarEntity.java @@ -0,0 +1,26 @@ +package dev.qynl.racers.car; + +import net.minecraft.entity.EntityType; +import net.minecraft.world.World; + +/** + * A car that is always driven by the AI - the opponents in single player races + * and the "let the AI show you the line" demo cars. + */ +public class AiCarEntity extends CarEntity { + public AiCarEntity(EntityType type, World world) { + super(type, world); + setAiDriving(true); + } + + @Override + public boolean isPlayerDriven() { + return false; + } + + @Override + protected boolean canAddPassenger(net.minecraft.entity.Entity passenger) { + // spectators can still hop in for a taxi ride, but they cannot take over + return getPassengerList().isEmpty() && passenger instanceof net.minecraft.entity.player.PlayerEntity; + } +} diff --git a/src/main/java/dev/qynl/racers/car/CarControls.java b/src/main/java/dev/qynl/racers/car/CarControls.java new file mode 100644 index 0000000..245d053 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/CarControls.java @@ -0,0 +1,100 @@ +package dev.qynl.racers.car; + +/** One tick of driver input, shared by human and AI drivers. */ +public final class CarControls { + public static final byte BIT_FORWARD = 1; + public static final byte BIT_BACK = 2; + public static final byte BIT_LEFT = 4; + public static final byte BIT_RIGHT = 8; + public static final byte BIT_HANDBRAKE = 16; + public static final byte BIT_BOOST = 32; + + public boolean forward; + public boolean back; + public boolean left; + public boolean right; + public boolean handbrake; + public boolean boost; + /** 0..1 analog steering used by the AI (overrides left/right when non-zero). */ + public double analogSteer; + public double analogThrottle = -1.0; + + public static CarControls none() { + return new CarControls(); + } + + public static CarControls fromMask(byte mask) { + CarControls controls = new CarControls(); + controls.applyMask(mask); + return controls; + } + + public void applyMask(byte mask) { + forward = (mask & BIT_FORWARD) != 0; + back = (mask & BIT_BACK) != 0; + left = (mask & BIT_LEFT) != 0; + right = (mask & BIT_RIGHT) != 0; + handbrake = (mask & BIT_HANDBRAKE) != 0; + boost = (mask & BIT_BOOST) != 0; + } + + public byte mask() { + byte mask = 0; + if (forward) { + mask |= BIT_FORWARD; + } + if (back) { + mask |= BIT_BACK; + } + if (left) { + mask |= BIT_LEFT; + } + if (right) { + mask |= BIT_RIGHT; + } + if (handbrake) { + mask |= BIT_HANDBRAKE; + } + if (boost) { + mask |= BIT_BOOST; + } + return mask; + } + + /** Steering input in the -1 (left) .. 1 (right) range. */ + public double steer() { + if (analogSteer != 0.0) { + return Math.max(-1.0, Math.min(1.0, analogSteer)); + } + double value = 0.0; + if (left) { + value -= 1.0; + } + if (right) { + value += 1.0; + } + return value; + } + + public double throttle() { + if (analogThrottle >= 0.0) { + return Math.max(0.0, Math.min(1.0, analogThrottle)); + } + return forward ? 1.0 : 0.0; + } + + public double brake() { + return back ? 1.0 : 0.0; + } + + public boolean isEmpty() { + return !forward && !back && !left && !right && !handbrake && !boost + && analogSteer == 0.0 && analogThrottle < 0.0; + } + + @Override + public String toString() { + return "CarControls[f=" + forward + " b=" + back + " l=" + left + " r=" + right + + " hb=" + handbrake + " boost=" + boost + " steer=" + steer() + "]"; + } +} diff --git a/src/main/java/dev/qynl/racers/car/CarData.java b/src/main/java/dev/qynl/racers/car/CarData.java new file mode 100644 index 0000000..aee3814 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/CarData.java @@ -0,0 +1,164 @@ +package dev.qynl.racers.car; + +import java.util.UUID; + +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.CustomModelDataComponent; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import dev.qynl.racers.registry.ModComponents; + +/** + * Everything a car item carries: which chassis, its colours, its tuning levels, + * wear, owner and whether the AI is allowed to drive it. + * + *

Stored as a plain NBT compound data component so the format stays + * forward/backward compatible. + */ +public final class CarData { + public final CarType type; + public final int primaryColor; + public final int secondaryColor; + public final int accentColor; + public final int[] levels; + public final float engineHealth; + public final float tyreHealth; + public final UUID owner; + public final String ownerName; + public final boolean aiAllowed; + + public CarData(CarType type, int primaryColor, int secondaryColor, int accentColor, + int[] levels, float engineHealth, float tyreHealth, UUID owner, + String ownerName, boolean aiAllowed) { + this.type = type; + this.primaryColor = primaryColor; + this.secondaryColor = secondaryColor; + this.accentColor = accentColor; + this.levels = levels.clone(); + this.engineHealth = engineHealth; + this.tyreHealth = tyreHealth; + this.owner = owner; + this.ownerName = ownerName; + this.aiAllowed = aiAllowed; + } + + public static CarData fresh(CarType type, PlayerEntity owner) { + return new CarData(type, PaintColors.defaultPrimary(type), PaintColors.defaultSecondary(type), + PaintColors.WHITE, new int[Tune.values().length], 1.0F, 1.0F, + owner == null ? null : owner.getUuid(), + owner == null ? "" : owner.getName().getString(), true); + } + + public CarStats stats() { + return CarStats.of(type, levels); + } + + public int level(Tune tune) { + return levels[tune.ordinal()]; + } + + public CarData withLevel(Tune tune, int level) { + int[] copy = levels.clone(); + copy[tune.ordinal()] = Math.max(0, Math.min(tune.maxLevel, level)); + return new CarData(type, primaryColor, secondaryColor, accentColor, copy, + engineHealth, tyreHealth, owner, ownerName, aiAllowed); + } + + public CarData withColors(int primary, int secondary) { + return new CarData(type, primary, secondary, accentColor, levels, engineHealth, + tyreHealth, owner, ownerName, aiAllowed); + } + + public CarData withAccent(int accent) { + return new CarData(type, primaryColor, secondaryColor, accent, levels, engineHealth, + tyreHealth, owner, ownerName, aiAllowed); + } + + public CarData withHealth(float engine, float tyres) { + return new CarData(type, primaryColor, secondaryColor, accentColor, levels, engine, + tyres, owner, ownerName, aiAllowed); + } + + public CarData withAiAllowed(boolean allowed) { + return new CarData(type, primaryColor, secondaryColor, accentColor, levels, + engineHealth, tyreHealth, owner, ownerName, allowed); + } + + public NbtCompound toNbt() { + NbtCompound nbt = new NbtCompound(); + nbt.putString("type", type.id); + nbt.putInt("primary", primaryColor); + nbt.putInt("secondary", secondaryColor); + nbt.putInt("accent", accentColor); + nbt.putIntArray("levels", levels); + nbt.putFloat("engineHealth", engineHealth); + nbt.putFloat("tyreHealth", tyreHealth); + nbt.putString("owner", owner == null ? "" : owner.toString()); + nbt.putString("ownerName", ownerName == null ? "" : ownerName); + nbt.putBoolean("ai", aiAllowed); + return nbt; + } + + public static CarData fromNbt(NbtCompound nbt) { + CarType type = CarType.byId(nbt.getString("type")); + int[] levels = nbt.getIntArray("levels"); + if (levels.length != Tune.values().length) { + int[] fixed = new int[Tune.values().length]; + System.arraycopy(levels, 0, fixed, 0, Math.min(levels.length, fixed.length)); + levels = fixed; + } + UUID owner = null; + String raw = nbt.getString("owner"); + if (!raw.isEmpty()) { + try { + owner = UUID.fromString(raw); + } catch (IllegalArgumentException ignored) { + owner = null; + } + } + return new CarData(type, + nbt.contains("primary") ? nbt.getInt("primary") : PaintColors.defaultPrimary(type), + nbt.contains("secondary") ? nbt.getInt("secondary") : PaintColors.defaultSecondary(type), + nbt.contains("accent") ? nbt.getInt("accent") : PaintColors.WHITE, + levels, + nbt.contains("engineHealth") ? nbt.getFloat("engineHealth") : 1.0F, + nbt.contains("tyreHealth") ? nbt.getFloat("tyreHealth") : 1.0F, + owner, nbt.getString("ownerName"), !nbt.contains("ai") || nbt.getBoolean("ai")); + } + + /** True if the given player may drive/retune this car. */ + public boolean ownedBy(PlayerEntity player) { + return owner == null || player == null || owner.equals(player.getUuid()); + } + + public Text displayName() { + return Text.literal(type.displayName).formatted(type.color); + } + + public Text description() { + return Text.translatable("item.fabricracers." + type.itemId() + ".desc") + .formatted(Formatting.GRAY); + } + + // -- item stack helpers ------------------------------------------------- + public static CarData of(ItemStack stack) { + NbtCompound nbt = ModComponents.carData(stack); + return nbt.isEmpty() ? null : fromNbt(nbt); + } + + public static ItemStack createStack(CarType type, PlayerEntity owner) { + ItemStack stack = new ItemStack(dev.qynl.racers.registry.ModItems.carItem(type)); + apply(stack, fresh(type, owner)); + return stack; + } + + public static void apply(ItemStack stack, CarData data) { + ModComponents.setCarData(stack, data.toNbt()); + stack.set(DataComponentTypes.CUSTOM_MODEL_DATA, new CustomModelDataComponent( + java.util.List.of(), java.util.List.of(), java.util.List.of(), java.util.List.of())); + } +} diff --git a/src/main/java/dev/qynl/racers/car/CarEntity.java b/src/main/java/dev/qynl/racers/car/CarEntity.java new file mode 100644 index 0000000..5124b73 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/CarEntity.java @@ -0,0 +1,1081 @@ +package dev.qynl.racers.car; + +import java.util.List; +import java.util.UUID; + +import net.minecraft.entity.Entity; +import net.minecraft.entity.EntityType; +import net.minecraft.entity.EquipmentSlot; +import net.minecraft.entity.LivingEntity; +import net.minecraft.entity.MovementType; +import net.minecraft.entity.data.DataTracker; +import net.minecraft.entity.data.TrackedData; +import net.minecraft.entity.data.TrackedDataHandlerRegistry; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Formatting; +import net.minecraft.util.Hand; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; + +import dev.qynl.racers.race.RaceManager; +import dev.qynl.racers.util.Cars; + +/** + * The car: physics, damage, boost and driving, on both logical sides. + * + *

Server authoritative. The client never simulates the car, it only sends + * {@link CarControls} and renders the tracked state, which keeps the two sides + * from ever disagreeing about where a car is. + * + *

Physics model (one tick = 1/20 s): + *

    + *
  1. project world velocity into the body frame (forward / lateral)
  2. + *
  3. longitudinal forces: engine, brakes, drag, rolling resistance
  4. + *
  5. lateral tyre force, clamped by the surface grip circle
  6. + *
  7. steering with a bicycle model whose yaw rate is limited by grip
  8. + *
  9. integrate and move with sub-stepping so 46 m/s cannot tunnel
  10. + *
+ */ +public abstract class CarEntity extends Entity { + protected static final TrackedData DATA_TYPE = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.STRING); + protected static final TrackedData DATA_PRIMARY = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.INTEGER); + protected static final TrackedData DATA_SECONDARY = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.INTEGER); + protected static final TrackedData DATA_ACCENT = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.INTEGER); + protected static final TrackedData DATA_LEVELS = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.INTEGER); + protected static final TrackedData DATA_SPEED = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.FLOAT); + protected static final TrackedData DATA_SLIP = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.FLOAT); + protected static final TrackedData DATA_STEER = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.FLOAT); + protected static final TrackedData DATA_BOOST = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.FLOAT); + protected static final TrackedData DATA_HEALTH = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.FLOAT); + protected static final TrackedData DATA_BOOSTING = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.BOOLEAN); + protected static final TrackedData DATA_DRIFTING = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.BOOLEAN); + protected static final TrackedData DATA_DRIVER = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.STRING); + protected static final TrackedData DATA_LIGHT = + DataTracker.registerData(CarEntity.class, TrackedDataHandlerRegistry.INTEGER); + + // ---- physics state ---------------------------------------------------- + protected double velX; + protected double velY; + protected double velZ; + protected double steerAngle; + protected double vForward; + protected double vLateral; + protected double slip; + protected double boostCharge = 0.4; + protected int boostTicks; + protected float engineHealth = 1.0F; + protected float tyreHealth = 1.0F; + protected boolean controlLocked; + protected boolean aiDriving; + protected int lastCrashTick = -100; + protected int lastPadTick = -100; + protected int stuckTicks; + protected double odo; + protected double driftScore; + protected int engineSoundCooldown; + protected float bodyRoll; + protected float bodyPitch; + protected int airborneTicks; + /** 0 = free roam, 1 = on the grid / racing, 2 = finished (AI brings it home). */ + protected int raceState; + protected LivingEntity lastSeenDriver; + protected CarData cachedData; + protected UUID storedOwner; + protected String storedOwnerName = ""; + /** Driver input received from the network (player cars only). */ + protected CarControls driverInput = CarControls.none(); + + protected CarEntity(EntityType type, World world) { + super(type, world); + this.setVelocity(Vec3d.ZERO); + } + + // ------------------------------------------------------------------ + // data plumbing + // ------------------------------------------------------------------ + public CarData data() { + if (cachedData == null) { + cachedData = new CarData(CarType.byId(getDataTracker().get(DATA_TYPE)), + getDataTracker().get(DATA_PRIMARY), getDataTracker().get(DATA_SECONDARY), + getDataTracker().get(DATA_ACCENT), unpackLevels(getDataTracker().get(DATA_LEVELS)), + engineHealth, tyreHealth, storedOwner, storedOwnerName, aiDriving); + } + return cachedData; + } + + public CarType carType() { + return CarType.byId(getDataTracker().get(DATA_TYPE)); + } + + public CarStats stats() { + return data().stats(); + } + + public void initFromData(CarData data) { + this.cachedData = null; + this.storedOwner = data.owner; + this.storedOwnerName = data.ownerName == null ? "" : data.ownerName; + this.engineHealth = MathHelper.clamp(data.engineHealth, 0.0F, 1.0F); + this.tyreHealth = MathHelper.clamp(data.tyreHealth, 0.05F, 1.0F); + getDataTracker().set(DATA_TYPE, data.type.id); + getDataTracker().set(DATA_PRIMARY, data.primaryColor); + getDataTracker().set(DATA_SECONDARY, data.secondaryColor); + getDataTracker().set(DATA_ACCENT, data.accentColor); + getDataTracker().set(DATA_LEVELS, packLevels(data.levels)); + getDataTracker().set(DATA_HEALTH, healthFraction()); + this.boostCharge = Math.min(1.0, 0.35 + 0.15 * data.level(Tune.BOOST)); + getDataTracker().set(DATA_BOOST, (float) boostCharge); + updateDisplayName(); + } + + public void applyData(CarData data) { + initFromData(data); + } + + public static int packLevels(int[] levels) { + int packed = 0; + for (int i = 0; i < Math.min(levels.length, Tune.values().length); i++) { + packed |= MathHelper.clamp(levels[i], 0, 15) << (i * 4); + } + return packed; + } + + public static int[] unpackLevels(int packed) { + int[] levels = new int[Tune.values().length]; + for (int i = 0; i < levels.length; i++) { + levels[i] = (packed >> (i * 4)) & 0xF; + } + return levels; + } + + public int[] levels() { + return unpackLevels(getDataTracker().get(DATA_LEVELS)); + } + + public UUID owner() { + return storedOwner; + } + + public String ownerName() { + return storedOwnerName; + } + + @Override + protected void initDataTracker(DataTracker.Builder builder) { + builder.add(DATA_TYPE, CarType.SPORT.id); + builder.add(DATA_PRIMARY, PaintColors.RED); + builder.add(DATA_SECONDARY, PaintColors.BLACK); + builder.add(DATA_ACCENT, PaintColors.WHITE); + builder.add(DATA_LEVELS, 0); + builder.add(DATA_SPEED, 0.0F); + builder.add(DATA_SLIP, 0.0F); + builder.add(DATA_STEER, 0.0F); + builder.add(DATA_BOOST, 0.4F); + builder.add(DATA_HEALTH, 1.0F); + builder.add(DATA_BOOSTING, false); + builder.add(DATA_DRIFTING, false); + builder.add(DATA_DRIVER, ""); + builder.add(DATA_LIGHT, 0); + } + + @Override + protected void readCustomDataFromNbt(NbtCompound nbt) { + if (nbt.contains("CarData")) { + initFromData(CarData.fromNbt(nbt.getCompound("CarData"))); + } + this.velX = nbt.getDouble("VelX"); + this.velY = nbt.getDouble("VelY"); + this.velZ = nbt.getDouble("VelZ"); + this.steerAngle = nbt.getDouble("Steer"); + this.driftScore = nbt.getDouble("DriftScore"); + this.odo = nbt.getDouble("Odo"); + this.aiDriving = nbt.getBoolean("AiDriving"); + String owner = nbt.getString("Owner"); + if (!owner.isEmpty()) { + try { + storedOwner = UUID.fromString(owner); + } catch (IllegalArgumentException ignored) { + storedOwner = null; + } + } + } + + @Override + protected void writeCustomDataToNbt(NbtCompound nbt) { + nbt.put("CarData", data().toNbt()); + nbt.putDouble("VelX", velX); + nbt.putDouble("VelY", velY); + nbt.putDouble("VelZ", velZ); + nbt.putDouble("Steer", steerAngle); + nbt.putDouble("DriftScore", driftScore); + nbt.putDouble("Odo", odo); + nbt.putBoolean("AiDriving", aiDriving); + nbt.putString("Owner", storedOwner == null ? "" : storedOwner.toString()); + } + + @Override + public Text getName() { + return carType().label(); + } + + // ------------------------------------------------------------------ + // main tick + // ------------------------------------------------------------------ + @Override + public void tick() { + super.tick(); + syncDriverState(); + if (getWorld().isClient()) { + clientTick(); + return; + } + if (aiDriving) { + driverInput = aiTick(); + } else if (getDriver() == null) { + driverInput = CarControls.none(); + } + CarControls controls = driverInput; + if (controlLocked) { + controls = CarControls.none(); + } + physicsTick(controls); + collideWithCars(); + serverEffects(); + if (getWorld().getTime() % 200 == 0) { + getDataTracker().set(DATA_LIGHT, 0); + } + } + + /** Keeps mount/dismount bookkeeping in one place (no fragile hooks needed). */ + protected void syncDriverState() { + LivingEntity driver = getDriver(); + if (driver != lastSeenDriver) { + if (driver != null) { + onDriverMount(driver); + } else if (lastSeenDriver != null) { + onDriverDismount(); + } + lastSeenDriver = driver; + } + } + + protected CarControls aiTick() { + if (aiDriver == null) { + aiDriver = new dev.qynl.racers.ai.AiDriver(); + } + return aiDriver.tick(this); + } + + protected dev.qynl.racers.ai.AiDriver aiDriver; + protected double aiSkill = 0.72; + protected boolean aiRubberBand = true; + + public dev.qynl.racers.ai.AiDriver aiDriver() { + return aiDriver; + } + + public double aiSkill() { + return aiSkill; + } + + public void setAiSkill(double skill) { + this.aiSkill = MathHelper.clamp(skill, 0.2, 1.0); + } + + public boolean aiRubberBand() { + return aiRubberBand; + } + + public void setAiRubberBand(boolean value) { + this.aiRubberBand = value; + } + + // ------------------------------------------------------------------ + // physics + // ------------------------------------------------------------------ + protected void physicsTick(CarControls controls) { + CarStats stats = stats(); + final double dt = 0.05; + SurfaceSample surface = sampleSurface(); + + Vec3d forward = Vec3d.fromPolar(0.0F, getYaw()); + Vec3d right = new Vec3d(-forward.z, 0.0, forward.x); + double fx = forward.x; + double fz = forward.z; + double rx = right.x; + double rz = right.z; + + double vf = velX * fx + velZ * fz; + double vl = velX * rx + velZ * rz; + + boolean grounded = isOnGround() || airborneTicks == 0; + airborneTicks = grounded ? 0 : airborneTicks + 1; + + boolean boosting = boostTicks > 0 && engineHealth > 0.12F; + double engineMul = surface.power().mult * engineHealth * (0.55 + 0.45 * tyreHealth); + if (!grounded) { + engineMul *= 0.2; + } + double maxSpeed = stats.topSpeed() * surface.speed().mult * (0.55 + 0.45 * engineHealth) + * (boosting ? 1.22 : 1.0); + + // --- longitudinal -------------------------------------------------- + double accel = 0.0; + double throttle = controls.throttle(); + double brake = controls.brake(); + if (engineHealth > 0.12F && throttle > 0.0) { + double norm = MathHelper.clamp(vf / Math.max(5.0, maxSpeed), 0.0, 1.2); + accel += stats.power() * throttle * engineMul * (1.0 - norm * norm * 0.9); + if (vf < -0.4) { + accel += stats.brakeForce() * 0.9; + } + } + if (brake > 0.0) { + if (vf > 0.5) { + accel -= stats.brakeForce() * brake * (0.4 + 0.6 * tyreHealth) * surface.grip().mult; + } else { + accel -= stats.power() * 0.55 * brake * engineMul; + } + } + if (controls.handbrake) { + accel -= Math.signum(vf) * stats.brakeForce() * 0.5; + } + accel -= stats.drag() * vf * Math.abs(vf) * 40.0; + accel -= 0.33 * vf * surface.roll().mult; + if (!grounded) { + accel *= 0.15; + } + vf += accel * dt; + if (Math.abs(vf) < 0.05 && throttle == 0.0 && brake == 0.0) { + vf = 0.0; + } + vf = MathHelper.clamp(vf, -7.0, maxSpeed); + + // --- steering ------------------------------------------------------ + double steerInput = controls.steer(); + double speedRatio = Math.min(1.0, Math.abs(vf) / Math.max(9.0, stats.topSpeed())); + double maxSteer = stats.maxSteerDeg() * (1.0 - 0.52 * speedRatio); + double steerTarget = steerInput * maxSteer; + double steerResponse = controls.handbrake ? 0.40 : 0.28; + if (surface.slippery()) { + steerResponse *= 0.7; + } + steerAngle += (steerTarget - steerAngle) * steerResponse; + + double steerRad = Math.toRadians(steerAngle) * (vf < 0.0 ? -1.0 : 1.0); + double wheelbase = Math.max(1.0, stats.wheelbase()); + double desiredYawRate = vf / wheelbase * Math.tan(steerRad); + double grip = stats.grip() * surface.grip().mult * (0.45 + 0.55 * tyreHealth) + * (controls.handbrake ? 0.32 : 1.0); + if (boosting) { + grip *= 1.05; + } + double maxYawRate = grip / Math.max(1.8, Math.abs(vf)); + double yawRate = MathHelper.clamp(desiredYawRate, -maxYawRate, maxYawRate); + if (controls.handbrake && Math.abs(vf) > 2.0) { + double lean = steerInput == 0.0 ? 1.0 : Math.signum(steerInput); + yawRate += lean * Math.min(1.3, 0.075 * Math.abs(vf)) * stats.driftBias(); + } + if (!grounded) { + yawRate += steerInput * 0.5; + } + setYaw(getYaw() + (float) Math.toDegrees(yawRate * dt)); + + // --- lateral tyre force -------------------------------------------- + double latStiffness = stats.latStiffness() * (grounded ? 1.0 : 0.2); + double latAccel = MathHelper.clamp(-latStiffness * vl, -grip, grip); + + // --- integrate ------------------------------------------------------ + velX += (fx * accel + rx * latAccel) * dt; + velZ += (fz * accel + rz * latAccel) * dt; + velY -= 0.5; + if (grounded && velY < 0.0) { + velY = 0.0; + } + if (surface.bouncy() && grounded && velY <= -0.05) { + velY = -velY * 0.65 + 0.6; + } + + if (controls.boost) { + activateBoost(); + } + if (boosting) { + boostTicks--; + if (boostTicks <= 0) { + getDataTracker().set(DATA_BOOSTING, false); + } + } + + moveWithCollisions(new Vec3d(velX * dt, MathHelper.clamp(velY, -1.8, 1.8) * dt, velZ * dt)); + + // --- bookkeeping ---------------------------------------------------- + Vec3d forward2 = Vec3d.fromPolar(0.0F, getYaw()); + Vec3d right2 = new Vec3d(-forward2.z, 0.0, forward2.x); + this.vForward = velX * forward2.x + velZ * forward2.z; + this.vLateral = velX * right2.x + velZ * right2.z; + double speed = Math.hypot(velX, velZ); + this.slip = speed < 0.4 ? 0.0 : MathHelper.clamp(Math.abs(vLateral) / speed, 0.0, 1.0); + this.odo += speed * dt; + + boolean drifting = slip > 0.30 && speed > 5.5; + if (drifting) { + driftScore += slip * speed * dt * 14.0; + boostCharge = Math.min(1.0, boostCharge + slip * speed * dt * 0.05); + } else if (boostTicks <= 0) { + boostCharge = Math.min(1.0, boostCharge + dt * 0.014); + } + tyreHealth = MathHelper.clamp((float) (tyreHealth + - Math.abs(vLateral) * dt * 0.0012 * (1.0 - 0.06 * data().level(Tune.TIRES)) + - (drifting ? 0.00035 : 0.0)), 0.05F, 1.0F); + + getDataTracker().set(DATA_SPEED, (float) vForward); + getDataTracker().set(DATA_SLIP, (float) slip); + getDataTracker().set(DATA_STEER, (float) (steerAngle / 32.0)); + getDataTracker().set(DATA_BOOST, (float) boostCharge); + getDataTracker().set(DATA_DRIFTING, drifting); + getDataTracker().set(DATA_HEALTH, healthFraction()); + getDataTracker().set(DATA_LIGHT, drifting || boostTicks > 0 ? 1 : 0); + + double targetRoll = MathHelper.clamp(-vLateral * 0.028, -0.2, 0.2); + double targetPitch = MathHelper.clamp((grounded ? 0.0 : -velY * 0.06) + accel * -0.007, -0.25, 0.25); + this.bodyRoll += (float) ((targetRoll - bodyRoll) * 0.2); + this.bodyPitch += (float) ((targetPitch - bodyPitch) * 0.2); + + if (speed < 0.45) { + stuckTicks++; + } else { + stuckTicks = 0; + } + } + + protected void moveWithCollisions(Vec3d delta) { + int steps = MathHelper.clamp((int) Math.ceil(delta.length() / 0.4), 1, 24); + Vec3d step = delta.multiply(1.0 / steps); + for (int i = 0; i < steps; i++) { + this.horizontalCollision = false; + this.move(MovementType.SELF, step); + if (this.horizontalCollision) { + double speed = Math.hypot(velX, velZ); + if (speed < 5.0 && tryStepUp()) { + continue; + } + handleCrash(speed); + break; + } + } + } + + /** Cars climb a 1 block ledge at low speed - kerbs, bumps, garage floors. */ + protected boolean tryStepUp() { + Vec3d before = getPos(); + Vec3d step = Vec3d.fromPolar(0.0F, getYaw()).multiply(0.7); + this.horizontalCollision = false; + this.move(MovementType.SELF, new Vec3d(step.x, 1.05, step.z)); + if (!this.horizontalCollision && this.getY() > before.y + 0.35) { + return true; + } + this.refreshPositionAndAngles(before.x, before.y, before.z, getYaw(), 0.0F); + return false; + } + + protected void handleCrash(double speed) { + double impact = Math.max(speed, Math.hypot(velX, velZ)); + double nx = -velX * 0.2; + double nz = -velZ * 0.2; + boolean hard = impact > 6.0 && (this.age - lastCrashTick) > 5; + velX = nx; + velZ = nz; + if (hard) { + lastCrashTick = this.age; + double damage = (impact - 6.0) * 0.02 * (1.0 - 0.06 * data().level(Tune.CHASSIS)); + damageCar((float) damage); + if (getWorld() instanceof ServerWorld world) { + world.playSound(null, getX(), getY(), getZ(), SoundEvents.ENTITY_ITEM_BREAK, + SoundCategory.NEUTRAL, 0.75F, 0.75F + random.nextFloat() * 0.2F); + world.spawnParticles(ParticleTypes.CRIT, getX(), getY() + 0.6, getZ(), + 8, 0.35, 0.3, 0.35, 0.15); + } + } + } + + public void hitBoostPad(BlockPos pos) { + if (getWorld().isClient() || this.age - lastPadTick <= 3) { + return; + } + lastPadTick = this.age; + boostTicks = Math.max(boostTicks, 30); + boostCharge = Math.min(1.0, boostCharge + 0.3); + getDataTracker().set(DATA_BOOSTING, true); + if (getWorld() instanceof ServerWorld world) { + world.playSound(null, getX(), getY(), getZ(), SoundEvents.ENTITY_FIREWORK_ROCKET_LAUNCH, + SoundCategory.NEUTRAL, 0.45F, 1.5F); + world.spawnParticles(ParticleTypes.END_ROD, getX(), getY() + 0.3, getZ(), + 5, 0.25, 0.1, 0.25, 0.05); + } + } + + public void hitBounceBlock(double power) { + this.velY = Math.max(velY, 3.4 * power); + this.airborneTicks = 1; + } + + public void activateBoost() { + if (getWorld().isClient() || boostTicks > 0 || boostCharge < 0.28 || engineHealth < 0.15F) { + return; + } + boostTicks = 34 + 12 * data().level(Tune.BOOST); + boostCharge = Math.max(0.0, boostCharge - 0.32); + getDataTracker().set(DATA_BOOSTING, true); + if (getWorld() instanceof ServerWorld world) { + world.playSound(null, getX(), getY(), getZ(), SoundEvents.ITEM_FIRECHARGE_USE, + SoundCategory.NEUTRAL, 0.6F, 1.15F); + } + } + + protected void serverEffects() { + if (!(getWorld() instanceof ServerWorld world)) { + return; + } + double speed = Math.hypot(velX, velZ); + if (slip > 0.32 && speed > 4.0 && this.age % 2 == 0) { + world.spawnParticles(ParticleTypes.CAMPFIRE_COSY_SMOKE, getX() - velX * 0.06, + getY() + 0.15, getZ() - velZ * 0.06, 1, 0.22, 0.05, 0.22, 0.012); + if (slip > 0.62) { + world.spawnParticles(ParticleTypes.CLOUD, getX() - velX * 0.09, getY() + 0.1, + getZ() - velZ * 0.09, 2, 0.2, 0.02, 0.2, 0.0); + } + } + if (boostTicks > 0) { + world.spawnParticles(ParticleTypes.FLAME, getX() - velX * 0.11, getY() + 0.42, + getZ() - velZ * 0.11, 2, 0.09, 0.05, 0.09, 0.02); + } + if (engineHealth <= 0.05F && this.age % 25 == 0) { + world.spawnParticles(ParticleTypes.LARGE_SMOKE, getX(), getY() + 0.7, getZ(), + 2, 0.3, 0.2, 0.3, 0.005); + } + if (engineSoundCooldown-- <= 0) { + engineSoundCooldown = 7; + double absV = Math.abs(vForward); + if (absV > 0.6 || boostTicks > 0 || engineHealth < 0.4F) { + double pitch = 0.6 + 0.8 * Math.min(1.0, absV / Math.max(7.0, stats().topSpeed())); + if (engineHealth < 0.4F) { + pitch *= 0.65; + } + world.playSound(null, getX(), getY(), getZ(), + engineHealth < 0.25F ? SoundEvents.BLOCK_FIRE_EXTINGUISH + : (boostTicks > 0 ? SoundEvents.ENTITY_MINECART_RIDING + : SoundEvents.ENTITY_MINECART_INSIDE), + SoundCategory.NEUTRAL, + (float) Math.min(0.6, 0.1 + absV * 0.013), (float) pitch); + } + } + } + + protected void clientTick() { + Vec3d forward = Vec3d.fromPolar(0.0F, getYaw()); + this.vForward = velX * forward.x + velZ * forward.z; + if (Math.abs(vForward) < 0.01) { + this.vForward = getDataTracker().get(DATA_SPEED); + } + this.vLateral = getDataTracker().get(DATA_SLIP); + } + + // ------------------------------------------------------------------ + // status accessors used by renderer + HUD + // ------------------------------------------------------------------ + public int raceState() { + return raceState; + } + + public void setRaceState(int state) { + this.raceState = state; + } + + /** Ticks the car has been wedged against something; read by the AI driver. */ + public int stuckTicks() { + return stuckTicks; + } + + public double getSpeed() { + return getDataTracker().get(DATA_SPEED); + } + + public double getSlip() { + return getDataTracker().get(DATA_SLIP); + } + + public double getSteerFactor() { + return getDataTracker().get(DATA_STEER); + } + + public double getBoostCharge() { + return getDataTracker().get(DATA_BOOST); + } + + public boolean isBoosting() { + return getDataTracker().get(DATA_BOOSTING); + } + + public boolean isDrifting() { + return getDataTracker().get(DATA_DRIFTING); + } + + public float getBodyRoll() { + return bodyRoll; + } + + public float getBodyPitch() { + return bodyPitch; + } + + public double getDriftScore() { + return driftScore; + } + + public double getOdometer() { + return odo; + } + + public float healthFraction() { + return MathHelper.clamp(engineHealth * 0.65F + tyreHealth * 0.35F, 0.0F, 1.0F); + } + + public float engineHealth() { + return engineHealth; + } + + public float tyreHealth() { + return tyreHealth; + } + + public boolean isBroken() { + return engineHealth < 0.12F; + } + + public void damageCar(float amount) { + if (amount <= 0.0F) { + return; + } + this.engineHealth = MathHelper.clamp(engineHealth - amount * 0.72F, 0.0F, 1.0F); + this.tyreHealth = MathHelper.clamp(tyreHealth - amount * 0.28F, 0.05F, 1.0F); + getDataTracker().set(DATA_HEALTH, healthFraction()); + } + + public void resetHealth() { + this.engineHealth = 1.0F; + this.tyreHealth = 1.0F; + getDataTracker().set(DATA_HEALTH, 1.0F); + } + + public ActionResult repair(PlayerEntity player, ItemStack wrench) { + if (!data().ownedBy(player)) { + player.sendMessage(Text.translatable("message.fabricracers.not_your_car", + Text.literal(storedOwnerName)).formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + boolean damaged = engineHealth < 0.999F || tyreHealth < 0.999F; + if (!damaged) { + player.sendMessage(Text.translatable("message.fabricracers.already_fine") + .formatted(Formatting.GRAY), true); + return ActionResult.CONSUME; + } + float before = engineHealth; + this.engineHealth = MathHelper.clamp(engineHealth + 0.45F, 0.0F, 1.0F); + this.tyreHealth = MathHelper.clamp(tyreHealth + 0.5F, 0.05F, 1.0F); + getDataTracker().set(DATA_HEALTH, healthFraction()); + this.engineHealth = Math.max(before, engineHealth); + if (getWorld() instanceof ServerWorld world) { + world.playSound(null, getX(), getY(), getZ(), SoundEvents.BLOCK_ANVIL_USE, + SoundCategory.NEUTRAL, 0.6F, 1.3F); + world.spawnParticles(ParticleTypes.HAPPY_VILLAGER, getX(), getY() + 0.5, getZ(), + 8, 0.4, 0.3, 0.4, 0.02); + } + if (!player.isCreative() && wrench != null && wrench.isDamageable()) { + wrench.damage(1, player, EquipmentSlot.MAINHAND); + } + player.sendMessage(Text.translatable("message.fabricracers.repaired") + .formatted(Formatting.GREEN), true); + return ActionResult.CONSUME; + } + + public void setPaint(int primary, int secondary) { + this.cachedData = null; + getDataTracker().set(DATA_PRIMARY, primary); + getDataTracker().set(DATA_SECONDARY, secondary); + } + + /** Used by the carrot key to claim an ownerless car. */ + public void setOwner(UUID owner, String name) { + this.storedOwner = owner; + this.storedOwnerName = name == null ? "" : name; + this.cachedData = null; + updateDisplayName(); + } + + public void setLevels(int[] levels) { + this.cachedData = null; + getDataTracker().set(DATA_LEVELS, packLevels(levels)); + } + + // ------------------------------------------------------------------ + // driver plumbing + // ------------------------------------------------------------------ + public abstract boolean isPlayerDriven(); + + public LivingEntity getDriver() { + for (Entity passenger : getPassengerList()) { + if (passenger instanceof LivingEntity living) { + return living; + } + } + return null; + } + + public boolean isRidden() { + return !getPassengerList().isEmpty(); + } + + @Override + public LivingEntity getControllingPassenger() { + return getDriver(); + } + + @Override + protected boolean canAddPassenger(Entity passenger) { + return getPassengerList().isEmpty() && passenger instanceof PlayerEntity; + } + + @Override + protected Vec3d getPassengerAttachmentPos(Entity passenger, + net.minecraft.entity.EntityDimensions dimensions, + float tickDelta) { + return new Vec3d(0.0, 0.62, 0.0); + } + + public void setAiDriving(boolean value) { + this.aiDriving = value; + if (value) { + this.aiDriver = new dev.qynl.racers.ai.AiDriver(); + } else { + this.aiDriver = null; + } + this.cachedData = null; + updateDisplayName(); + } + + public boolean isAiDriving() { + return aiDriving; + } + + public void setControlLocked(boolean locked) { + this.controlLocked = locked; + } + + public boolean isControlLocked() { + return controlLocked; + } + + public void setDriverInput(CarControls controls) { + this.driverInput = controls; + } + + public CarControls getDriverInput() { + return driverInput; + } + + public void onDriverMount(LivingEntity driver) { + if (driver instanceof PlayerEntity player) { + storedOwnerName = player.getName().getString(); + getDataTracker().set(DATA_DRIVER, storedOwnerName); + if (storedOwner == null) { + storedOwner = player.getUuid(); + } + } + aiDriving = false; + aiDriver = null; + updateDisplayName(); + if (getWorld() instanceof ServerWorld world) { + world.playSound(null, getX(), getY(), getZ(), SoundEvents.BLOCK_IRON_DOOR_CLOSE, + SoundCategory.NEUTRAL, 0.5F, 1.6F); + } + } + + public void onDriverDismount() { + getDataTracker().set(DATA_DRIVER, ""); + updateDisplayName(); + driverInput = CarControls.none(); + if (getWorld() instanceof ServerWorld world) { + world.playSound(null, getX(), getY(), getZ(), SoundEvents.BLOCK_IRON_DOOR_OPEN, + SoundCategory.NEUTRAL, 0.5F, 1.4F); + } + } + + public String driverName() { + return getDataTracker().get(DATA_DRIVER); + } + + protected void updateDisplayName() { + String driver = getDataTracker().get(DATA_DRIVER); + Text label = Text.literal("").append(carType().label()) + .append(driver.isEmpty() + ? (aiDriving ? Text.literal(" [AI]").formatted(Formatting.AQUA) + : Text.literal(" [parked]").formatted(Formatting.DARK_GRAY)) + : Text.literal(" " + driver).formatted(Formatting.WHITE)); + if (!storedOwnerName.isEmpty()) { + label = label.copy().append(Text.literal(" (" + storedOwnerName + ")") + .formatted(Formatting.DARK_GRAY)); + } + setCustomName(label); + setCustomNameVisible(!driver.isEmpty() || aiDriving); + } + + /** Teleports with all physical state reset - used by the race engine. */ + public void teleportCleanly(double x, double y, double z, float yaw) { + this.velX = 0.0; + this.velY = 0.0; + this.velZ = 0.0; + this.steerAngle = 0.0; + this.bodyRoll = 0.0F; + this.bodyPitch = 0.0F; + this.boostTicks = 0; + this.airborneTicks = 0; + this.driverInput = CarControls.none(); + this.slip = 0.0; + getDataTracker().set(DATA_SPEED, 0.0F); + getDataTracker().set(DATA_BOOSTING, false); + if (getWorld() instanceof ServerWorld serverWorld) { + this.teleport(serverWorld, x, y, z, java.util.Set.of(), yaw, 0.0F); + } else { + this.refreshPositionAndAngles(x, y, z, yaw, 0.0F); + } + } + + // ------------------------------------------------------------------ + // interactions + // ------------------------------------------------------------------ + @Override + public ActionResult interact(PlayerEntity player, Hand hand) { + if (getWorld().isClient() || hand != Hand.MAIN_HAND) { + return ActionResult.SUCCESS; + } + if (isRidden()) { + return ActionResult.PASS; + } + ItemStack stack = player.getStackInHand(hand); + if (stack.getItem() instanceof dev.qynl.racers.item.ToolItems.Wrench + || stack.getItem() instanceof dev.qynl.racers.item.ToolItems.PaintCan) { + return ActionResult.PASS; + } + if (!data().ownedBy(player) && !player.isCreative()) { + player.sendMessage(Text.translatable("message.fabricracers.not_your_car", + Text.literal(storedOwnerName)).formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + player.startRiding(this); + return ActionResult.CONSUME; + } + + @Override + public boolean isPushable() { + return false; + } + + @Override + public boolean canHit() { + return true; + } + + @Override + public boolean isCollidable() { + return true; + } + + @Override + public boolean damage(net.minecraft.entity.damage.DamageSource source, float amount) { + if (!getWorld().isClient() && amount > 0.5F) { + damageCar(Math.min(0.3F, amount * 0.04F)); + if (source.getAttacker() instanceof LivingEntity attacker) { + double dx = getX() - attacker.getX(); + double dz = getZ() - attacker.getZ(); + double len = Math.max(0.2, Math.hypot(dx, dz)); + velX += dx / len * 0.4; + velZ += dz / len * 0.4; + } + } + return false; + } + + @Override + public boolean canBeHitByProjectile() { + return true; + } + + /** Keeps cars from stacking inside each other, with momentum exchange. */ + protected void collideWithCars() { + List others = getWorld().getEntitiesByClass(CarEntity.class, + getBoundingBox().expand(1.4), other -> other != this && !other.isRemoved()); + for (CarEntity other : others) { + Box a = getBoundingBox(); + Box b = other.getBoundingBox(); + if (!a.intersects(b)) { + continue; + } + double dx = other.getX() - getX(); + double dz = other.getZ() - getZ(); + double dist = Math.max(0.2, Math.hypot(dx, dz)); + double overlap = 1.75 - dist; + if (overlap <= 0) { + continue; + } + double nx = dx / dist; + double nz = dz / dist; + double m1 = Math.max(0.3, stats().mass()); + double m2 = Math.max(0.3, other.stats().mass()); + double share1 = m2 / (m1 + m2); + double push = overlap * 0.5; + this.velX -= nx * push * share1; + this.velZ -= nz * push * share1; + other.velX += nx * push * (1.0 - share1); + other.velZ += nz * push * (1.0 - share1); + + double relative = (velX - other.velX) * nx + (velZ - other.velZ) * nz; + if (relative > 1.5) { + double impulse = relative * 0.3; + this.velX -= nx * impulse * share1 * 2.0; + this.velZ -= nz * impulse * share1 * 2.0; + other.velX += nx * impulse * (1.0 - share1) * 2.0; + other.velZ += nz * impulse * (1.0 - share1) * 2.0; + if (relative > 7.0) { + this.damageCar((float) (relative * 0.01)); + other.damageCar((float) (relative * 0.01)); + if (getWorld() instanceof ServerWorld world) { + world.playSound(null, getX(), getY(), getZ(), SoundEvents.ENTITY_ITEM_BREAK, + SoundCategory.NEUTRAL, 0.5F, 1.0F); + world.spawnParticles(ParticleTypes.CRIT, (getX() + other.getX()) / 2.0, + getY() + 0.6, (getZ() + other.getZ()) / 2.0, 6, 0.2, 0.2, 0.2, 0.1); + } + } + } + } + } + + // ------------------------------------------------------------------ + // surface sampling + // ------------------------------------------------------------------ + public record Multiplier(double mult) { + } + + public record SurfaceSample(Multiplier power, Multiplier speed, Multiplier grip, + Multiplier roll, boolean bouncy, boolean slippery) { + } + + protected SurfaceSample sampleSurface() { + SurfaceType front = probeSurface(offsetAhead(0.55F)); + SurfaceType rear = probeSurface(offsetAhead(-0.55F)); + SurfaceType center = probeSurface(getBlockPos()); + double power = (front.powerMul + rear.powerMul * 1.3 + center.powerMul) / 3.3; + double speed = (front.speedMul + rear.speedMul * 1.3 + center.speedMul) / 3.3; + double grip = (front.gripMul * 1.2 + rear.gripMul * 1.4 + center.gripMul) / 3.6; + double roll = (front.rollMul + rear.rollMul + center.rollMul) / 3.0; + boolean bouncy = front.bouncy || rear.bouncy; + boolean slippery = front == SurfaceType.ICE || rear == SurfaceType.ICE + || center == SurfaceType.ICE; + return new SurfaceSample(new Multiplier(power), new Multiplier(speed), + new Multiplier(grip), new Multiplier(roll), bouncy, slippery); + } + + private BlockPos offsetAhead(float blocks) { + Vec3d forward = Vec3d.fromPolar(0.0F, getYaw()).multiply(blocks); + return BlockPos.ofFloored(getX() + forward.x, getY() - 0.35, getZ() + forward.z); + } + + private SurfaceType probeSurface(BlockPos pos) { + var state = getWorld().getBlockState(pos); + if (!state.isAir()) { + return SurfaceType.of(state); + } + var below = getWorld().getBlockState(pos.down()); + if (!below.isAir()) { + return SurfaceType.of(below); + } + return SurfaceType.AIR; + } + + // ------------------------------------------------------------------ + // velocity bridge (Entity keeps its own copy, we keep ours) + // ------------------------------------------------------------------ + @Override + public Vec3d getVelocity() { + return new Vec3d(velX, velY, velZ); + } + + @Override + public void setVelocity(Vec3d velocity) { + this.velX = velocity.x; + this.velY = velocity.y; + this.velZ = velocity.z; + super.setVelocity(velocity); + this.velocityDirty = true; + } + + @Override + public void addVelocity(double deltaX, double deltaY, double deltaZ) { + this.velX += deltaX; + this.velY += deltaY; + this.velZ += deltaZ; + super.setVelocity(new Vec3d(velX, velY, velZ)); + this.velocityDirty = true; + } + + @Override + public void setYaw(float yaw) { + super.setYaw(yaw); + } + + // ------------------------------------------------------------------ + // race integration helpers + // ------------------------------------------------------------------ + public RaceManager raceManager() { + if (getWorld() instanceof ServerWorld serverWorld) { + return RaceManager.get(serverWorld.getServer()); + } + return null; + } + + public BlockPos spawnBlock() { + return new BlockPos((int) Math.floor(getX()), (int) Math.floor(getY()), (int) Math.floor(getZ())); + } + + public double distanceTo(double x, double z) { + return Math.hypot(getX() - x, getZ() - z); + } + + /** Distance travelled since the car was created, in blocks. */ + public double odometer() { + return odo; + } +} diff --git a/src/main/java/dev/qynl/racers/car/CarSpec.java b/src/main/java/dev/qynl/racers/car/CarSpec.java new file mode 100644 index 0000000..9e60741 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/CarSpec.java @@ -0,0 +1,46 @@ +package dev.qynl.racers.car; + +/** + * Purely visual/physical description of a car body. One renderer/model pair is + * reused for every car and reshaped with these numbers, which keeps the client + * model count small while still giving each vehicle a distinct silhouette. + */ +public record CarSpec(float length, float width, float height, float wheelRadius, + float wheelBase, float trackWidth, float bodyLift, + BodyStyle style, boolean openWheels, boolean cockpitWindow) { + + public enum BodyStyle { + KART, + HATCHBACK, + MUSCLE, + EXOTIC, + OPEN_WHEEL, + TRUCK, + ROD + } + + public static CarSpec of(String type) { + return switch (type) { + case "kart" -> new CarSpec(2.3F, 1.5F, 0.95F, 0.24F, 1.35F, 1.32F, 0.22F, + BodyStyle.KART, false, false); + case "rally" -> new CarSpec(3.3F, 1.85F, 1.35F, 0.33F, 2.25F, 1.66F, 0.30F, + BodyStyle.HATCHBACK, false, true); + case "sport" -> new CarSpec(3.7F, 1.85F, 1.10F, 0.34F, 2.45F, 1.70F, 0.24F, + BodyStyle.MUSCLE, false, true); + case "hyper" -> new CarSpec(3.9F, 1.95F, 1.00F, 0.35F, 2.60F, 1.80F, 0.20F, + BodyStyle.EXOTIC, false, true); + case "f1" -> new CarSpec(4.2F, 1.95F, 0.95F, 0.34F, 3.10F, 1.78F, 0.16F, + BodyStyle.OPEN_WHEEL, true, false); + case "truck" -> new CarSpec(4.4F, 2.15F, 1.85F, 0.40F, 3.20F, 1.98F, 0.36F, + BodyStyle.TRUCK, false, true); + case "hotrod" -> new CarSpec(3.9F, 1.90F, 1.30F, 0.38F, 2.85F, 1.76F, 0.30F, + BodyStyle.ROD, false, true); + case "drift" -> new CarSpec(3.7F, 1.90F, 1.15F, 0.34F, 2.45F, 1.74F, 0.24F, + BodyStyle.MUSCLE, false, true); + case "shadow" -> new CarSpec(4.1F, 2.00F, 1.05F, 0.36F, 2.95F, 1.86F, 0.18F, + BodyStyle.EXOTIC, false, true); + default -> new CarSpec(3.7F, 1.85F, 1.15F, 0.34F, 2.50F, 1.72F, 0.25F, + BodyStyle.MUSCLE, false, true); + }; + } +} diff --git a/src/main/java/dev/qynl/racers/car/CarStats.java b/src/main/java/dev/qynl/racers/car/CarStats.java new file mode 100644 index 0000000..d2cd2b1 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/CarStats.java @@ -0,0 +1,66 @@ +package dev.qynl.racers.car; + +/** + * The numbers the physics actually run on. Base values come from the car type, + * every tuning level then multiplies them. + * + *

Units are Minecraft-ish metric: blocks (== metres) per second, m/s^2 for + * accelerations, degrees for angles. + */ +public record CarStats(double power, double topSpeed, double brakeForce, double grip, + double latStiffness, double maxSteerDeg, double wheelbase, + double drag, double mass, double boostMul, double driftBias, + double maxHealth) { + + public static CarStats of(CarType type, int[] levels) { + double engine = 1.0 + 0.115 * lv(levels, Tune.ENGINE); + double gearbox = 1.0 + 0.055 * lv(levels, Tune.GEARBOX); + double brakes = 1.0 + 0.11 * lv(levels, Tune.BRAKES); + double tires = 1.0 + 0.075 * lv(levels, Tune.TIRES); + double aero = 1.0 + 0.07 * lv(levels, Tune.AERO); + double chassis = 1.0 + 0.06 * lv(levels, Tune.CHASSIS); + double boost = 1.0 + 0.25 * lv(levels, Tune.BOOST); + + double weight = type.baseMass * (1.0 - 0.03 * lv(levels, Tune.CHASSIS)); + return new CarStats( + type.basePower * engine, + type.baseTopSpeed * gearbox * (1.0 + 0.02 * lv(levels, Tune.ENGINE)), + type.baseBrakes * brakes, + type.baseGrip * tires * (1.0 + 0.5 * aero) * (1.0 - 0.02 * lv(levels, Tune.CHASSIS)), + type.baseLatStiffness * tires * (1.0 + 0.4 * aero), + type.baseSteer, + type.baseWheelBase, + type.baseDrag * (1.0 - 0.12 * lv(levels, Tune.AERO)), + weight, + type.baseBoostMul * boost, + type.driftBias, + type.baseHealth * (1.0 + 0.18 * lv(levels, Tune.CHASSIS))); + } + + private static int lv(int[] levels, Tune tune) { + if (levels == null || tune.ordinal() >= levels.length) { + return 0; + } + return Math.max(0, Math.min(tune.maxLevel, levels[tune.ordinal()])); + } + + /** Top speed in km/h, for the HUD. */ + public double topSpeedKmh() { + return topSpeed * 3.6; + } + + /** 0..1 rating used by the garage screen bars. */ + public double rating(int index) { + return switch (index) { + case 0 -> clamp01(power / 30.0); + case 1 -> clamp01(topSpeed / 60.0); + case 2 -> clamp01(brakeForce / 32.0); + case 3 -> clamp01(grip / 28.0); + default -> clamp01(maxHealth / 400.0); + }; + } + + private static double clamp01(double v) { + return Math.max(0.0, Math.min(1.0, v)); + } +} diff --git a/src/main/java/dev/qynl/racers/car/CarType.java b/src/main/java/dev/qynl/racers/car/CarType.java new file mode 100644 index 0000000..43cfb86 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/CarType.java @@ -0,0 +1,113 @@ +package dev.qynl.racers.car; + +import java.util.List; + +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +/** + * The nine stock cars. Every car is available as a vehicle item and shares the + * car entity + renderer, only the numbers and the body spec change. + */ +public enum CarType { + KART("kart", "Cart Kart", 13.0, 24.0, 11.0, 17.0, 6.0, 34.0, 1.35, + 0.0016, 0.62, 1.18, 1.05, 190.0, "3.1", Formatting.GREEN), + RALLY("rally", "Rally Rascal", 11.5, 30.0, 13.0, 14.6, 5.2, 33.0, 2.25, + 0.0020, 1.05, 1.14, 0.95, 260.0, "350", Formatting.YELLOW), + SPORT("sport", "Circuit Sport", 13.5, 36.0, 15.5, 16.4, 5.6, 31.0, 2.45, + 0.0019, 1.00, 1.15, 0.95, 280.0, "GT-R", Formatting.RED), + HYPER("hyper", "Hyper Nova", 16.5, 42.0, 16.5, 17.4, 5.9, 30.0, 2.60, + 0.0017, 0.92, 1.20, 0.90, 300.0, "V12", Formatting.GOLD), + F1("f1", "Formula Apex", 18.5, 46.0, 21.0, 20.2, 6.6, 32.0, 3.10, + 0.0026, 0.72, 1.26, 0.75, 320.0, "Apex", Formatting.AQUA), + TRUCK("truck", "Ram Truck", 10.5, 26.0, 9.0, 10.6, 4.4, 36.0, 3.20, + 0.0024, 1.95, 1.05, 0.85, 460.0, "V8", Formatting.DARK_GREEN), + DRIFT("drift", "Drift Queen", 12.5, 31.0, 13.5, 13.2, 4.6, 40.0, 2.45, + 0.0020, 1.00, 1.05, 1.45, 270.0, "S15", Formatting.LIGHT_PURPLE), + HOTROD("hotrod", "Hot Rod", 15.5, 34.0, 9.0, 12.0, 4.4, 36.0, 2.90, + 0.0028, 1.22, 1.10, 1.10, 300.0, "V8 Blown", Formatting.DARK_RED), + /** Secret car - quick, light, and slightly unnerving. */ + SHADOW("shadow", "Shadow", 19.5, 48.0, 22.0, 21.0, 6.8, 30.0, 2.95, + 0.0015, 0.68, 1.30, 0.80, 340.0, "Null", Formatting.DARK_PURPLE); + + public final String id; + public final String displayName; + public final double basePower; + public final double baseTopSpeed; + public final double baseBrakes; + public final double baseGrip; + public final double baseLatStiffness; + public final double baseSteer; + public final double baseWheelBase; + public final double baseDrag; + public final double baseMass; + public final double baseBoostMul; + public final double driftBias; + public final double baseHealth; + public final String engineName; + public final Formatting color; + + CarType(String id, String displayName, double power, double topSpeed, double brakes, + double grip, double latStiffness, double steer, double wheelBase, double drag, + double mass, double boostMul, double driftBias, double health, String engineName, + Formatting color) { + this.id = id; + this.displayName = displayName; + this.basePower = power; + this.baseTopSpeed = topSpeed; + this.baseBrakes = brakes; + this.baseGrip = grip; + this.baseLatStiffness = latStiffness; + this.baseSteer = steer; + this.baseWheelBase = wheelBase; + this.baseDrag = drag; + this.baseMass = mass; + this.baseBoostMul = boostMul; + this.driftBias = driftBias; + this.baseHealth = health; + this.engineName = engineName; + this.color = color; + } + + public static CarType byId(String id) { + for (CarType type : values()) { + if (type.id.equals(id)) { + return type; + } + } + return SPORT; + } + + public static List stock() { + return List.of(KART, RALLY, SPORT, HYPER, F1, TRUCK, DRIFT, HOTROD); + } + + public CarSpec spec() { + return CarSpec.of(id); + } + + public Text label() { + return Text.literal(displayName).formatted(color); + } + + public String itemId() { + return "car_" + id; + } + + public String textureName() { + return id; + } + + /** Shop price in emeralds, and how many emeralds the owner gets when selling. */ + public int price() { + return switch (this) { + case KART -> 3; + case RALLY, DRIFT -> 6; + case SPORT, HOTROD -> 8; + case TRUCK -> 9; + case HYPER -> 12; + case F1 -> 16; + case SHADOW -> 24; + }; + } +} diff --git a/src/main/java/dev/qynl/racers/car/PaintColors.java b/src/main/java/dev/qynl/racers/car/PaintColors.java new file mode 100644 index 0000000..8bb13eb --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/PaintColors.java @@ -0,0 +1,145 @@ +package dev.qynl.racers.car; + +import java.util.LinkedHashMap; +import java.util.Map; + +import net.minecraft.util.Formatting; + +/** The paint palette: every car keeps three colours (body, secondary, accent). */ +public final class PaintColors { + public static final int WHITE = 0xF2F2F0; + public static final int BLACK = 0x2A2A30; + public static final int RED = 0xDC3B36; + public static final int ORANGE = 0xF07A28; + public static final int YELLOW = 0xF2C42A; + public static final int LIME = 0x4FD05A; + public static final int GREEN = 0x189454; + public static final int CYAN = 0x3CC8DC; + public static final int LIGHT_BLUE = 0x5B9CE8; + public static final int BLUE = 0x2C5CC4; + public static final int PURPLE = 0x8C3EBE; + public static final int MAGENTA = 0xEC6CC4; + public static final int PINK = 0xF090B8; + public static final int BROWN = 0x8A5A2C; + public static final int GRAY = 0x8E8E96; + public static final int LIGHT_GRAY = 0xC8C8CE; + + private static final Map NAMED = new LinkedHashMap<>(); + private static final Map CHAT = new LinkedHashMap<>(); + + static { + NAMED.put("white", WHITE); + NAMED.put("black", BLACK); + NAMED.put("red", RED); + NAMED.put("orange", ORANGE); + NAMED.put("yellow", YELLOW); + NAMED.put("lime", LIME); + NAMED.put("green", GREEN); + NAMED.put("cyan", CYAN); + NAMED.put("light_blue", LIGHT_BLUE); + NAMED.put("blue", BLUE); + NAMED.put("purple", PURPLE); + NAMED.put("magenta", MAGENTA); + NAMED.put("pink", PINK); + NAMED.put("brown", BROWN); + NAMED.put("gray", GRAY); + NAMED.put("light_gray", LIGHT_GRAY); + + CHAT.put(WHITE, Formatting.WHITE); + CHAT.put(BLACK, Formatting.DARK_GRAY); + CHAT.put(RED, Formatting.RED); + CHAT.put(ORANGE, Formatting.GOLD); + CHAT.put(YELLOW, Formatting.YELLOW); + CHAT.put(LIME, Formatting.GREEN); + CHAT.put(GREEN, Formatting.DARK_GREEN); + CHAT.put(CYAN, Formatting.DARK_AQUA); + CHAT.put(LIGHT_BLUE, Formatting.AQUA); + CHAT.put(BLUE, Formatting.BLUE); + CHAT.put(PURPLE, Formatting.DARK_PURPLE); + CHAT.put(MAGENTA, Formatting.LIGHT_PURPLE); + CHAT.put(PINK, Formatting.LIGHT_PURPLE); + CHAT.put(BROWN, Formatting.GOLD); + CHAT.put(GRAY, Formatting.GRAY); + CHAT.put(LIGHT_GRAY, Formatting.GRAY); + } + + private PaintColors() { + } + + public static Map named() { + return NAMED; + } + + public static Integer byName(String name) { + return NAMED.get(name); + } + + public static Formatting chat(int rgb) { + Formatting closest = Formatting.WHITE; + double best = Double.MAX_VALUE; + for (Map.Entry entry : CHAT.entrySet()) { + double d = distance(entry.getKey(), rgb); + if (d < best) { + best = d; + closest = entry.getValue(); + } + } + return closest; + } + + private static double distance(int a, int b) { + int ar = (a >> 16) & 0xFF, ag = (a >> 8) & 0xFF, ab = a & 0xFF; + int br = (b >> 16) & 0xFF, bg = (b >> 8) & 0xFF, bb = b & 0xFF; + return Math.pow(ar - br, 2) + Math.pow(ag - bg, 2) + Math.pow(ab - bb, 2); + } + + /** Next colour in the palette (used by the paint can item). */ + public static int next(int rgb, int direction) { + Object[] values = NAMED.values().toArray(); + int index = 0; + for (int i = 0; i < values.length; i++) { + if ((Integer) values[i] == rgb) { + index = i; + break; + } + } + index = Math.floorMod(index + direction, values.length); + return (Integer) values[index]; + } + + public static String nameOf(int rgb) { + for (Map.Entry entry : NAMED.entrySet()) { + if (entry.getValue() == rgb) { + return entry.getKey().replace('_', ' '); + } + } + return String.format("#%06X", rgb & 0xFFFFFF); + } + + public static int defaultPrimary(CarType type) { + return switch (type) { + case KART -> ORANGE; + case RALLY -> LIGHT_BLUE; + case SPORT -> RED; + case HYPER -> YELLOW; + case F1 -> GREEN; + case TRUCK -> BROWN; + case DRIFT -> MAGENTA; + case HOTROD -> 0x902C3C; + case SHADOW -> 0x26243A; + }; + } + + public static int defaultSecondary(CarType type) { + return switch (type) { + case KART -> YELLOW; + case RALLY -> WHITE; + case SPORT -> BLACK; + case HYPER, F1 -> WHITE; + case TRUCK -> GRAY; + case DRIFT -> 0x342E3C; + case HOTROD -> 0xCECEC6; + case SHADOW -> PURPLE; + }; + } +} diff --git a/src/main/java/dev/qynl/racers/car/PlayerCarEntity.java b/src/main/java/dev/qynl/racers/car/PlayerCarEntity.java new file mode 100644 index 0000000..3be2a56 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/PlayerCarEntity.java @@ -0,0 +1,19 @@ +package dev.qynl.racers.car; + +import net.minecraft.entity.EntityType; +import net.minecraft.world.World; + +/** + * A car a player can own, drive, tune and race. The physics live in + * {@link CarEntity}; this class only adds the player specific bits. + */ +public class PlayerCarEntity extends CarEntity { + public PlayerCarEntity(EntityType type, World world) { + super(type, world); + } + + @Override + public boolean isPlayerDriven() { + return !aiDriving; + } +} diff --git a/src/main/java/dev/qynl/racers/car/SurfaceType.java b/src/main/java/dev/qynl/racers/car/SurfaceType.java new file mode 100644 index 0000000..92b3c38 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/SurfaceType.java @@ -0,0 +1,92 @@ +package dev.qynl.racers.car; + +import net.minecraft.block.BlockState; +import net.minecraft.block.Blocks; +import net.minecraft.registry.tag.BlockTags; + +import dev.qynl.racers.registry.ModBlocks; + +/** How the ground under the wheels changes the physics. */ +public enum SurfaceType { + ROAD(1.00, 1.00, 1.00, 1.00, false), + PIT(1.00, 1.00, 1.00, 1.00, false), + KERB(0.96, 0.96, 0.88, 1.25, false), + ROUGH(0.80, 0.82, 0.84, 1.30, false), + GRASS(0.62, 0.60, 0.66, 1.70, false), + SAND(0.55, 0.52, 0.58, 2.10, false), + STONE(0.86, 0.88, 0.90, 1.20, false), + ICE(0.90, 0.95, 0.12, 0.35, false), + SLIME(0.88, 0.90, 0.70, 1.45, true), + WATER(0.45, 0.40, 0.55, 3.20, false), + AIR(1.0, 1.0, 1.0, 1.0, false); + + /** Multiplies engine power. */ + public final double powerMul; + /** Multiplies top speed. */ + public final double speedMul; + /** Multiplies available grip. */ + public final double gripMul; + /** Multiplies rolling resistance. */ + public final double rollMul; + public final boolean bouncy; + + SurfaceType(double powerMul, double speedMul, double gripMul, double rollMul, boolean bouncy) { + this.powerMul = powerMul; + this.speedMul = speedMul; + this.gripMul = gripMul; + this.rollMul = rollMul; + this.bouncy = bouncy; + } + + public static SurfaceType of(BlockState state) { + if (state.isAir()) { + return AIR; + } + var block = state.getBlock(); + if (block == ModBlocks.ASPHALT || block == ModBlocks.ASPHALT_CRACKED + || block == ModBlocks.ROAD_BLOCK || block == ModBlocks.FINISH_LINE + || block == ModBlocks.BOOST_PAD || block == ModBlocks.STATION) { + return ROAD; + } + if (block == ModBlocks.PIT_LANE) { + return PIT; + } + if (block == ModBlocks.KERB) { + return KERB; + } + if (block == ModBlocks.FAKE_ICE) { + return ICE; + } + if (block == ModBlocks.FAKE_SLIME) { + return SLIME; + } + if (state.isOf(Blocks.ICE) || state.isOf(Blocks.PACKED_ICE) || state.isOf(Blocks.BLUE_ICE) + || state.isOf(Blocks.FROSTED_ICE)) { + return ICE; + } + if (state.isOf(Blocks.SLIME_BLOCK)) { + return SLIME; + } + if (state.isOf(Blocks.WATER) || state.isOf(Blocks.KELP) + || state.isOf(Blocks.SEAGRASS) || state.isOf(Blocks.BUBBLE_COLUMN)) { + return WATER; + } + if (state.isIn(BlockTags.DIRT) || state.isIn(BlockTags.LEAVES) + || state.isIn(BlockTags.FLOWERS) || state.isOf(Blocks.GRASS_BLOCK) + || state.isOf(Blocks.MOSS_BLOCK) || state.isOf(Blocks.FARMLAND)) { + return GRASS; + } + if (state.isIn(BlockTags.SAND) || state.isOf(Blocks.SOUL_SAND) || state.isOf(Blocks.GRAVEL) + || state.isOf(Blocks.CLAY)) { + return SAND; + } + if (state.isIn(BlockTags.STONE_BRICKS) || state.isIn(BlockTags.BASE_STONE_OVERWORLD) + || state.isOf(Blocks.STONE) || state.isOf(Blocks.COBBLESTONE) + || state.isOf(Blocks.SMOOTH_STONE) || state.isIn(BlockTags.WOOL) + || state.isIn(BlockTags.PLANKS) || state.isOf(Blocks.QUARTZ_BLOCK)) { + return STONE; + } + // anything else solid-but-unclassified (concrete, terracotta, ...) is rough + return ROUGH; + } +} diff --git a/src/main/java/dev/qynl/racers/car/Tune.java b/src/main/java/dev/qynl/racers/car/Tune.java new file mode 100644 index 0000000..9997445 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/Tune.java @@ -0,0 +1,48 @@ +package dev.qynl.racers.car; + +import java.util.List; + +/** + * The seven tuning systems a car can be upgraded with. Each level costs more + * resources (see {@link dev.qynl.racers.shop.PartShop}) and stacks with the + * car's base {@link CarStats}. + */ +public enum Tune { + ENGINE("engine", "Engine", 4, "Power and acceleration"), + GEARBOX("gearbox", "Gearbox", 3, "Top speed and shift speed"), + BRAKES("brakes", "Brakes", 3, "Braking force and balance"), + TIRES("tires", "Tyres", 4, "Grip, cornering speed, drift control"), + AERO("aero", "Aero", 3, "Downforce, high speed grip, less drag"), + CHASSIS("chassis", "Chassis", 3, "Weight, crash resistance, health"), + BOOST("boost", "Nitro", 3, "Boost capacity and recharge rate"); + + public final String id; + public final String displayName; + public final int maxLevel; + public final String description; + + Tune(String id, String displayName, int maxLevel, String description) { + this.id = id; + this.displayName = displayName; + this.maxLevel = maxLevel; + this.description = description; + } + + public static Tune byId(String id) { + for (Tune tune : values()) { + if (tune.id.equals(id)) { + return tune; + } + } + return null; + } + + public static List all() { + return List.of(values()); + } + + /** Registry-style names used by the villager shop and the item registry. */ + public String itemName() { + return "tune_" + id; + } +} diff --git a/src/main/java/dev/qynl/racers/command/RaceCommands.java b/src/main/java/dev/qynl/racers/command/RaceCommands.java new file mode 100644 index 0000000..125a3ea --- /dev/null +++ b/src/main/java/dev/qynl/racers/command/RaceCommands.java @@ -0,0 +1,508 @@ +package dev.qynl.racers.command; + +import com.mojang.brigadier.arguments.DoubleArgumentType; +import com.mojang.brigadier.arguments.IntegerArgumentType; +import com.mojang.brigadier.arguments.StringArgumentType; +import com.mojang.brigadier.context.CommandContext; +import com.mojang.brigadier.suggestion.SuggestionProvider; + +import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback; +import net.minecraft.command.CommandSource; +import net.minecraft.command.argument.EntityArgumentType; +import net.minecraft.server.command.CommandManager; +import net.minecraft.server.command.ServerCommandSource; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.car.CarType; +import dev.qynl.racers.car.PaintColors; +import dev.qynl.racers.race.RaceEntry; +import dev.qynl.racers.race.RaceFormat; +import dev.qynl.racers.race.RaceManager; +import dev.qynl.racers.race.RaceSession; +import dev.qynl.racers.stats.StatsManager; +import dev.qynl.racers.track.Track; +import dev.qynl.racers.track.TrackManager; +import dev.qynl.racers.track.TrackPlacement; +import dev.qynl.racers.track.TrackRecorder; +import dev.qynl.racers.util.Cars; +import dev.qynl.racers.util.Stopwatch; + +/** {@code /race ...} - everything the mod can do, scriptable. */ +public final class RaceCommands { + private RaceCommands() { + } + + private static final SuggestionProvider TRACKS = (context, builder) -> + CommandSource.suggestMatching( + TrackManager.get(context.getSource().getServer()).all().stream() + .map(track -> track.id).toList(), builder); + + private static final SuggestionProvider CAR_TYPES = (context, builder) -> + CommandSource.suggestMatching( + java.util.Arrays.stream(CarType.values()).map(type -> type.id).toList(), builder); + + public static void init() { + CommandRegistrationCallback.EVENT.register((dispatcher, registryAccess, environment) -> + dispatcher.register(CommandManager.literal("race") + .executes(context -> help(context.getSource())) + .then(CommandManager.literal("help").executes(context -> help(context.getSource()))) + .then(CommandManager.literal("list").executes(context -> list(context.getSource()))) + // --- driving helpers --------------------------------- + .then(CommandManager.literal("car") + .executes(context -> giveCar(context.getSource(), "sport", null)) + .then(CommandManager.argument("type", StringArgumentType.word()) + .suggests(CAR_TYPES) + .executes(context -> giveCar(context.getSource(), + StringArgumentType.getString(context, "type"), null)) + .then(CommandManager.argument("player", EntityArgumentType.player()) + .executes(context -> giveCar(context.getSource(), + StringArgumentType.getString(context, "type"), + EntityArgumentType.getPlayer(context, "player")))))) + .then(CommandManager.literal("park") + .executes(context -> park(context.getSource()))) + .then(CommandManager.literal("repair") + .executes(context -> repair(context.getSource()))) + .then(CommandManager.literal("ai") + .executes(context -> toggleAi(context.getSource(), -1)) + .then(CommandManager.argument("on", com.mojang.brigadier.arguments.BoolArgumentType.bool()) + .executes(context -> toggleAi(context.getSource(), + com.mojang.brigadier.arguments.BoolArgumentType.getBool(context, "on") ? 1 : 0))) + .then(CommandManager.argument("skill", DoubleArgumentType.doubleArg(0.2, 1.0)) + .executes(context -> setAiSkill(context.getSource(), + DoubleArgumentType.getDouble(context, "skill"))))) + // --- tracks ------------------------------------------- + .then(CommandManager.literal("tracks").executes(context -> listTracks(context.getSource()))) + .then(CommandManager.literal("wand") + .executes(context -> giveWand(context.getSource()))) + .then(CommandManager.literal("place") + .then(CommandManager.argument("track", StringArgumentType.word()) + .suggests(TRACKS) + .executes(context -> place(context.getSource(), + StringArgumentType.getString(context, "track"), 0)) + .then(CommandManager.argument("rotation", IntegerArgumentType.integer(0, 359)) + .executes(context -> place(context.getSource(), + StringArgumentType.getString(context, "track"), + IntegerArgumentType.getInteger(context, "rotation")))))) + .then(CommandManager.literal("remove") + .executes(context -> remove(context.getSource()))) + .then(CommandManager.literal("record") + .executes(context -> record(context.getSource(), 7, true)) + .then(CommandManager.argument("width", IntegerArgumentType.integer(3, 15)) + .executes(context -> record(context.getSource(), + IntegerArgumentType.getInteger(context, "width"), true)) + .then(CommandManager.argument("barriers", + com.mojang.brigadier.arguments.BoolArgumentType.bool()) + .executes(context -> record(context.getSource(), + IntegerArgumentType.getInteger(context, "width"), + com.mojang.brigadier.arguments.BoolArgumentType.getBool(context, "barriers")))))) + // --- racing ------------------------------------------ + .then(CommandManager.literal("start") + .executes(context -> start(context.getSource(), 0, 3, 0.72)) + .then(CommandManager.argument("ai", IntegerArgumentType.integer(0, 15)) + .executes(context -> start(context.getSource(), + IntegerArgumentType.getInteger(context, "ai"), 3, 0.72)) + .then(CommandManager.argument("laps", IntegerArgumentType.integer(1, 20)) + .executes(context -> start(context.getSource(), + IntegerArgumentType.getInteger(context, "ai"), + IntegerArgumentType.getInteger(context, "laps"), 0.72)) + .then(CommandManager.argument("skill", DoubleArgumentType.doubleArg(0.2, 1.0)) + .executes(context -> start(context.getSource(), + IntegerArgumentType.getInteger(context, "ai"), + IntegerArgumentType.getInteger(context, "laps"), + DoubleArgumentType.getDouble(context, "skill"))))))) + .then(CommandManager.literal("join").executes(context -> join(context.getSource()))) + .then(CommandManager.literal("leave").executes(context -> leave(context.getSource()))) + .then(CommandManager.literal("stop").executes(context -> stop(context.getSource()))) + .then(CommandManager.literal("info").executes(context -> info(context.getSource()))) + .then(CommandManager.literal("standings").executes(context -> standings(context.getSource()))) + .then(CommandManager.literal("stats") + .executes(context -> stats(context.getSource(), context.getSource().getPlayer())) + .then(CommandManager.argument("player", EntityArgumentType.player()) + .executes(context -> stats(context.getSource(), + EntityArgumentType.getPlayer(context, "player"))))) + .then(CommandManager.literal("top") + .executes(context -> top(context.getSource(), null)) + .then(CommandManager.argument("track", StringArgumentType.word()) + .suggests(TRACKS) + .executes(context -> top(context.getSource(), + StringArgumentType.getString(context, "track"))))) + .then(CommandManager.literal("paint") + .then(CommandManager.argument("color", StringArgumentType.word()) + .suggests((context, builder) -> CommandSource.suggestMatching( + PaintColors.named().keySet(), builder)) + .executes(context -> paint(context.getSource(), + StringArgumentType.getString(context, "color"))))) + .then(CommandManager.literal("drift") + .executes(context -> drift(context.getSource()))))); + } + + // ------------------------------------------------------------------ + private static int help(ServerCommandSource source) { + source.sendFeedback(() -> Text.literal("FabricRacers - /race commands").formatted(Formatting.GOLD), + false); + String[] lines = { + "/race car [type] - get a car (kart, rally, sport, hyper, f1, truck, drift, hotrod, shadow)", + "/race park - put the car you are in back into your inventory", + "/race repair - fix your car", + "/race ai [on|off|skill 0.2-1.0] - hand your car to the AI", + "/race tracks | /race wand - list circuits / get the track wand", + "/race place [rotation] - build a circuit where you stand", + "/race remove - remove the nearest placed circuit", + "/race record [width] [barriers] - record a lap and turn it into a circuit", + "/race start [ai] [laps] [skill] - start a race where you are", + "/race join | leave | stop | info | standings", + "/race stats [player] | /race top [track] - records and leaderboards", + "/race paint | /race drift - cosmetics and the drift challenge", + }; + for (String line : lines) { + source.sendFeedback(() -> Text.literal(line).formatted(Formatting.GRAY), false); + } + return 1; + } + + private static int list(ServerCommandSource source) { + var manager = TrackManager.get(source.getServer()); + source.sendFeedback(() -> Text.literal("Circuits (" + manager.all().size() + "):") + .formatted(Formatting.GOLD), false); + for (Track track : manager.all()) { + source.sendFeedback(() -> Text.literal(" - " + track.id + ": " + track.describe()) + .formatted(Formatting.GRAY), false); + } + source.sendFeedback(() -> Text.literal("Placed: " + manager.placements().size()) + .formatted(Formatting.YELLOW), false); + for (TrackPlacement placement : manager.placements()) { + source.sendFeedback(() -> Text.literal(" - " + placement.label()).formatted(Formatting.GRAY), + false); + } + return 1; + } + + private static int listTracks(ServerCommandSource source) { + return list(source); + } + + private static int giveCar(ServerCommandSource source, String type, ServerPlayerEntity target) { + ServerPlayerEntity player = target != null ? target : source.getPlayer(); + if (player == null) { + source.sendError(Text.literal("Player only.")); + return 0; + } + CarType carType = CarType.byId(type); + var stack = dev.qynl.racers.registry.ModItems.carStack(carType, player); + if (!player.getInventory().insertStack(stack)) { + player.dropItem(stack, false); + } + source.sendFeedback(() -> Text.translatable("message.fabricracers.car_given", + carType.displayName).formatted(Formatting.GREEN), false); + return 1; + } + + private static int giveWand(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null) { + source.sendError(Text.literal("Player only.")); + return 0; + } + var stack = new net.minecraft.item.ItemStack(dev.qynl.racers.registry.ModItems.TRACK_WAND); + player.getInventory().insertStack(stack); + return 1; + } + + private static int park(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null || !(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + player.stopRiding(); + player.getInventory().insertStack(parkStack(car)); + car.discard(); + return 1; + } + + private static net.minecraft.item.ItemStack parkStack(CarEntity car) { + var stack = new net.minecraft.item.ItemStack(dev.qynl.racers.registry.ModItems.carItem(car.carType())); + CarData.apply(stack, car.data().withHealth(car.engineHealth(), car.tyreHealth())); + return stack; + } + + private static int repair(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null || !(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + car.resetHealth(); + source.sendFeedback(() -> Text.translatable("message.fabricracers.repaired") + .formatted(Formatting.GREEN), false); + return 1; + } + + private static int toggleAi(ServerCommandSource source, int value) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null || !(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + boolean on = value == -1 ? !car.isAiDriving() : value == 1; + car.setAiDriving(on); + source.sendFeedback(() -> Text.literal(on ? "AI driver engaged." : "AI driver off.") + .formatted(Formatting.AQUA), false); + return 1; + } + + private static int setAiSkill(ServerCommandSource source, double skill) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null || !(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + car.setAiSkill(skill); + car.setAiDriving(true); + source.sendFeedback(() -> Text.literal(String.format("AI skill set to %.2f", skill)) + .formatted(Formatting.AQUA), false); + return 1; + } + + private static int place(ServerCommandSource source, String trackId, int rotation) { + ServerPlayerEntity player = source.getPlayer(); + TrackManager manager = TrackManager.get(source.getServer()); + Track track = manager.track(trackId); + if (track == null) { + source.sendError(Text.literal("Unknown track: " + trackId)); + return 0; + } + if (player == null) { + source.sendError(Text.literal("Player only (the track is built around you).")); + return 0; + } + BlockPos anchor = player.getBlockPos().offset(player.getHorizontalFacing()); + manager.placeTrack(player, track, anchor, rotation); + return 1; + } + + private static int remove(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + TrackManager manager = TrackManager.get(source.getServer()); + if (player == null) { + source.sendError(Text.literal("Player only.")); + return 0; + } + TrackPlacement placement = manager.placementNear(player.getPos(), 300.0); + if (placement == null) { + source.sendError(Text.literal("No placed circuit found.")); + return 0; + } + manager.removePlacement(player, placement, true); + return 1; + } + + private static int record(ServerCommandSource source, int width, boolean barriers) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null) { + source.sendError(Text.literal("Player only.")); + return 0; + } + TrackManager manager = TrackManager.get(source.getServer()); + TrackRecorder recorder = manager.recorder(player); + if (recorder.isFinished()) { + return 0; + } + if (recorder.samples() < 2) { + recorder.setWidth(width); + recorder.setBarriers(barriers); + source.sendFeedback(() -> Text.literal("Recording - drive a lap, then run /race record again.") + .formatted(Formatting.GREEN), false); + return 1; + } + recorder.stop(); + return 1; + } + + private static int start(ServerCommandSource source, int ai, int laps, double skill) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null || !(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + TrackManager manager = TrackManager.get(source.getServer()); + TrackPlacement placement = manager.placementNear(car.getPos(), 160.0); + if (placement == null) { + source.sendError(Text.literal("No circuit nearby. Use /race place first.")); + return 0; + } + RaceFormat format = RaceFormat.of(laps, ai, skill, true); + RaceSession session = RaceManager.get(source.getServer()) + .start(player, placement, format, ai, skill); + if (session == null) { + return 0; + } + source.sendFeedback(() -> Text.literal(session.describe()).formatted(Formatting.GOLD), false); + return 1; + } + + private static int join(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null) { + return 0; + } + RaceManager races = RaceManager.get(source.getServer()); + RaceSession session = null; + double best = 160.0; + for (RaceSession candidate : races.sessions()) { + if (!candidate.isLive() || candidate.state != RaceSession.State.COUNTDOWN) { + continue; + } + double distance = candidate.placement().distanceTo(player.getPos()); + if (distance < best) { + best = distance; + session = candidate; + } + } + if (session == null) { + source.sendError(Text.literal("No race is waiting on a grid nearby.")); + return 0; + } + if (!(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + int slot = session.entries().size(); + Vec3d pos = session.placement().gridPos(slot); + car.teleportCleanly(pos.x, pos.y, pos.z, session.placement().startYaw()); + car.setControlLocked(true); + car.setRaceState(1); + RaceEntry entry = new RaceEntry(car, player.getUuid(), player.getName().getString(), false, 0.0); + session.addEntry(entry); + session.announce(player.getName().getString() + " joined the grid"); + return 1; + } + + private static int leave(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null) { + return 0; + } + RaceSession session = RaceManager.get(source.getServer()).sessionFor(player); + if (session == null) { + source.sendError(Text.literal("You are not racing.")); + return 0; + } + RaceEntry entry = session.entryFor(player); + if (entry != null) { + entry.finished = true; + entry.finishMs = System.currentTimeMillis() - session.raceStartMs; + } + player.stopRiding(); + StatsManager.get(source.getServer()).recordWreck(player); + return 1; + } + + private static int stop(ServerCommandSource source) { + RaceManager races = RaceManager.get(source.getServer()); + int stopped = 0; + for (RaceSession session : new java.util.ArrayList<>(races.sessions())) { + races.stop(session, "Race stopped by an operator."); + stopped++; + } + int count = stopped; + source.sendFeedback(() -> Text.literal("Stopped " + count + " race(s).").formatted(Formatting.YELLOW), + false); + return 1; + } + + private static int info(ServerCommandSource source) { + RaceManager races = RaceManager.get(source.getServer()); + if (races.sessions().isEmpty()) { + source.sendFeedback(() -> Text.literal("No live races.").formatted(Formatting.GRAY), false); + return 1; + } + for (RaceSession session : races.sessions()) { + source.sendFeedback(() -> Text.literal(session.describe()).formatted(Formatting.GOLD), false); + } + return 1; + } + + private static int standings(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + RaceSession session = player == null ? null + : RaceManager.get(source.getServer()).sessionFor(player); + if (session == null) { + source.sendFeedback(() -> Text.literal("No race.").formatted(Formatting.GRAY), false); + return 1; + } + source.sendFeedback(() -> Text.literal(session.standingsText()).formatted(Formatting.WHITE), false); + return 1; + } + + private static int stats(ServerCommandSource source, ServerPlayerEntity target) { + if (target == null) { + source.sendError(Text.literal("Player only.")); + return 0; + } + StatsManager.get(source.getServer()).sendRecord(target); + return 1; + } + + private static int top(ServerCommandSource source, String trackId) { + StatsManager stats = StatsManager.get(source.getServer()); + if (trackId == null) { + TrackPlacement placement = null; + if (source.getPlayer() != null) { + placement = TrackManager.get(source.getServer()) + .placementNear(source.getPlayer().getPos(), 200.0); + } + trackId = placement != null ? placement.track.id + : (TrackManager.get(source.getServer()).all().isEmpty() ? null + : TrackManager.get(source.getServer()).all().get(0).id); + } + if (trackId == null) { + source.sendError(Text.literal("No tracks yet.")); + return 0; + } + String id = trackId; + source.sendFeedback(() -> Text.literal("Leaderboard - " + id).formatted(Formatting.GOLD), false); + int position = 1; + for (var entry : stats.leaderboard(id)) { + int place = position++; + source.sendFeedback(() -> Text.literal(place + ". " + entry.getKey() + " " + + Stopwatch.format(entry.getValue())).formatted(Formatting.WHITE), false); + } + return 1; + } + + private static int paint(ServerCommandSource source, String colorName) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null || !(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + Integer color = PaintColors.byName(colorName); + if (color == null) { + source.sendError(Text.literal("Unknown colour.")); + return 0; + } + car.setPaint(color, car.data().secondaryColor); + source.sendFeedback(() -> Text.literal("Painted " + colorName + ".").formatted(Formatting.GREEN), + false); + return 1; + } + + private static int drift(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + if (player == null || !(player.getVehicle() instanceof CarEntity car)) { + source.sendError(Text.translatable("race.fabricracers.need_car")); + return 0; + } + source.sendFeedback(() -> Text.literal(String.format("Drift score: %.0f (boost %.0f%%)", + car.getDriftScore(), car.getBoostCharge() * 100.0)).formatted(Formatting.LIGHT_PURPLE), + false); + return 1; + } +} diff --git a/src/main/java/dev/qynl/racers/event/CarServerEvents.java b/src/main/java/dev/qynl/racers/event/CarServerEvents.java new file mode 100644 index 0000000..63d3e8e --- /dev/null +++ b/src/main/java/dev/qynl/racers/event/CarServerEvents.java @@ -0,0 +1,71 @@ +package dev.qynl.racers.event; + +import net.fabricmc.fabric.api.entity.event.v1.ServerEntityWorldChangeEvents; +import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; +import net.fabricmc.fabric.api.event.player.AttackEntityCallback; +import net.fabricmc.fabric.api.event.player.UseEntityCallback; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Formatting; + +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.race.RaceManager; +import dev.qynl.racers.race.RaceSession; +import dev.qynl.racers.stats.StatsManager; + +/** + * Small bits of glue: mounting/dismounting bookkeeping, camera hints, keeping + * players out of their own way while racing and clearing the HUD. + */ +public final class CarServerEvents { + private CarServerEvents() { + } + + public static void init() { + // Prevent friendly fire on cars: a punch nudges the car instead of hurting the driver + AttackEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { + if (entity instanceof CarEntity) { + return ActionResult.PASS; + } + return ActionResult.PASS; + }); + + UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { + if (world.isClient || !(entity instanceof CarEntity car)) { + return ActionResult.PASS; + } + if (player.isSneaking()) { + return ActionResult.PASS; + } + return ActionResult.PASS; + }); + + ServerEntityWorldChangeEvents.AFTER_PLAYER_CHANGE_WORLD.register((player, origin, destination) -> { + var session = RaceManager.get(player.server).sessionFor(player); + if (session != null) { + session.announce(player.getName().getString() + " left the area"); + } + }); + + ServerTickEvents.END_SERVER_TICK.register(server -> { + // Safety net: cars that somehow ended up under the world get parked one block up + for (ServerPlayerEntity player : server.getPlayerManager().getPlayerList()) { + if (player.getVehicle() instanceof CarEntity car && car.getY() < -64.0) { + car.teleportCleanly(car.getX(), 70.0, car.getZ(), car.getYaw()); + player.sendMessage(Text.translatable("message.fabricracers.recovered") + .formatted(Formatting.YELLOW), true); + } + } + }); + } + + /** Called when a player leaves a car: clears the HUD state and keeps stats fresh. */ + public static void onDismount(PlayerEntity player, CarEntity car) { + RaceSession session = RaceManager.get(player.getServer()).sessionFor(player); + if (session == null && player instanceof ServerPlayerEntity serverPlayer) { + StatsManager.get(serverPlayer.server).recordDrive(car); + } + } +} diff --git a/src/main/java/dev/qynl/racers/item/CarItem.java b/src/main/java/dev/qynl/racers/item/CarItem.java new file mode 100644 index 0000000..dae074d --- /dev/null +++ b/src/main/java/dev/qynl/racers/item/CarItem.java @@ -0,0 +1,132 @@ +package dev.qynl.racers.item; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.ItemUsageContext; +import net.minecraft.item.tooltip.TooltipType; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Formatting; +import net.minecraft.util.Hand; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Direction; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; + +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.car.CarType; +import dev.qynl.racers.car.PlayerCarEntity; +import dev.qynl.racers.util.Cars; + +import java.util.List; + +/** The car itself, as an item: place it, hop in, drive it. */ +public class CarItem extends Item { + private final CarType type; + + public CarItem(CarType type, Settings settings) { + super(settings); + this.type = type; + } + + public CarType carType() { + return type; + } + + @Override + public ActionResult useOnBlock(ItemUsageContext context) { + World world = context.getWorld(); + if (world.isClient) { + return ActionResult.SUCCESS; + } + BlockPos pos = context.getBlockPos().offset(context.getSide()); + Vec3d spawn = new Vec3d(pos.getX() + 0.5, pos.getY(), pos.getZ() + 0.5); + // face away from the clicked face + Direction side = context.getSide(); + float yaw = switch (side) { + case NORTH -> 180.0F; + case SOUTH -> 0.0F; + case WEST -> 90.0F; + case EAST -> 270.0F; + default -> context.getPlayer() == null ? 0.0F : context.getPlayer().getYaw(); + }; + return spawnCar(context.getStack(), context.getPlayer(), world, spawn, yaw); + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + if (world.isClient) { + return ActionResult.SUCCESS; + } + ItemStack stack = user.getStackInHand(hand); + // Drop it roughly a car length in front of the player + Vec3d look = Vec3d.fromPolar(0.0F, user.getYaw()).multiply(2.6); + Vec3d spawn = user.getPos().add(look); + double ground = Cars.groundHeight(world, spawn); + if (Double.isNaN(ground)) { + user.sendMessage(Text.translatable("message.fabricracers.no_room").formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + return spawnCar(stack, user, world, new Vec3d(spawn.x, ground, spawn.z), user.getYaw()); + } + + private ActionResult spawnCar(ItemStack stack, PlayerEntity player, World world, Vec3d spawn, float yaw) { + CarData data = CarData.of(stack); + if (data == null) { + data = CarData.fresh(type, player); + CarData.apply(stack, data); + } + if (player != null && !data.ownedBy(player)) { + player.sendMessage(Text.translatable("message.fabricracers.not_your_car", + Text.literal(data.ownerName)).formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + PlayerCarEntity car = new PlayerCarEntity(dev.qynl.racers.registry.ModEntities.PLAYER_CAR, world); + car.initFromData(data); + car.refreshPositionAndAngles(spawn.x, Cars.snapCarY(world, spawn), spawn.z, yaw, 0.0F); + if (!world.isSpaceEmpty(car, car.getBoundingBox()) || !Cars.groundIsSolid(world, car.getBlockPos())) { + if (player != null) { + player.sendMessage(Text.translatable("message.fabricracers.no_room").formatted(Formatting.RED), true); + car.discard(); + } + return ActionResult.FAIL; + } + world.spawnEntity(car); + if (player instanceof ServerPlayerEntity serverPlayer) { + dev.qynl.racers.stats.StatsManager.get(serverPlayer.server) + .get(serverPlayer).carsPlaced++; + } + if (player == null || !player.isCreative()) { + stack.decrement(1); + } + return ActionResult.CONSUME; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType tooltipType) { + CarData data = CarData.of(stack); + tooltip.add(Text.translatable("item.fabricracers." + type.itemId() + ".desc") + .formatted(Formatting.GRAY)); + if (data != null) { + tooltip.add(Text.translatable("tooltip.fabricracers.stats", + String.format("%.0f", data.stats().topSpeedKmh()), + String.format("%.1f", data.stats().power()), + data.ownerName == null || data.ownerName.isEmpty() ? "-" : data.ownerName) + .formatted(Formatting.DARK_GRAY)); + for (dev.qynl.racers.car.Tune tune : dev.qynl.racers.car.Tune.values()) { + int level = data.level(tune); + if (level > 0) { + tooltip.add(Text.literal(" " + tune.displayName + " " + level + "/" + tune.maxLevel) + .formatted(Formatting.GOLD)); + } + } + tooltip.add(Text.translatable("tooltip.fabricracers.color", + dev.qynl.racers.car.PaintColors.nameOf(data.primaryColor), + dev.qynl.racers.car.PaintColors.nameOf(data.secondaryColor)) + .formatted(Formatting.DARK_GRAY)); + } + } +} diff --git a/src/main/java/dev/qynl/racers/item/ToolItems.java b/src/main/java/dev/qynl/racers/item/ToolItems.java new file mode 100644 index 0000000..238be70 --- /dev/null +++ b/src/main/java/dev/qynl/racers/item/ToolItems.java @@ -0,0 +1,348 @@ +package dev.qynl.racers.item; + +import java.util.List; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.ItemUsageContext; +import net.minecraft.item.tooltip.TooltipType; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.ActionResult; +import net.minecraft.util.Formatting; +import net.minecraft.util.Hand; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; + +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.car.PaintColors; +import dev.qynl.racers.car.Tune; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.registry.ModItems; +import dev.qynl.racers.stats.StatsManager; +import dev.qynl.racers.track.TrackManager; +import dev.qynl.racers.util.Cars; +import dev.qynl.racers.util.Stopwatch; + +/** The toolbox: wrench, paint can, track wand, stopwatch, trophies, key and parts. */ +public final class ToolItems { + private ToolItems() { + } + + private static CarEntity carAt(World world, PlayerEntity player) { + if (player.getVehicle() instanceof CarEntity car) { + return car; + } + CarEntity near = Cars.carNear(world, player.getPos(), 4.0); + return near; + } + + /** Repairs a car, picks it back up, or opens the tuning sheet. */ + public static class Wrench extends Item { + public Wrench(Settings settings) { + super(settings); + } + + @Override + public ActionResult useOnBlock(ItemUsageContext context) { + if (context.getWorld().isClient) { + return ActionResult.SUCCESS; + } + CarEntity car = Cars.carNear(context.getWorld(), context.getBlockPos().toCenterPos(), 3.0); + if (car == null) { + return ActionResult.PASS; + } + return interact(car, context.getPlayer(), context.getStack(), context.getPlayer().isSneaking()); + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + if (world.isClient) { + return ActionResult.SUCCESS; + } + CarEntity car = carAt(world, user); + if (car == null) { + user.sendMessage(Text.translatable("message.fabricracers.wrench_none") + .formatted(Formatting.GRAY), true); + return ActionResult.PASS; + } + return interact(car, user, user.getStackInHand(hand), user.isSneaking()); + } + + private ActionResult interact(CarEntity car, PlayerEntity player, ItemStack wrench, boolean sneaking) { + if (player == null) { + return ActionResult.PASS; + } + if (sneaking) { + if (!car.data().ownedBy(player)) { + player.sendMessage(Text.translatable("message.fabricracers.not_your_car", + Text.literal(car.ownerName())).formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + if (car.isRidden()) { + player.sendMessage(Text.translatable("message.fabricracers.car_occupied") + .formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + ItemStack stack = new ItemStack(ModItems.carItem(car.data().type)); + CarData.apply(stack, car.data().withHealth(1.0F, 1.0F)); + car.discard(); + if (!player.getInventory().insertStack(stack)) { + player.dropItem(stack, false); + } + if (!player.isCreative()) { + wrench.damage(1, player, net.minecraft.entity.EquipmentSlot.MAINHAND); + } + player.sendMessage(Text.translatable("message.fabricracers.car_picked_up") + .formatted(Formatting.GREEN), true); + return ActionResult.CONSUME; + } + if (player.isSneaking() || !car.data().ownedBy(player)) { + return car.repair(player, wrench); + } + // open the tuning sheet on the client + if (player instanceof ServerPlayerEntity serverPlayer) { + net.minecraft.nbt.NbtCompound extra = new net.minecraft.nbt.NbtCompound(); + for (Tune tune : Tune.values()) { + extra.putInt(tune.id, dev.qynl.racers.shop.PartShop.costFor(tune, + car.data().level(tune) + 1)); + } + ModNetworking.sendGarage(serverPlayer, car, extra); + } + return ActionResult.CONSUME; + } + } + + /** Claims an ownerless car, or repaints one. */ + public static class CarKey extends Item { + public CarKey(Settings settings) { + super(settings); + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + if (world.isClient) { + return ActionResult.SUCCESS; + } + CarEntity car = carAt(world, user); + if (car == null) { + user.sendMessage(Text.translatable("message.fabricracers.wrench_none") + .formatted(Formatting.GRAY), true); + return ActionResult.PASS; + } + if (car.ownerName().isEmpty() || car.owner() == null) { + car.setOwner(user.getUuid(), user.getName().getString()); + user.sendMessage(Text.translatable("message.fabricracers.claimed", + car.carType().displayName).formatted(Formatting.GREEN), true); + return ActionResult.CONSUME; + } + user.sendMessage(Text.translatable("message.fabricracers.key_owner", + Text.literal(car.ownerName())).formatted(Formatting.YELLOW), true); + return ActionResult.CONSUME; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType type) { + tooltip.add(Text.translatable("item.fabricracers.carrot_key.desc").formatted(Formatting.GRAY)); + } + } + + /** Repaints a car. */ + public static class PaintCan extends Item { + private final int color; + + public PaintCan(int color, Settings settings) { + super(settings); + this.color = color; + } + + @Override + public ActionResult useOnBlock(ItemUsageContext context) { + if (context.getWorld().isClient) { + return ActionResult.SUCCESS; + } + CarEntity car = Cars.carNear(context.getWorld(), context.getBlockPos().toCenterPos(), 3.0); + return paint(car, context.getPlayer(), context.getStack()); + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + if (world.isClient) { + return ActionResult.SUCCESS; + } + return paint(carAt(world, user), user, user.getStackInHand(hand)); + } + + private ActionResult paint(CarEntity car, PlayerEntity player, ItemStack stack) { + if (car == null || player == null) { + return ActionResult.PASS; + } + if (!car.data().ownedBy(player)) { + player.sendMessage(Text.translatable("message.fabricracers.not_your_car", + Text.literal(car.ownerName())).formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + car.setPaint(color, car.data().secondaryColor); + if (!player.isCreative()) { + stack.decrement(1); + } + player.sendMessage(Text.translatable("message.fabricracers.painted", + Text.literal(PaintColors.nameOf(color)).formatted(PaintColors.chat(color))) + .formatted(Formatting.GRAY), true); + return ActionResult.CONSUME; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType type) { + tooltip.add(Text.translatable("item.fabricracers.paint.desc").formatted(Formatting.GRAY)); + } + } + + /** Selects and places whole circuits. */ + public static class TrackWand extends Item { + public TrackWand(Settings settings) { + super(settings); + } + + @Override + public ActionResult useOnBlock(ItemUsageContext context) { + return use(context.getWorld(), context.getPlayer(), context.getHand(), context.getBlockPos()); + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + BlockPos pos = user == null ? BlockPos.ORIGIN : user.getBlockPos(); + return use(world, user, hand, pos); + } + + private ActionResult use(World world, PlayerEntity user, Hand hand, BlockPos pos) { + if (world.isClient) { + return ActionResult.SUCCESS; + } + if (!(user instanceof ServerPlayerEntity player)) { + return ActionResult.PASS; + } + TrackManager manager = TrackManager.get(player.server); + if (!player.isCreativeLevelTwoOp() && !manager.placements().isEmpty() + && player.getVehicle() == null) { + // anyone may build in creative, otherwise it is an op tool + player.sendMessage(Text.translatable("message.fabricracers.wand_needs_op") + .formatted(Formatting.RED), true); + return ActionResult.FAIL; + } + BlockPos anchor = player.isSneaking() && !manager.placements().isEmpty() + ? player.getBlockPos() : pos.offset(player.getHorizontalFacing()); + manager.markWandAnchor(player, anchor); + ModNetworking.sendTrackList(player, manager, anchor); + return ActionResult.CONSUME; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType type) { + tooltip.add(Text.translatable("item.fabricracers.track_wand.desc").formatted(Formatting.GRAY)); + tooltip.add(Text.translatable("item.fabricracers.track_wand.desc2").formatted(Formatting.DARK_GRAY)); + } + } + + /** Lap stopwatch (client side timer). */ + public static class Stopwatch extends Item { + public Stopwatch(Settings settings) { + super(settings); + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + if (world.isClient) { + Stopwatch.items().toggle(); + return ActionResult.SUCCESS; + } + return ActionResult.CONSUME; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType type) { + tooltip.add(Text.translatable("item.fabricracers.stopwatch.desc").formatted(Formatting.GRAY)); + } + } + + /** Bragging rights; printing your racing record. */ + public static class Trophy extends Item { + private final String place; + + public Trophy(String place, Settings settings) { + super(settings); + this.place = place; + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + if (world.isClient) { + return ActionResult.SUCCESS; + } + if (user instanceof ServerPlayerEntity player) { + StatsManager.get(player.server).sendRecord(player); + } + return ActionResult.CONSUME; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType type) { + tooltip.add(Text.translatable("item.fabricracers.trophy_" + place + ".desc") + .formatted(Formatting.GRAY)); + } + } + + /** One upgrade part, consumed by the garage. */ + public static class TunePart extends Item { + private final Tune tune; + + public TunePart(Tune tune, Settings settings) { + super(settings); + this.tune = tune; + } + + public Tune tune() { + return tune; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType type) { + tooltip.add(Text.translatable("tooltip.fabricracers.tune_part", tune.description) + .formatted(Formatting.GRAY)); + tooltip.add(Text.translatable("tooltip.fabricracers.tune_part_use") + .formatted(Formatting.DARK_GRAY)); + } + } + + /** Race entry token: right click to see the state of the local race. */ + public static class RacePass extends Item { + public RacePass(Settings settings) { + super(settings); + } + + @Override + public ActionResult use(World world, PlayerEntity user, Hand hand) { + if (world.isClient) { + return ActionResult.SUCCESS; + } + if (user instanceof ServerPlayerEntity player) { + var session = dev.qynl.racers.race.RaceManager.get(player.server).sessionFor(player); + if (session == null) { + player.sendMessage(Text.translatable("race.fabricracers.pass_free") + .formatted(Formatting.GRAY), true); + } else { + player.sendMessage(Text.literal(session.standingsText()).formatted(Formatting.WHITE), + false); + } + } + return ActionResult.CONSUME; + } + + @Override + public void appendTooltip(ItemStack stack, TooltipContext context, List tooltip, TooltipType type) { + tooltip.add(Text.translatable("item.fabricracers.race_pass.desc").formatted(Formatting.GRAY)); + } + } +} diff --git a/src/main/java/dev/qynl/racers/net/ModNetworking.java b/src/main/java/dev/qynl/racers/net/ModNetworking.java new file mode 100644 index 0000000..b511020 --- /dev/null +++ b/src/main/java/dev/qynl/racers/net/ModNetworking.java @@ -0,0 +1,289 @@ +package dev.qynl.racers.net; + +import java.util.List; + +import com.mojang.serialization.Codec; +import com.mojang.serialization.codecs.RecordCodecBuilder; + +import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; +import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtList; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.util.math.BlockPos; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.race.RaceManager; +import dev.qynl.racers.race.RaceSession; +import dev.qynl.racers.track.Track; +import dev.qynl.racers.track.TrackManager; +import dev.qynl.racers.track.TrackPlacement; + +/** + * All packets in one place. + * + *

Four payloads cover everything: car controls (20/s while driving), a + * generic client->server action envelope, a generic server->client answer, and + * the race HUD snapshot. + */ +public final class ModNetworking { + // --- actions ----------------------------------------------------------- + public static final int ACT_TRACK_LIST = 1; + public static final int ACT_PLACEMENTS = 2; + public static final int ACT_GARAGE = 3; + public static final int ACT_NOTIFY = 4; + public static final int ACT_PLACE_RESULT = 5; + public static final int ACT_PROFILE = 6; + public static final int ACT_RACE_SETUP = 7; + public static final int ACT_RECORD_STATE = 8; + public static final int ACT_CAR_LIST = 9; + + // --- client requests --------------------------------------------------- + public static final int REQ_GARAGE = 1; + public static final int REQ_TUNE = 2; + public static final int REQ_PAINT = 3; + public static final int REQ_AI_TOGGLE = 4; + public static final int REQ_PLACE_TRACK = 5; + public static final int REQ_REMOVE_TRACK = 6; + public static final int REQ_HOST_RACE = 7; + public static final int REQ_JOIN_RACE = 8; + public static final int REQ_LEAVE_RACE = 9; + public static final int REQ_STATS = 10; + public static final int REQ_RECORD_TRACK = 11; + public static final int REQ_SPECTATE = 12; + public static final int REQ_REPAIR = 13; + + private ModNetworking() { + } + + // ------------------------------------------------------------------ + // payload records + // ------------------------------------------------------------------ + /** Driver input, sent every tick while a player is behind the wheel. */ + public record CarControl(int entityId, int mask) implements net.minecraft.network.packet.CustomPayload { + public static final Id ID = new Id<>(FabricRacers.id("car_control")); + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("entity").forGetter(CarControl::entityId), + Codec.INT.fieldOf("mask").forGetter(CarControl::mask) + ).apply(instance, CarControl::new)); + + @Override + public Id getId() { + return ID; + } + } + + /** Generic client request envelope. */ + public record ClientAction(int action, int entityId, int a, int b, String text) + implements net.minecraft.network.packet.CustomPayload { + public static final Id ID = new Id<>(FabricRacers.id("client_action")); + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("action").forGetter(ClientAction::action), + Codec.INT.fieldOf("entity").forGetter(ClientAction::entityId), + Codec.INT.fieldOf("a").forGetter(ClientAction::a), + Codec.INT.fieldOf("b").forGetter(ClientAction::b), + Codec.STRING.fieldOf("text").forGetter(ClientAction::text) + ).apply(instance, ClientAction::new)); + + @Override + public Id getId() { + return ID; + } + + public static ClientAction of(int action, int entityId, int a, int b) { + return new ClientAction(action, entityId, a, b, ""); + } + } + + /** Generic server answer: a code, four numbers, a message and a data blob. */ + public record ServerAction(int action, int entityId, int a, int b, float f, String text, + NbtCompound data) + implements net.minecraft.network.packet.CustomPayload { + public static final Id ID = new Id<>(FabricRacers.id("server_action")); + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("action").forGetter(ServerAction::action), + Codec.INT.fieldOf("entity").forGetter(ServerAction::entityId), + Codec.INT.fieldOf("a").forGetter(ServerAction::a), + Codec.INT.fieldOf("b").forGetter(ServerAction::b), + Codec.FLOAT.fieldOf("f").forGetter(ServerAction::f), + Codec.STRING.fieldOf("text").forGetter(ServerAction::text), + NbtCompound.CODEC.fieldOf("data").forGetter(ServerAction::data) + ).apply(instance, ServerAction::new)); + + @Override + public Id getId() { + return ID; + } + + public static ServerAction of(int action, int entityId, int a, int b, int f, String text) { + return new ServerAction(action, entityId, a, b, f, text, new NbtCompound()); + } + } + + /** The race HUD: everything the overlay needs for one player, 5x per second. */ + public record Hud(int state, int countdown, int lap, int laps, int position, int total, + int timeMs, int lapTimeMs, int bestLapMs, int speedKmh, double boost, + int driftScore, int gapAheadMs, int gapBehindMs, int flags, + String trackName, String message, String standings, String carName) + implements net.minecraft.network.packet.CustomPayload { + public static final Id ID = new Id<>(FabricRacers.id("hud")); + public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( + Codec.INT.fieldOf("state").forGetter(Hud::state), + Codec.INT.fieldOf("countdown").forGetter(Hud::countdown), + Codec.INT.fieldOf("lap").forGetter(Hud::lap), + Codec.INT.fieldOf("laps").forGetter(Hud::laps), + Codec.INT.fieldOf("position").forGetter(Hud::position), + Codec.INT.fieldOf("total").forGetter(Hud::total), + Codec.INT.fieldOf("timeMs").forGetter(Hud::timeMs), + Codec.INT.fieldOf("lapTimeMs").forGetter(Hud::lapTimeMs), + Codec.INT.fieldOf("bestLapMs").forGetter(Hud::bestLapMs), + Codec.INT.fieldOf("speed").forGetter(Hud::speedKmh), + Codec.DOUBLE.fieldOf("boost").forGetter(Hud::boost), + Codec.INT.fieldOf("drift").forGetter(Hud::driftScore), + Codec.INT.fieldOf("gapAhead").forGetter(Hud::gapAheadMs), + Codec.INT.fieldOf("gapBehind").forGetter(Hud::gapBehindMs), + Codec.INT.fieldOf("flags").forGetter(Hud::flags), + Codec.STRING.fieldOf("track").forGetter(Hud::trackName), + Codec.STRING.fieldOf("message").forGetter(Hud::message), + Codec.STRING.fieldOf("standings").forGetter(Hud::standings), + Codec.STRING.fieldOf("car").forGetter(Hud::carName) + ).apply(instance, Hud::new)); + + @Override + public Id getId() { + return ID; + } + + public static final int FLAG_DRIFTING = 1; + public static final int FLAG_BOOSTING = 2; + public static final int FLAG_OFF_TRACK = 4; + public static final int FLAG_DAMAGED = 8; + public static final int FLAG_FINISHED = 16; + + public boolean flag(int flag) { + return (flags & flag) != 0; + } + } + + // ------------------------------------------------------------------ + // registration + // ------------------------------------------------------------------ + public static void init() { + PayloadTypeRegistry.playC2S().register(CarControl.ID, CarControl.CODEC); + PayloadTypeRegistry.playC2S().register(ClientAction.ID, ClientAction.CODEC); + PayloadTypeRegistry.playS2C().register(ServerAction.ID, ServerAction.CODEC); + PayloadTypeRegistry.playS2C().register(Hud.ID, Hud.CODEC); + + ServerPlayNetworking.registerGlobalReceiver(CarControl.ID, (payload, context) -> { + ServerPlayerEntity player = context.player(); + var world = player.getServerWorld(); + var entity = world.getEntityById(payload.entityId()); + if (entity instanceof CarEntity car && player.getVehicle() == car) { + car.setDriverInput(dev.qynl.racers.car.CarControls.fromMask((byte) payload.mask())); + } + }); + + ServerPlayNetworking.registerGlobalReceiver(ClientAction.ID, (payload, context) -> + ServerActions.handle(context.player(), payload)); + } + + // ------------------------------------------------------------------ + // server -> client helpers + // ------------------------------------------------------------------ + public static void send(ServerPlayerEntity player, ServerAction action) { + ServerPlayNetworking.send(player, action); + } + + public static void notify(ServerPlayerEntity player, String message) { + send(player, ServerAction.of(ACT_NOTIFY, 0, 0, 0, 0, message)); + } + + public static void sendHud(ServerPlayerEntity player, Hud hud) { + ServerPlayNetworking.send(player, hud); + } + + /** Sends the list of tracks the player may build (for the track wand screen). */ + public static void sendTrackList(ServerPlayerEntity player, TrackManager manager, BlockPos anchor) { + NbtCompound data = new NbtCompound(); + NbtList list = new NbtList(); + for (Track track : manager.all()) { + NbtCompound entry = new NbtCompound(); + entry.putString("id", track.id); + entry.putString("name", track.name); + entry.putInt("sx", track.size[0]); + entry.putInt("sy", track.size[1]); + entry.putInt("sz", track.size[2]); + entry.putInt("cps", track.checkpointCount()); + entry.putInt("laps", track.defaultLaps); + entry.putInt("width", track.width); + entry.putBoolean("stock", track.stock); + list.add(entry); + } + data.put("tracks", list); + NbtList anchorList = new NbtList(); + anchorList.add(net.minecraft.nbt.NbtInt.of(anchor.getX())); + anchorList.add(net.minecraft.nbt.NbtInt.of(anchor.getY())); + anchorList.add(net.minecraft.nbt.NbtInt.of(anchor.getZ())); + data.put("anchor", anchorList); + data.putInt("rotation", manager.suggestedRotation(player)); + send(player, new ServerAction(ACT_TRACK_LIST, 0, 0, 0, 0.0F, "", data)); + } + + /** Sends every placed circuit so the client can draw them + show a map. */ + public static void sendPlacements(ServerPlayerEntity player, TrackManager manager) { + NbtCompound data = new NbtCompound(); + NbtList list = new NbtList(); + for (TrackPlacement placement : manager.placements()) { + NbtCompound entry = new NbtCompound(); + entry.putString("track", placement.track.id); + entry.putInt("x", placement.anchor.getX()); + entry.putInt("y", placement.anchor.getY()); + entry.putInt("z", placement.anchor.getZ()); + entry.putInt("rot", placement.rotation); + list.add(entry); + } + data.put("list", list); + send(player, new ServerAction(ACT_PLACEMENTS, 0, 0, 0, 0.0F, "", data)); + } + + /** Sends the car's tuning sheet to the garage screen. */ + public static void sendGarage(ServerPlayerEntity player, CarEntity car, NbtCompound extra) { + NbtCompound data = new NbtCompound(); + data.put("car", car.data().toNbt()); + data.putInt("health", (int) (car.healthFraction() * 100.0F)); + data.putString("driver", car.driverName()); + data.putBoolean("ai", car.isAiDriving()); + if (extra != null) { + data.put("extra", extra); + } + send(player, new ServerAction(ACT_GARAGE, car.getId(), 0, 0, 0.0F, "", data)); + } + + /** Sends the player's racing record. */ + public static void sendProfile(ServerPlayerEntity player, NbtCompound profile) { + send(player, new ServerAction(ACT_PROFILE, 0, 0, 0, 0.0F, "", profile)); + } + + /** Tells every player in the race something (chat + HUD banner). */ + public static void broadcastRace(MinecraftServer server, RaceSession session, String message) { + for (ServerPlayerEntity player : session.viewers()) { + notify(player, message); + } + } + + public static void sendRaceSetup(ServerPlayerEntity player, NbtCompound data) { + send(player, new ServerAction(ACT_RACE_SETUP, 0, 0, 0, 0.0F, "", data)); + } + + public static void sendRecordState(ServerPlayerEntity player, int state, String name) { + send(player, ServerAction.of(ACT_RECORD_STATE, 0, state, 0, 0, name)); + } + + /** Convenience used by commands that run on a server without any player. */ + public static List players(MinecraftServer server) { + return server.getPlayerManager().getPlayerList(); + } +} diff --git a/src/main/java/dev/qynl/racers/net/ServerActions.java b/src/main/java/dev/qynl/racers/net/ServerActions.java new file mode 100644 index 0000000..cbf16da --- /dev/null +++ b/src/main/java/dev/qynl/racers/net/ServerActions.java @@ -0,0 +1,342 @@ +package dev.qynl.racers.net; + +import net.minecraft.nbt.NbtCompound; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.car.PaintColors; +import dev.qynl.racers.car.Tune; +import dev.qynl.racers.race.RaceEntry; +import dev.qynl.racers.race.RaceFormat; +import dev.qynl.racers.race.RaceManager; +import dev.qynl.racers.race.RaceSession; +import dev.qynl.racers.shop.PartShop; +import dev.qynl.racers.stats.StatsManager; +import dev.qynl.racers.track.Track; +import dev.qynl.racers.track.TrackManager; +import dev.qynl.racers.track.TrackPlacement; +import dev.qynl.racers.util.Cars; + +/** Handles every button press and menu action coming from a client. */ +public final class ServerActions { + private ServerActions() { + } + + public static void handle(ServerPlayerEntity player, ModNetworking.ClientAction action) { + switch (action.action()) { + case ModNetworking.REQ_GARAGE -> openGarage(player, action.entityId()); + case ModNetworking.REQ_TUNE -> tune(player, action.entityId(), action.a()); + case ModNetworking.REQ_PAINT -> paint(player, action.entityId(), action.a(), action.b()); + case ModNetworking.REQ_AI_TOGGLE -> toggleAi(player, action.entityId(), action.a()); + case ModNetworking.REQ_PLACE_TRACK -> placeTrack(player, action.text(), action.a(), action.b()); + case ModNetworking.REQ_REMOVE_TRACK -> removeTrack(player, action.a(), action.b(), action.text()); + case ModNetworking.REQ_HOST_RACE -> hostRace(player, action); + case ModNetworking.REQ_JOIN_RACE -> joinRace(player); + case ModNetworking.REQ_LEAVE_RACE -> leaveRace(player); + case ModNetworking.REQ_STATS -> StatsManager.get(player.server).sendRecord(player); + case ModNetworking.REQ_RECORD_TRACK -> toggleRecording(player); + case ModNetworking.REQ_SPECTATE -> spectate(player); + case ModNetworking.REQ_REPAIR -> repair(player); + default -> { + } + } + } + + private static CarEntity carOf(ServerPlayerEntity player, int entityId) { + if (player.getServerWorld().getEntityById(entityId) instanceof CarEntity car) { + return car; + } + if (player.getVehicle() instanceof CarEntity car) { + return car; + } + return Cars.carNear(player.getServerWorld(), player.getPos(), 4.0); + } + + private static void openGarage(ServerPlayerEntity player, int entityId) { + CarEntity car = carOf(player, entityId); + if (car == null) { + ModNetworking.notify(player, "No car nearby to tune."); + return; + } + NbtCompound extra = new NbtCompound(); + for (Tune tune : Tune.values()) { + extra.putInt(tune.id, PartShop.costFor(tune, car.data().level(tune) + 1)); + extra.putInt(tune.id + "_owned", countParts(player, + dev.qynl.racers.registry.ModItems.part(tune))); + } + ModNetworking.sendGarage(player, car, extra); + } + + private static void tune(ServerPlayerEntity player, int entityId, int tuneIndex) { + CarEntity car = carOf(player, entityId); + if (car == null || tuneIndex < 0 || tuneIndex >= Tune.values().length) { + return; + } + if (!car.data().ownedBy(player)) { + ModNetworking.notify(player, "That is not your car."); + return; + } + Tune tune = Tune.values()[tuneIndex]; + int level = car.data().level(tune); + if (level >= tune.maxLevel) { + ModNetworking.notify(player, tune.displayName + " is already maxed out."); + return; + } + int cost = PartShop.costFor(tune, level + 1); + if (player.isCreative() || countParts(player, dev.qynl.racers.registry.ModItems.part(tune)) >= cost) { + if (!player.isCreative()) { + consumeParts(player, dev.qynl.racers.registry.ModItems.part(tune), cost); + } + int[] levels = car.levels(); + levels[tune.ordinal()] = level + 1; + car.setLevels(levels); + car.resetHealth(); + player.sendMessage(Text.translatable("message.fabricracers.tuned", + tune.displayName, level + 1, tune.maxLevel).formatted(Formatting.GREEN), true); + openGarage(player, car.getId()); + } else { + ModNetworking.notify(player, "You need " + cost + "x " + tune.displayName + + " part (you have " + countParts(player, + dev.qynl.racers.registry.ModItems.part(tune)) + ")."); + } + } + + private static void paint(ServerPlayerEntity player, int entityId, int slot, int colorIndex) { + CarEntity car = carOf(player, entityId); + if (car == null || !car.data().ownedBy(player)) { + return; + } + int color = PaintColors.named().containsValue(colorIndex) ? colorIndex + : PaintColors.next(slot == 0 ? car.data().primaryColor : car.data().secondaryColor, 1); + if (slot == 0) { + car.setPaint(color, car.data().secondaryColor); + } else { + car.setPaint(car.data().primaryColor, color); + } + openGarage(player, car.getId()); + } + + private static void toggleAi(ServerPlayerEntity player, int entityId, int value) { + CarEntity car = carOf(player, entityId); + if (car == null || !car.data().ownedBy(player)) { + return; + } + if (car.isRidden() && car.getDriver() != player) { + ModNetworking.notify(player, "Someone is driving that car."); + return; + } + boolean want = value == 1; + car.setAiDriving(want); + car.setAiSkill(want ? Math.max(car.aiSkill(), 0.7) : car.aiSkill()); + ModNetworking.notify(player, want ? "AI driver: engaged." : "AI driver: off."); + openGarage(player, car.getId()); + } + + private static void placeTrack(ServerPlayerEntity player, String trackId, int rotation, int mode) { + TrackManager manager = TrackManager.get(player.server); + Track track = manager.track(trackId); + if (track == null) { + ModNetworking.notify(player, "Unknown track: " + trackId); + return; + } + BlockPos anchor = manager.wandAnchor(player); + if (anchor == null) { + anchor = player.getBlockPos(); + } + if (mode == 1) { + anchor = anchor.offset(player.getHorizontalFacing()); + } + manager.placeTrack(player, track, anchor, rotation); + } + + private static void removeTrack(ServerPlayerEntity player, int x, int y, int z, String text) { + TrackManager manager = TrackManager.get(player.server); + BlockPos pos = new BlockPos(x, y, z); + TrackPlacement placement = manager.placementAt(pos); + if (placement == null) { + placement = manager.placementNear(player.getPos(), 200.0); + } + if (placement == null) { + ModNetworking.notify(player, "No placed track nearby."); + return; + } + for (RaceSession session : RaceManager.get(player.server).sessions()) { + if (session.placement() == placement) { + ModNetworking.notify(player, "A race is running on that track."); + return; + } + } + manager.removePlacement(player, placement, true); + } + + private static void hostRace(ServerPlayerEntity player, ModNetworking.ClientAction action) { + RaceManager races = RaceManager.get(player.server); + TrackManager tracks = TrackManager.get(player.server); + if (races.sessionFor(player) != null) { + ModNetworking.notify(player, "You are already in a race. /race leave first."); + return; + } + if (!(player.getVehicle() instanceof CarEntity car)) { + ModNetworking.notify(player, "Get in a car first, then start the race."); + return; + } + TrackPlacement placement = tracks.placementNear(car.getPos(), 140.0); + if (placement == null) { + ModNetworking.notify(player, "Drive closer to a circuit (or place one with the wand)."); + return; + } + RaceFormat format = RaceFormat.of(action.a(), action.b() & 0xFF, + Math.max(0.2, (action.b() >> 8) / 100.0), true); + format.name = "Grand Prix"; + races.start(player, placement, format, format.aiOpponents, format.aiSkill); + } + + private static void joinRace(ServerPlayerEntity player) { + RaceManager races = RaceManager.get(player.server); + if (races.sessionFor(player) != null) { + return; + } + if (!(player.getVehicle() instanceof CarEntity car)) { + ModNetworking.notify(player, "Get in a car first."); + return; + } + RaceSession best = null; + double bestDistance = 120.0; + for (RaceSession session : races.sessions()) { + if (!session.isLive() || session.state != RaceSession.State.COUNTDOWN) { + continue; + } + double distance = session.placement().distanceTo(car.getPos()); + if (distance < bestDistance) { + bestDistance = distance; + best = session; + } + } + if (best == null) { + ModNetworking.notify(player, "No race is on the grid near you."); + return; + } + RaceEntry entry = new RaceEntry(car, player.getUuid(), player.getName().getString(), false, 0.0); + int slot = best.entries().size(); + Vec3d pos = best.placement().gridPos(slot); + car.teleportCleanly(pos.x, pos.y, pos.z, best.placement().startYaw()); + car.setControlLocked(true); + car.setRaceState(1); + best.addEntry(entry); + best.announce(player.getName().getString() + " joined the grid"); + StatsManager.get(player.server).get(player).racesStarted++; + StatsManager.get(player.server).markDirty(); + } + + private static void leaveRace(ServerPlayerEntity player) { + RaceManager races = RaceManager.get(player.server); + RaceSession session = races.sessionFor(player); + if (session == null) { + ModNetworking.notify(player, "You are not in a race."); + return; + } + RaceEntry entry = session.entryFor(player); + if (entry != null && !entry.finished) { + entry.finished = true; + entry.finishMs = System.currentTimeMillis() - session.raceStartMs; + StatsManager.get(player.server).recordWreck(player); + } + player.stopRiding(); + if (player.getVehicle() instanceof CarEntity car) { + car.setAiDriving(true); + } + ModNetworking.notify(player, "You left the race."); + } + + private static void toggleRecording(ServerPlayerEntity player) { + TrackManager manager = TrackManager.get(player.server); + TrackRecorder recorder = manager.recorder(player); + if (recorder.samples() > 0 && !recorder.isFinished()) { + recorder.stop(); + return; + } + if (recorder.samples() > 0) { + manager.stopRecording(player); + manager.recorder(player); + } + ModNetworking.sendRecordState(player, 1, "0 samples"); + ModNetworking.notify(player, "Recording. Drive a lap, then use the wand again."); + } + + private static void spectate(ServerPlayerEntity player) { + RaceManager races = RaceManager.get(player.server); + RaceSession best = null; + double bestDistance = 200.0; + for (RaceSession session : races.sessions()) { + if (!session.isLive()) { + continue; + } + double distance = session.placement().distanceTo(player.getPos()); + if (distance < bestDistance) { + bestDistance = distance; + best = session; + } + } + if (best == null) { + ModNetworking.notify(player, "No live race nearby."); + return; + } + best.addSpectator(player); + ModNetworking.notify(player, "Spectating " + best.placement().track.name); + } + + private static void repair(ServerPlayerEntity player) { + if (player.getVehicle() instanceof CarEntity car) { + car.resetHealth(); + player.sendMessage(Text.translatable("message.fabricracers.repaired") + .formatted(Formatting.GREEN), true); + } + } + + private static int countParts(ServerPlayerEntity player, net.minecraft.item.Item item) { + int count = 0; + for (int i = 0; i < player.getInventory().size(); i++) { + var stack = player.getInventory().getStack(i); + if (stack.getItem() == item) { + count += stack.getCount(); + } + } + return count; + } + + private static void consumeParts(ServerPlayerEntity player, net.minecraft.item.Item item, int amount) { + int remaining = amount; + for (int i = 0; i < player.getInventory().size() && remaining > 0; i++) { + var stack = player.getInventory().getStack(i); + if (stack.getItem() == item) { + int take = Math.min(remaining, stack.getCount()); + stack.decrement(take); + remaining -= take; + } + } + } + + /** Used by commands: spawn a car for a player next to themselves. */ + public static CarEntity spawnCarFor(ServerPlayerEntity player, CarData data) { + var world = player.getServerWorld(); + Vec3d look = Vec3d.fromPolar(0.0F, player.getYaw()).multiply(3.0); + Vec3d spawn = player.getPos().add(look); + double ground = Cars.groundHeight(world, spawn); + if (Double.isNaN(ground)) { + return null; + } + dev.qynl.racers.car.PlayerCarEntity car = new dev.qynl.racers.car.PlayerCarEntity( + dev.qynl.racers.registry.ModEntities.PLAYER_CAR, world); + car.initFromData(data); + car.refreshPositionAndAngles(spawn.x, ground, spawn.z, player.getYaw(), 0.0F); + if (!world.spawnEntity(car)) { + return null; + } + return car; + } +} diff --git a/src/main/java/dev/qynl/racers/race/RaceEntry.java b/src/main/java/dev/qynl/racers/race/RaceEntry.java new file mode 100644 index 0000000..3494fd9 --- /dev/null +++ b/src/main/java/dev/qynl/racers/race/RaceEntry.java @@ -0,0 +1,65 @@ +package dev.qynl.racers.race; + +import java.util.UUID; + +import net.minecraft.server.network.ServerPlayerEntity; + +import dev.qynl.racers.car.CarEntity; + +/** One car in a race: a player entry, an AI entry, or both (AI takeover after finishing). */ +public final class RaceEntry { + public final CarEntity car; + public final UUID playerId; + public final String name; + public final boolean ai; + public double skill; + + public int lap = 1; + public int nextCheckpoint; + public long lapStartMs; + public long bestLapMs; + public long lastLapMs; + public long totalMs; + public long finishMs = -1L; + public boolean finished; + public int finishPosition; + public int position; + public double progress; + public boolean aiTakeover; + /** Place in the live standings as of the previous check, used to count overtakes. */ + public int lastPosition; + public int offTrackTicks; + public int pitStops; + public int overtakes; + + public RaceEntry(CarEntity car, UUID playerId, String name, boolean ai, double skill) { + this.car = car; + this.playerId = playerId; + this.name = name; + this.ai = ai; + this.skill = skill; + this.lapStartMs = System.currentTimeMillis(); + } + + public boolean isPlayerEntry() { + return playerId != null; + } + + public ServerPlayerEntity player() { + if (playerId == null || car.getServer() == null) { + return null; + } + return car.getServer().getPlayerManager().getPlayer(playerId); + } + + public String label() { + return name + (ai ? " (AI)" : ""); + } + + public String timeLabel() { + if (finishMs >= 0) { + return dev.qynl.racers.util.Stopwatch.format(finishMs); + } + return "L" + Math.min(lap, 99); + } +} diff --git a/src/main/java/dev/qynl/racers/race/RaceFormat.java b/src/main/java/dev/qynl/racers/race/RaceFormat.java new file mode 100644 index 0000000..ee3ce05 --- /dev/null +++ b/src/main/java/dev/qynl/racers/race/RaceFormat.java @@ -0,0 +1,49 @@ +package dev.qynl.racers.race; + +import net.minecraft.util.math.MathHelper; + +/** The rules of one race. */ +public final class RaceFormat { + public int laps = 3; + public int aiOpponents; + /** 0.35 = relaxed, 0.72 = club racer, 1.0 = alien. */ + public double aiSkill = 0.72; + public boolean damage = true; + public boolean boostPads = true; + public boolean rubberBand = true; + /** Grid slots taken by the AI rather than by players. */ + public boolean reverseGrid; + public int countdownTicks = 100; + public int prizeEmeralds = 6; + public String name = "Grand Prix"; + + public static RaceFormat of(int laps, int aiOpponents, double aiSkill, boolean damage) { + RaceFormat format = new RaceFormat(); + format.laps = MathHelper.clamp(laps, 1, 20); + format.aiOpponents = MathHelper.clamp(aiOpponents, 0, 19); + format.aiSkill = MathHelper.clamp(aiSkill, 0.2, 1.0); + format.damage = damage; + return format; + } + + public String skillName() { + if (aiSkill >= 0.95) { + return "Alien"; + } + if (aiSkill >= 0.8) { + return "Pro"; + } + if (aiSkill >= 0.62) { + return "Club"; + } + if (aiSkill >= 0.45) { + return "Rookie"; + } + return "Sunday driver"; + } + + public String describe() { + return name + ": " + laps + " laps, " + aiOpponents + " AI (" + skillName() + + "), damage " + (damage ? "on" : "off") + (rubberBand ? ", rubber band on" : ""); + } +} diff --git a/src/main/java/dev/qynl/racers/race/RaceManager.java b/src/main/java/dev/qynl/racers/race/RaceManager.java new file mode 100644 index 0000000..a034a5a --- /dev/null +++ b/src/main/java/dev/qynl/racers/race/RaceManager.java @@ -0,0 +1,264 @@ +package dev.qynl.racers.race; + +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.car.AiCarEntity; +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.car.PlayerCarEntity; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.registry.ModEntities; +import dev.qynl.racers.stats.StatsManager; +import dev.qynl.racers.track.TrackManager; +import dev.qynl.racers.track.TrackPlacement; +import dev.qynl.racers.util.Cars; + +/** + * The race engine's front door: creates sessions, spawns the AI, feeds the HUD + * and cleans up when players leave. + */ +public final class RaceManager { + private static final Map INSTANCES = new HashMap<>(); + + private final MinecraftServer server; + private final List sessions = new ArrayList<>(); + private final Map lastHud = new HashMap<>(); + private int spawnCounter; + + private RaceManager(MinecraftServer server) { + this.server = server; + } + + public static RaceManager get(MinecraftServer server) { + if (server == null) { + return null; + } + return INSTANCES.computeIfAbsent(server, RaceManager::new); + } + + public static void forget(MinecraftServer server) { + INSTANCES.remove(server); + } + + public List sessions() { + return sessions; + } + + // ------------------------------------------------------------------ + // lookups + // ------------------------------------------------------------------ + public RaceSession sessionFor(CarEntity car) { + for (RaceSession session : sessions) { + if (session.isLive() && session.entryFor(car) != null) { + return session; + } + } + return null; + } + + public RaceEntry entryFor(CarEntity car) { + RaceSession session = sessionFor(car); + return session == null ? null : session.entryFor(car); + } + + public RaceSession sessionFor(PlayerEntity player) { + for (RaceSession session : sessions) { + if (session.isLive() && session.contains(player)) { + return session; + } + } + return null; + } + + public boolean isRacing(PlayerEntity player) { + return sessionFor(player) != null; + } + + // ------------------------------------------------------------------ + // lifecycle + // ------------------------------------------------------------------ + public RaceSession start(ServerPlayerEntity host, TrackPlacement placement, RaceFormat format, + int aiCount, double aiSkill) { + RaceSession session = new RaceSession(server, placement, format); + List cars = new ArrayList<>(); + + // players who are sitting in a car near the track, host first + for (ServerPlayerEntity player : ModNetworking.players(server)) { + if (!(player.getVehicle() instanceof CarEntity car)) { + continue; + } + if (player.getUuid().equals(host.getUuid())) { + continue; + } + if (placement.distanceTo(car.getPos()) > 220.0) { + continue; + } + cars.add(car); + } + if (!(host.getVehicle() instanceof CarEntity hostCar)) { + host.sendMessage(Text.translatable("race.fabricracers.need_car") + .formatted(Formatting.RED), false); + return null; + } + cars.add(0, hostCar); + + // spawn the AI cars + int spawned = 0; + for (int i = 0; i < MathHelper.clamp(aiCount, 0, 15); i++) { + AiCarEntity ai = spawnAiCar(placement, format, aiSkill + (i % 3) * 0.02, hostCar); + if (ai == null) { + break; + } + float yaw = placement.startYaw(); + Vec3d pos = placement.gridPos(cars.size() + spawned); + ai.teleportCleanly(pos.x, pos.y, pos.z, yaw); + cars.add(ai); + spawned++; + } + + session.buildGrid(cars); + if (session.entries().isEmpty()) { + host.sendMessage(Text.translatable("race.fabricracers.need_car").formatted(Formatting.RED), + false); + return null; + } + for (RaceEntry entry : session.entries()) { + ServerPlayerEntity player = entry.player(); + if (player != null) { + StatsManager.get(server).get(player).racesStarted++; + StatsManager.get(server).markDirty(); + } + } + sessions.add(session); + session.announce("Grid set: " + session.entries().size() + " cars, " + + format.laps + " laps of " + placement.track.name); + FabricRacers.LOGGER.info("Race started: {}", session.describe()); + return session; + } + + private AiCarEntity spawnAiCar(TrackPlacement placement, RaceFormat format, double skill, + CarEntity template) { + var world = (net.minecraft.server.world.ServerWorld) template.getWorld(); + AiCarEntity car = new AiCarEntity(ModEntities.AI_CAR, world); + CarData data = template.data() == null + ? CarData.fresh(template.carType(), null) + : new CarData(template.carType(), palettise(spawnCounter++), template.data().secondaryColor, + template.data().accentColor, balancedLevels(template, format), + 1.0F, 1.0F, null, "FabricRacers AI", true); + car.initFromData(data); + car.setAiSkill(skill); + car.setAiRubberBand(format.rubberBand); + car.setAiDriving(true); + Vec3d pos = placement.gridPos(0); + car.refreshPositionAndAngles(pos.x, pos.y, pos.z, placement.startYaw(), 0.0F); + if (!world.spawnEntity(car)) { + return null; + } + return car; + } + + /** AI cars are tuned to roughly match the player's car, so races stay fair. */ + private int[] balancedLevels(CarEntity template, RaceFormat format) { + int[] levels = new int[dev.qynl.racers.car.Tune.values().length]; + int target = MathHelper.clamp((int) Math.round(format.aiSkill * 6.0), 0, 12); + int[] order = {0, 3, 5, 1, 2, 4, 6}; + for (int i = 0; i < target; i++) { + int tuneIndex = order[i % order.length]; + levels[tuneIndex] = Math.min(dev.qynl.racers.car.Tune.values()[tuneIndex].maxLevel, + levels[tuneIndex] + 1); + } + return levels; + } + + private int palettise(int index) { + int[] colors = {0xDC3B36, 0x2C5CC4, 0xF2C42A, 0x4FD05A, 0xF07A28, 0x8C3EBE, + 0x3CC8DC, 0xF090B8, 0xC8C8CE, 0x189454}; + return colors[Math.floorMod(index, colors.length)]; + } + + public void stop(RaceSession session, String reason) { + for (RaceEntry entry : session.entries()) { + entry.car.setControlLocked(false); + entry.car.setRaceState(0); + if (entry.car instanceof AiCarEntity ai) { + ai.discard(); + } + } + sessions.remove(session); + if (reason != null && !reason.isEmpty()) { + session.announce(reason); + } + } + + public void shutdown() { + for (RaceSession session : new ArrayList<>(sessions)) { + stop(session, null); + } + sessions.clear(); + } + + public void onJoin(ServerPlayerEntity player) { + ModNetworking.sendPlacements(player, TrackManager.get(server)); + } + + public void onDisconnect(ServerPlayerEntity player) { + lastHud.remove(player.getUuid()); + for (RaceSession session : new ArrayList<>(sessions)) { + RaceEntry entry = session.entryFor(player); + if (entry != null && !entry.finished) { + entry.finished = true; + entry.finishMs = System.currentTimeMillis() - session.raceStartMs; + StatsManager.get(server).recordWreck(player); + session.announce(entry.label() + " left the race"); + } + } + } + + // ------------------------------------------------------------------ + // per tick + // ------------------------------------------------------------------ + public void tick() { + for (RaceSession session : new ArrayList<>(sessions)) { + session.tick(); + if (session.state == RaceSession.State.FINISHED && session.finishedTicks() > 240) { + stop(session, null); + } + } + long now = System.currentTimeMillis(); + for (ServerPlayerEntity player : ModNetworking.players(server)) { + long last = lastHud.getOrDefault(player.getUuid(), 0L); + if (now - last < 180L) { + continue; + } + lastHud.put(player.getUuid(), now); + if (!(player.getVehicle() instanceof CarEntity car)) { + continue; + } + RaceSession session = sessionFor(player); + if (session != null) { + ModNetworking.sendHud(player, session.hudFor(player)); + } else { + ModNetworking.sendHud(player, RaceSession.freeRoamHud(player, car)); + } + StatsManager.get(server).recordDrive(car); + } + } + + /** Clears the client HUD when a player stops driving. */ + public void clearHud(ServerPlayerEntity player) { + lastHud.remove(player.getUuid()); + } +} diff --git a/src/main/java/dev/qynl/racers/race/RaceSession.java b/src/main/java/dev/qynl/racers/race/RaceSession.java new file mode 100644 index 0000000..aea40d0 --- /dev/null +++ b/src/main/java/dev/qynl/racers/race/RaceSession.java @@ -0,0 +1,548 @@ +package dev.qynl.racers.race; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.UUID; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.particle.ParticleTypes; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.server.world.ServerWorld; +import net.minecraft.sound.SoundCategory; +import net.minecraft.sound.SoundEvents; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.MathHelper; +import net.minecraft.util.math.Vec3d; + +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.util.Sounds; +import dev.qynl.racers.registry.ModItems; +import dev.qynl.racers.stats.StatsManager; +import dev.qynl.racers.track.TrackPlacement; +import dev.qynl.racers.util.Interp; +import dev.qynl.racers.util.Stopwatch; + +/** + * The race state machine for one circuit: countdown, racing, results. + * + *

Responsibilities: ordering the grid, watching checkpoint progress, lap and + * race timing, live standings, rubber banding, pit stop bookkeeping, finishing + * rules, prizes and the HUD snapshot. + */ +public final class RaceSession { + public enum State { + COUNTDOWN, + RACING, + FINISHED + } + + private final MinecraftServer server; + private final TrackPlacement placement; + private final RaceFormat format; + private final List entries = new ArrayList<>(); + private final Set spectators = new HashSet<>(); + private final long createdMs = System.currentTimeMillis(); + public State state = State.COUNTDOWN; + public int countdown; + public long raceStartMs; + public String banner = ""; + public int bannerTicks; + public int finishedTicks; + private long lastOvertakeCheck; + private int abandoned; + + public RaceSession(MinecraftServer server, TrackPlacement placement, RaceFormat format) { + this.server = server; + this.placement = placement; + this.format = format; + this.countdown = format.countdownTicks; + this.raceStartMs = System.currentTimeMillis() + countdown * 50L; + } + + public TrackPlacement placement() { + return placement; + } + + public RaceFormat format() { + return format; + } + + public List entries() { + return entries; + } + + public RaceEntry entryFor(CarEntity car) { + for (RaceEntry entry : entries) { + if (entry.car == car) { + return entry; + } + } + return null; + } + + public RaceEntry entryFor(PlayerEntity player) { + for (RaceEntry entry : entries) { + if (player.getUuid().equals(entry.playerId)) { + return entry; + } + } + return null; + } + + public boolean contains(PlayerEntity player) { + return entryFor(player) != null || spectators.contains(player.getUuid()); + } + + public void addSpectator(PlayerEntity player) { + spectators.add(player.getUuid()); + } + + public Set viewers() { + Set viewers = new HashSet<>(); + for (RaceEntry entry : entries) { + ServerPlayerEntity player = entry.player(); + if (player != null) { + viewers.add(player); + } + } + for (UUID id : spectators) { + ServerPlayerEntity player = server.getPlayerManager().getPlayer(id); + if (player != null) { + viewers.add(player); + } + } + return viewers; + } + + /** Assigns the grid: players first, then the AI filling the remaining slots. */ + public void buildGrid(List cars) { + int slot = 0; + for (CarEntity car : cars) { + if (entries.size() >= placement.gridSlots()) { + break; + } + Vec3d pos = placement.gridPos(slot); + float yaw = placement.startYaw(); + car.teleportCleanly(pos.x, pos.y, pos.z, yaw); + car.setControlLocked(true); + car.setRaceState(1); + car.resetHealth(); + UUID playerId = car.getDriver() instanceof PlayerEntity player ? player.getUuid() : null; + String name = playerId == null ? "AI " + (entries.size() + 1) + : car.getDriver().getName().getString(); + double skill = MathHelper.clamp(format.aiSkill + (entries.size() % 3) * 0.02, 0.2, 1.0); + RaceEntry entry = new RaceEntry(car, playerId, name, playerId == null, + playerId == null ? skill : 0.0); + entry.lapStartMs = System.currentTimeMillis(); + entries.add(entry); + slot++; + } + } + + public void addEntry(RaceEntry entry) { + entries.add(entry); + } + + public int lapCount() { + return format.laps; + } + + // ------------------------------------------------------------------ + // ticking + // ------------------------------------------------------------------ + public void tick() { + long now = System.currentTimeMillis(); + if (bannerTicks > 0) { + bannerTicks--; + } + switch (state) { + case COUNTDOWN -> tickCountdown(now); + case RACING -> tickRacing(now); + case FINISHED -> tickFinished(); + default -> { + } + } + } + + private void tickCountdown(long now) { + int previous = countdown; + countdown--; + if (countdown <= 0) { + state = State.RACING; + raceStartMs = now; + banner("GO!", 40); + for (RaceEntry entry : entries) { + entry.car.setControlLocked(false); + entry.car.setAiDriving(entry.ai); + entry.lapStartMs = now; + ServerPlayerEntity player = entry.player(); + if (player != null) { + player.playSoundToPlayer(Sounds.of(SoundEvents.ENTITY_EXPERIENCE_ORB_PICKUP), + SoundCategory.NEUTRAL, 1.0F, 1.6F); + } + } + return; + } + if (countdown % 20 == 0 || previous > 100) { + int seconds = (countdown + 19) / 20; + for (ServerPlayerEntity player : viewers()) { + player.playSoundToPlayer(Sounds.of(SoundEvents.BLOCK_NOTE_BLOCK_PLING), + SoundCategory.NEUTRAL, 1.0F, seconds <= 1 ? 2.0F : 1.2F); + } + } + } + + private void tickRacing(long now) { + updateProgress(now); + for (RaceEntry entry : entries) { + if (entry.finished) { + continue; + } + CarEntity car = entry.car; + if (car.isRemoved()) { + entry.finished = true; + entry.finishMs = now - raceStartMs; + entry.finishPosition = ++abandoned; + continue; + } + if (entry.offTrackTicks > 0) { + entry.offTrackTicks--; + } + } + int finished = countFinished(); + if (finished >= entries.size()) { + finishAll(); + } else if (finished > 0 && finishedWindow(now)) { + // give everyone else a chance, then close the session + finishAll(); + } + if (now - lastOvertakeCheck > 250L) { + lastOvertakeCheck = now; + trackOvertakes(); + } + } + + private void tickFinished() { + finishedTicks++; + if (finishedTicks == 100) { + for (RaceEntry entry : entries) { + entry.car.setRaceState(0); + } + } + } + + public int finishedTicks() { + return finishedTicks; + } + + private boolean finishedWindow(long now) { + long elapsed = now - firstFinishMs(); + return elapsed > 5000L + format.laps * 15000L; + } + + private long firstFinishMs() { + for (RaceEntry entry : entries) { + if (entry.finishMs >= 0) { + return raceStartMs + entry.finishMs; + } + } + return Long.MAX_VALUE; + } + + private boolean anyFinished() { + return countFinished() > 0; + } + + private int countFinished() { + int count = 0; + for (RaceEntry entry : entries) { + if (entry.finished) { + count++; + } + } + return count; + } + + private void finishAll() { + long now = System.currentTimeMillis(); + for (RaceEntry entry : entries) { + if (!entry.finished) { + entry.finished = true; + entry.finishMs = now - raceStartMs; + entry.finishPosition = countFinished(); + } + } + state = State.FINISHED; + finishedTicks = 0; + awardPrizes(); + announceResults(); + } + + // ------------------------------------------------------------------ + // progress + // ------------------------------------------------------------------ + private void updateProgress(long now) { + int checkpoints = placement.checkpointCount(); + for (RaceEntry entry : entries) { + CarEntity car = entry.car; + if (entry.finished) { + entry.progress = 1_000_000.0 - entry.finishPosition * 10.0; + continue; + } + Vec3d target = placement.checkpointPos(entry.nextCheckpoint); + double distance = Math.hypot(car.getX() - target.x, car.getZ() - target.z); + double radius = Math.max(3.5, placement.track.halfWidth() + 2.0); + if (distance < radius) { + if (entry.nextCheckpoint == 0) { + // crossed the line + entry.lastLapMs = now - entry.lapStartMs; + if (entry.bestLapMs == 0L || entry.lastLapMs < entry.bestLapMs) { + entry.bestLapMs = entry.lastLapMs; + ServerPlayerEntity player = entry.player(); + if (player != null) { + StatsManager.get(server).recordLap(player, placement.track, + entry.bestLapMs); + } + } + entry.lapStartMs = now; + entry.lap++; + if (entry.lap > format.laps) { + entry.finished = true; + entry.finishMs = now - raceStartMs; + entry.finishPosition = countFinished(); + onFinish(entry); + continue; + } else { + ServerPlayerEntity player = entry.player(); + if (player != null) { + player.sendMessage(Text.translatable("race.fabricracers.lap", + entry.lap, format.laps, + Stopwatch.formatShort(entry.lastLapMs)) + .formatted(Formatting.GOLD), true); + } + } + } + entry.nextCheckpoint = (entry.nextCheckpoint + 1) % checkpoints; + } + entry.progress = (entry.lap - 1) * 100000.0 + entry.nextCheckpoint * 1000.0 + - distance; + entry.totalMs = now - raceStartMs; + } + rank(); + } + + private void onFinish(RaceEntry entry) { + CarEntity car = entry.car; + // the car keeps circulating with the AI so the podium ceremony looks alive + if (!entry.ai && entry.player() != null) { + entry.aiTakeover = true; + car.setAiDriving(true); + car.setAiRubberBand(false); + ServerPlayerEntity player = entry.player(); + player.sendMessage(Text.translatable("race.fabricracers.finished", + entry.finishPosition, Stopwatch.format(entry.finishMs)) + .formatted(Formatting.GREEN), false); + player.playSoundToPlayer(Sounds.of(SoundEvents.ENTITY_PLAYER_LEVELUP), SoundCategory.NEUTRAL, + 0.7F, 1.3F); + if (entry.finishPosition <= 3) { + StatsManager.get(server).awardTrophy(player, entry.finishPosition); + } + } + ServerWorld world = (ServerWorld) car.getWorld(); + world.spawnParticles(ParticleTypes.FIREWORK, car.getX(), car.getY() + 1.0, car.getZ(), + 20, 0.4, 0.4, 0.4, 0.15); + world.playSound(null, car.getX(), car.getY(), car.getZ(), + SoundEvents.ENTITY_FIREWORK_ROCKET_LARGE_BLAST, SoundCategory.NEUTRAL, 0.8F, 1.2F); + } + + private void rank() { + entries.sort(Comparator.comparingDouble((RaceEntry entry) -> -entry.progress)); + int position = 1; + for (RaceEntry entry : entries) { + entry.position = position++; + } + } + + private void trackOvertakes() { + rank(); + for (RaceEntry entry : entries) { + if (entry.lastPosition != 0 && entry.position < entry.lastPosition) { + entry.overtakes += entry.lastPosition - entry.position; + } + entry.lastPosition = entry.position; + } + } + + public void announce(String message) { + banner(message, 60); + for (ServerPlayerEntity player : viewers()) { + player.sendMessage(Text.literal(message).formatted(Formatting.AQUA), false); + } + } + + private void banner(String message, int ticks) { + banner = message; + bannerTicks = ticks; + } + + private void announceResults() { + StringBuilder builder = new StringBuilder(); + builder.append("=== ").append(placement.track.name).append(" results ===\n"); + List sorted = new ArrayList<>(entries); + sorted.sort(Comparator.comparingInt(entry -> entry.finishPosition)); + for (RaceEntry entry : sorted) { + builder.append(entry.finishPosition).append(". ").append(entry.label()) + .append(" ").append(Stopwatch.format(entry.finishMs)) + .append(entry.bestLapMs > 0 ? " (best lap " + Stopwatch.format(entry.bestLapMs) + ")" : "") + .append("\n"); + } + for (ServerPlayerEntity player : viewers()) { + player.sendMessage(Text.literal(builder.toString()).formatted(Formatting.WHITE), false); + } + } + + private void awardPrizes() { + StatsManager stats = StatsManager.get(server); + for (RaceEntry entry : entries) { + ServerPlayerEntity player = entry.player(); + if (player == null) { + continue; + } + stats.recordRace(player, placement.track, entry); + if (entry.finishPosition == 1) { + stats.givePrize(player, format.prizeEmeralds + 4); + } else if (entry.finishPosition <= 3) { + stats.givePrize(player, format.prizeEmeralds); + } + } + } + + // ------------------------------------------------------------------ + // helpers used by the AI + HUD + // ------------------------------------------------------------------ + /** 0.9 .. 1.12 speed multiplier that keeps the pack together. */ + public double rubberBand(CarEntity car, RaceEntry entry) { + if (!format.rubberBand) { + return 1.0; + } + double best = 0.0; + double worst = Double.MAX_VALUE; + for (RaceEntry other : entries) { + best = Math.max(best, other.progress); + worst = Math.min(worst, other.progress); + } + double spread = best - worst; + if (spread < 4500.0) { + return 1.0; + } + double behind = best - entry.progress; + double factor = 1.0 + MathHelper.clamp(behind / 60000.0, 0.0, 0.11); + if (behind < spread * 0.25) { + factor = 0.965; + } + return MathHelper.clamp(factor, 0.92, 1.12); + } + + /** Position of a car in the live standings. */ + public int positionOf(CarEntity car) { + RaceEntry entry = entryFor(car); + return entry == null ? 0 : entry.position; + } + + public boolean isFinished() { + return state == State.FINISHED; + } + + public boolean isLive() { + return state == State.RACING || state == State.COUNTDOWN; + } + + public String standingsText() { + StringBuilder builder = new StringBuilder(); + List sorted = new ArrayList<>(entries); + sorted.sort(Comparator.comparingInt(entry -> entry.finished ? entry.finishPosition : 100 + entry.position)); + int shown = 0; + for (RaceEntry entry : sorted) { + if (shown++ >= 10) { + break; + } + if (builder.length() > 0) { + builder.append('\n'); + } + builder.append(entry.position).append(' ').append(entry.label()).append(' ') + .append(entry.finished ? Stopwatch.format(entry.finishMs) : "L" + entry.lap) + .append(' ').append(entry.bestLapMs > 0 ? Stopwatch.format(entry.bestLapMs) : ""); + } + return builder.toString(); + } + + public ModNetworking.Hud hudFor(ServerPlayerEntity player) { + RaceEntry entry = entryFor(player); + CarEntity car = entry != null ? entry.car : (player.getVehicle() instanceof CarEntity c ? c : null); + int flags = 0; + if (car != null) { + if (car.isDrifting()) { + flags |= ModNetworking.Hud.FLAG_DRIFTING; + } + if (car.isBoosting()) { + flags |= ModNetworking.Hud.FLAG_BOOSTING; + } + if (car.healthFraction() < 0.6F) { + flags |= ModNetworking.Hud.FLAG_DAMAGED; + } + } + if (entry != null && entry.finished) { + flags |= ModNetworking.Hud.FLAG_FINISHED; + } + int position = entry != null ? entry.position : 0; + int lap = entry != null ? Math.min(entry.lap, format.laps) : 0; + return new ModNetworking.Hud(state.ordinal(), Math.max(0, countdown / 20 + 1), + lap, format.laps, position, entries.size(), + (int) (entry != null ? entry.totalMs : 0), + (int) (entry != null && entry.lapStartMs > 0 + ? System.currentTimeMillis() - entry.lapStartMs : 0), + (int) (entry != null ? entry.bestLapMs : 0), + (int) Math.round(Math.abs(car != null ? car.getSpeed() : 0.0) * 3.6), + car != null ? car.getBoostCharge() : 0.0, + (int) (car != null ? car.getDriftScore() : 0), + 0, 0, flags, + placement.track.name, + bannerTicks > 0 ? banner : "", + standingsText(), + car != null ? car.carType().displayName : ""); + } + + /** Freeroam HUD for a player who is driving but not racing. */ + public static ModNetworking.Hud freeRoamHud(ServerPlayerEntity player, CarEntity car) { + int flags = 0; + if (car.isDrifting()) { + flags |= ModNetworking.Hud.FLAG_DRIFTING; + } + if (car.isBoosting()) { + flags |= ModNetworking.Hud.FLAG_BOOSTING; + } + if (car.healthFraction() < 0.6F) { + flags |= ModNetworking.Hud.FLAG_DAMAGED; + } + if (car.isBroken()) { + flags |= ModNetworking.Hud.FLAG_DAMAGED; + } + return new ModNetworking.Hud(-1, 0, 0, 0, 0, 0, 0, 0, 0, + (int) Math.round(Math.abs(car.getSpeed()) * 3.6), car.getBoostCharge(), + (int) car.getDriftScore(), 0, 0, flags, "", + "", "", car.carType().displayName); + } + + public String describe() { + return placement.track.name + " | " + format.describe() + + " | " + entries.size() + " cars | state " + state + + " | age " + Interp.seconds((System.currentTimeMillis() - createdMs)); + } +} diff --git a/src/main/java/dev/qynl/racers/registry/ModBlocks.java b/src/main/java/dev/qynl/racers/registry/ModBlocks.java new file mode 100644 index 0000000..ec106d0 --- /dev/null +++ b/src/main/java/dev/qynl/racers/registry/ModBlocks.java @@ -0,0 +1,69 @@ +package dev.qynl.racers.registry; + +import java.util.LinkedHashMap; +import java.util.Map; + +import net.minecraft.block.AbstractBlock; +import net.minecraft.block.Block; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.sound.BlockSoundGroup; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.block.RacerBlocks; + +/** Every block the mod adds, plus a lookup table used by the structure builder. */ +public final class ModBlocks { + private static final Map BY_NAME = new LinkedHashMap<>(); + + public static final Block ASPHALT = register("asphalt", + new RacerBlocks.Asphalt(RacerBlocks.tarmac())); + public static final Block ASPHALT_CRACKED = register("asphalt_cracked", + new RacerBlocks.Asphalt(RacerBlocks.tarmac().strength(1.0F, 5.0F))); + public static final Block ROAD_BLOCK = register("road_block", + new RacerBlocks.RoadLine(RacerBlocks.tarmac())); + public static final Block FINISH_LINE = register("finish_line", + new RacerBlocks.FinishLine(RacerBlocks.tarmac().strength(1.4F, 8.0F))); + public static final Block PIT_LANE = register("pit_lane", + new RacerBlocks.PitLane(RacerBlocks.tarmac().sounds(BlockSoundGroup.STONE))); + public static final Block KERB = register("kerb", + new RacerBlocks.Kerb(RacerBlocks.decor())); + public static final Block SAFETY_BARRIER = register("safety_barrier", + new RacerBlocks.SecurityBarrier(RacerBlocks.barrier())); + public static final Block START_GATE = register("start_gate", + new RacerBlocks.StartGate(RacerBlocks.barrier().luminance(state -> 7))); + public static final Block CHECKPOINT_MARKER = register("checkpoint_marker", + new RacerBlocks.CheckpointMarker(RacerBlocks.decor() + .luminance(state -> state.get(RacerBlocks.CheckpointMarker.LIT) ? 9 : 2))); + public static final Block STATION = register("station", + new RacerBlocks.Station(RacerBlocks.decor().luminance(state -> 5))); + public static final Block BOOST_PAD = register("boost_pad", + new RacerBlocks.BoostPad(RacerBlocks.tarmac() + .sounds(BlockSoundGroup.METAL).luminance(state -> 10))); + public static final Block FAKE_ICE = register("fake_ice", + new RacerBlocks.FakeIce(AbstractBlock.Settings.create().strength(0.6F) + .sounds(BlockSoundGroup.GLASS).slipperiness(0.989F) + .nonOpaque().mapColor(net.minecraft.block.MapColor.LIGHT_BLUE))); + public static final Block FAKE_SLIME = register("fake_slime", + new RacerBlocks.FakeSlime(AbstractBlock.Settings.create().strength(0.4F) + .sounds(BlockSoundGroup.SLIME).slipperiness(0.8F) + .nonOpaque().mapColor(net.minecraft.block.MapColor.LIME))); + + private static Block register(String name, Block block) { + BY_NAME.put(name, block); + return Registry.register(Registries.BLOCK, FabricRacers.id(name), block); + } + + /** Returns the mod block registered under {@code name}, or null. */ + public static Block byName(String name) { + return BY_NAME.get(name); + } + + public static Map all() { + return BY_NAME; + } + + public static void init() { + // class-loading side effect + } +} diff --git a/src/main/java/dev/qynl/racers/registry/ModComponents.java b/src/main/java/dev/qynl/racers/registry/ModComponents.java new file mode 100644 index 0000000..f1d216b --- /dev/null +++ b/src/main/java/dev/qynl/racers/registry/ModComponents.java @@ -0,0 +1,100 @@ +package dev.qynl.racers.registry; + +import net.minecraft.component.DataComponentTypes; +import net.minecraft.component.type.NbtComponent; +import net.minecraft.item.ItemStack; +import net.minecraft.nbt.NbtCompound; + +/** + * Item payloads. + * + *

Everything the mod stores on an item rides inside the vanilla + * {@code minecraft:custom_data} component, under namespaced keys. That keeps + * CarData forward compatible (it is plain NBT), needs no custom component + * registry, and still syncs to clients automatically. + */ +public final class ModComponents { + /** Key of the {@link dev.qynl.racers.car.CarData} payload on a car item. */ + public static final String CAR_DATA = "fabricracers:car_data"; + /** Key of the last stopwatch lap time (milliseconds, long). */ + public static final String LAP_TIME = "fabricracers:lap_time"; + /** Key of the track wand's remembered mode (int). */ + public static final String WAND_MODE = "fabricracers:wand_mode"; + + private ModComponents() { + } + + public static void init() { + // nothing to register: the payload rides in minecraft:custom_data + } + + /** The whole fabricracers payload of a stack (never null, may be empty). */ + public static NbtCompound payload(ItemStack stack) { + NbtComponent component = stack.get(DataComponentTypes.CUSTOM_DATA); + return component == null ? new NbtCompound() : component.copyNbt(); + } + + public static void write(ItemStack stack, NbtCompound payload) { + if (payload.isEmpty()) { + stack.remove(DataComponentTypes.CUSTOM_DATA); + } else { + stack.set(DataComponentTypes.CUSTOM_DATA, NbtComponent.of(payload)); + } + } + + private static NbtCompound section(ItemStack stack, String key) { + NbtCompound payload = payload(stack); + return payload.contains(key) ? payload.getCompound(key) : new NbtCompound(); + } + + private static void put(ItemStack stack, String key, NbtCompound value) { + NbtCompound payload = payload(stack); + if (value.isEmpty()) { + payload.remove(key); + } else { + payload.put(key, value); + } + write(stack, payload); + } + + // -- car data ---------------------------------------------------------- + public static NbtCompound carData(ItemStack stack) { + return section(stack, CAR_DATA); + } + + public static void setCarData(ItemStack stack, NbtCompound nbt) { + put(stack, CAR_DATA, nbt); + } + + // -- stopwatch --------------------------------------------------------- + public static long lapTime(ItemStack stack) { + NbtCompound payload = payload(stack); + return payload.contains(LAP_TIME) ? payload.getLong(LAP_TIME) : 0L; + } + + public static void setLapTime(ItemStack stack, long millis) { + NbtCompound payload = payload(stack); + if (millis <= 0L) { + payload.remove(LAP_TIME); + } else { + payload.putLong(LAP_TIME, millis); + } + write(stack, payload); + } + + // -- track wand -------------------------------------------------------- + public static int wandMode(ItemStack stack) { + NbtCompound payload = payload(stack); + return payload.contains(WAND_MODE) ? payload.getInt(WAND_MODE) : 0; + } + + public static void setWandMode(ItemStack stack, int mode) { + NbtCompound payload = payload(stack); + if (mode == 0) { + payload.remove(WAND_MODE); + } else { + payload.putInt(WAND_MODE, mode); + } + write(stack, payload); + } +} diff --git a/src/main/java/dev/qynl/racers/registry/ModEntities.java b/src/main/java/dev/qynl/racers/registry/ModEntities.java new file mode 100644 index 0000000..03d561f --- /dev/null +++ b/src/main/java/dev/qynl/racers/registry/ModEntities.java @@ -0,0 +1,36 @@ +package dev.qynl.racers.registry; + +import net.minecraft.entity.EntityType; +import net.minecraft.entity.SpawnGroup; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.car.AiCarEntity; +import dev.qynl.racers.car.PlayerCarEntity; + +/** Entity types. Two of them, sharing everything except the driver source. */ +public final class ModEntities { + public static final EntityType PLAYER_CAR = Registry.register( + Registries.ENTITY_TYPE, FabricRacers.id("player_car"), + EntityType.Builder.create(PlayerCarEntity::new, SpawnGroup.MISC) + .dimensions(1.9F, 1.25F) + .maxTrackingRange(16) + .trackingTickInterval(1) + .build("player_car")); + + public static final EntityType AI_CAR = Registry.register( + Registries.ENTITY_TYPE, FabricRacers.id("ai_car"), + EntityType.Builder.create(AiCarEntity::new, SpawnGroup.MISC) + .dimensions(1.9F, 1.25F) + .maxTrackingRange(16) + .trackingTickInterval(1) + .build("ai_car")); + + private ModEntities() { + } + + public static void init() { + // CarEntity extends Entity (not LivingEntity), so no attribute defaults are needed. + } +} diff --git a/src/main/java/dev/qynl/racers/registry/ModItems.java b/src/main/java/dev/qynl/racers/registry/ModItems.java new file mode 100644 index 0000000..b306213 --- /dev/null +++ b/src/main/java/dev/qynl/racers/registry/ModItems.java @@ -0,0 +1,208 @@ +package dev.qynl.racers.registry; + +import java.util.LinkedHashMap; +import java.util.Map; + +import net.fabricmc.fabric.api.itemgroup.v1.FabricItemGroup; +import net.fabricmc.fabric.api.itemgroup.v1.ItemGroupEvents; +import net.minecraft.block.Block; +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.BlockItem; +import net.minecraft.item.Item; +import net.minecraft.item.ItemGroup; +import net.minecraft.item.ItemStack; +import net.minecraft.registry.Registries; +import net.minecraft.registry.Registry; +import net.minecraft.text.Text; +import net.minecraft.util.Rarity; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.car.CarData; +import dev.qynl.racers.car.CarType; +import dev.qynl.racers.car.PaintColors; +import dev.qynl.racers.car.Tune; +import dev.qynl.racers.item.CarItem; +import dev.qynl.racers.item.ToolItems; + +/** Every item: cars, tools, trophies, paint cans, upgrade parts and blocks. */ +public final class ModItems { + private static final Map BY_NAME = new LinkedHashMap<>(); + private static final Map CAR_ITEMS = new LinkedHashMap<>(); + private static final Map TUNE_PARTS = new LinkedHashMap<>(); + private static final Map PAINT_ITEMS = new LinkedHashMap<>(); + + // --- cars -------------------------------------------------------------- + public static final Item CAR_KART = car(CarType.KART); + public static final Item CAR_RALLY = car(CarType.RALLY); + public static final Item CAR_SPORT = car(CarType.SPORT); + public static final Item CAR_HYPER = car(CarType.HYPER); + public static final Item CAR_F1 = car(CarType.F1); + public static final Item CAR_TRUCK = car(CarType.TRUCK); + public static final Item CAR_DRIFT = car(CarType.DRIFT); + public static final Item CAR_HOTROD = car(CarType.HOTROD); + public static final Item CAR_SHADOW = car(CarType.SHADOW); + + // --- tools ------------------------------------------------------------- + public static final Item CARROT_KEY = register("carrot_key", + new ToolItems.CarKey(new Item.Settings().maxCount(1).rarity(Rarity.UNCOMMON))); + public static final Item WRENCH = register("wrench", + new ToolItems.Wrench(new Item.Settings().maxCount(1).maxDamage(220))); + public static final Item TRACK_WAND = register("track_wand", + new ToolItems.TrackWand(new Item.Settings().maxCount(1).rarity(Rarity.RARE))); + public static final Item STOPWATCH = register("stopwatch", + new ToolItems.Stopwatch(new Item.Settings().maxCount(1))); + public static final Item TROPHY_GOLD = register("trophy_gold", + new ToolItems.Trophy("gold", new Item.Settings().maxCount(1).rarity(Rarity.RARE))); + public static final Item TROPHY_SILVER = register("trophy_silver", + new ToolItems.Trophy("silver", new Item.Settings().maxCount(16))); + public static final Item TROPHY_BRONZE = register("trophy_bronze", + new ToolItems.Trophy("bronze", new Item.Settings().maxCount(16))); + public static final Item RACE_PASS = register("race_pass", + new ToolItems.RacePass(new Item.Settings().maxCount(1))); + + // --- paint ------------------------------------------------------------- + public static final Item PAINT_WHITE = paint("white", PaintColors.WHITE); + public static final Item PAINT_BLACK = paint("black", PaintColors.BLACK); + public static final Item PAINT_RED = paint("red", PaintColors.RED); + public static final Item PAINT_ORANGE = paint("orange", PaintColors.ORANGE); + public static final Item PAINT_YELLOW = paint("yellow", PaintColors.YELLOW); + public static final Item PAINT_GREEN = paint("green", PaintColors.GREEN); + public static final Item PAINT_BLUE = paint("blue", PaintColors.BLUE); + public static final Item PAINT_PURPLE = paint("purple", PaintColors.PURPLE); + + // --- tuning parts ------------------------------------------------------ + public static final Item TUNE_ENGINE = part(Tune.ENGINE); + public static final Item TUNE_GEARBOX = part(Tune.GEARBOX); + public static final Item TUNE_BRAKES = part(Tune.BRAKES); + public static final Item TUNE_TIRES = part(Tune.TIRES); + public static final Item TUNE_AERO = part(Tune.AERO); + public static final Item TUNE_CHASSIS = part(Tune.CHASSIS); + public static final Item TUNE_BOOST = part(Tune.BOOST); + + // --- block items ------------------------------------------------------- + public static final Item BLOCK_ASPHALT_ITEM = blockItem("asphalt", ModBlocks.ASPHALT); + public static final Item BLOCK_ASPHALT_CRACKED_ITEM = blockItem("asphalt_cracked", ModBlocks.ASPHALT_CRACKED); + public static final Item BLOCK_ROAD_BLOCK_ITEM = blockItem("road_block", ModBlocks.ROAD_BLOCK); + public static final Item BLOCK_FINISH_LINE_ITEM = blockItem("finish_line", ModBlocks.FINISH_LINE); + public static final Item BLOCK_PIT_LANE_ITEM = blockItem("pit_lane", ModBlocks.PIT_LANE); + public static final Item BLOCK_KERB_ITEM = blockItem("kerb", ModBlocks.KERB); + public static final Item BLOCK_SAFETY_BARRIER_ITEM = blockItem("safety_barrier", ModBlocks.SAFETY_BARRIER); + public static final Item BLOCK_START_GATE_ITEM = blockItem("start_gate", ModBlocks.START_GATE); + public static final Item BLOCK_CHECKPOINT_MARKER_ITEM = blockItem("checkpoint_marker", ModBlocks.CHECKPOINT_MARKER); + public static final Item BLOCK_STATION_ITEM = blockItem("station", ModBlocks.STATION); + public static final Item BLOCK_BOOST_PAD_ITEM = blockItem("boost_pad", ModBlocks.BOOST_PAD); + public static final Item BLOCK_FAKE_ICE_ITEM = blockItem("fake_ice", ModBlocks.FAKE_ICE); + public static final Item BLOCK_FAKE_SLIME_ITEM = blockItem("fake_slime", ModBlocks.FAKE_SLIME); + + public static final ItemGroup GROUP = Registry.register(Registries.ITEM_GROUP, + FabricRacers.id("racing"), + FabricItemGroup.builder() + .displayName(Text.translatable("itemGroup.fabricracers.racing")) + .icon(() -> new ItemStack(CAR_SPORT)) + .entries((context, entries) -> { + for (CarType type : CarType.values()) { + entries.add(new ItemStack(CAR_ITEMS.get(type))); + } + entries.add(WRENCH); + entries.add(TRACK_WAND); + entries.add(STOPWATCH); + entries.add(CARROT_KEY); + entries.add(RACE_PASS); + entries.add(TROPHY_GOLD); + entries.add(TROPHY_SILVER); + entries.add(TROPHY_BRONZE); + PAINT_ITEMS.values().forEach(entries::add); + TUNE_PARTS.values().forEach(entries::add); + entries.add(BLOCK_ASPHALT_ITEM); + entries.add(BLOCK_ASPHALT_CRACKED_ITEM); + entries.add(BLOCK_ROAD_BLOCK_ITEM); + entries.add(BLOCK_FINISH_LINE_ITEM); + entries.add(BLOCK_PIT_LANE_ITEM); + entries.add(BLOCK_KERB_ITEM); + entries.add(BLOCK_SAFETY_BARRIER_ITEM); + entries.add(BLOCK_START_GATE_ITEM); + entries.add(BLOCK_CHECKPOINT_MARKER_ITEM); + entries.add(BLOCK_STATION_ITEM); + entries.add(BLOCK_BOOST_PAD_ITEM); + entries.add(BLOCK_FAKE_ICE_ITEM); + entries.add(BLOCK_FAKE_SLIME_ITEM); + }) + .build()); + + private ModItems() { + } + + // ------------------------------------------------------------------ + private static Item car(CarType type) { + Item item = register(type.itemId(), new CarItem(type, new Item.Settings() + .maxCount(1).rarity(type == CarType.SHADOW ? Rarity.EPIC : Rarity.UNCOMMON))); + CAR_ITEMS.put(type, item); + return item; + } + + private static Item part(Tune tune) { + String name = switch (tune) { + case ENGINE -> "engine_turbo"; + case GEARBOX -> "gearbox_dct"; + case BRAKES -> "brakes_carbon"; + case TIRES -> "tires_street"; + case AERO -> "aero_wing"; + case CHASSIS -> "chassis_plate"; + case BOOST -> "boost_nitro"; + }; + Item item = register(name, new ToolItems.TunePart(tune, new Item.Settings().maxCount(64))); + TUNE_PARTS.put(tune, item); + return item; + } + + private static Item paint(String name, int color) { + Item item = register("paint_" + name, new ToolItems.PaintCan(color, new Item.Settings().maxCount(16))); + PAINT_ITEMS.put(name, item); + return item; + } + + private static Item blockItem(String name, Block block) { + return register(name, new BlockItem(block, new Item.Settings())); + } + + private static Item register(String name, Item item) { + BY_NAME.put(name, item); + return Registry.register(Registries.ITEM, FabricRacers.id(name), item); + } + + public static Item byName(String name) { + return BY_NAME.get(name); + } + + public static Item carItem(CarType type) { + return CAR_ITEMS.get(type); + } + + public static Item part(Tune tune) { + return TUNE_PARTS.get(tune); + } + + public static Item paintItem(String name) { + return PAINT_ITEMS.get(name); + } + + public static Map carItems() { + return CAR_ITEMS; + } + + /** A ready to drive car, owned by {@code owner}. */ + public static ItemStack carStack(CarType type, PlayerEntity owner) { + ItemStack stack = new ItemStack(carItem(type)); + CarData.apply(stack, CarData.fresh(type, owner)); + return stack; + } + + public static void init() { + // the group is registered at class load; also mirror the tools into vanilla tabs + ItemGroupEvents.modifyEntriesEvent(net.minecraft.item.ItemGroups.TRANSPORTATION) + .register(entries -> { + entries.add(CAR_SPORT); + entries.add(WRENCH); + }); + } +} diff --git a/src/main/java/dev/qynl/racers/registry/ModSounds.java b/src/main/java/dev/qynl/racers/registry/ModSounds.java new file mode 100644 index 0000000..735b988 --- /dev/null +++ b/src/main/java/dev/qynl/racers/registry/ModSounds.java @@ -0,0 +1,18 @@ +package dev.qynl.racers.registry; + +/** + * Sound registry. + * + *

FabricRacers deliberately reuses vanilla sound events (minecarts for the + * engine, anvils for the wrench, fireworks for the podium) so the mod ships + * without a single binary sound asset. Custom .ogg files can be dropped in + * later and registered here. + */ +public final class ModSounds { + private ModSounds() { + } + + public static void init() { + // no custom sounds yet - see the class comment + } +} diff --git a/src/main/java/dev/qynl/racers/shop/PartShop.java b/src/main/java/dev/qynl/racers/shop/PartShop.java new file mode 100644 index 0000000..1ae676a --- /dev/null +++ b/src/main/java/dev/qynl/racers/shop/PartShop.java @@ -0,0 +1,92 @@ +package dev.qynl.racers.shop; + +import java.util.ArrayList; +import java.util.List; + +import net.fabricmc.fabric.api.object.builder.v1.trade.TradeOfferHelper; +import net.minecraft.item.Item; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.util.math.random.Random; +import net.minecraft.village.TradeOffer; +import net.minecraft.village.TradeOffers; +import net.minecraft.village.VillagerProfession; + +import dev.qynl.racers.car.CarType; +import dev.qynl.racers.car.Tune; +import dev.qynl.racers.registry.ModItems; + +/** + * Economy: mechanics and wandering traders sell tuning parts and complete cars + * so a survival world has a path to the fast stuff. Race prizes pay in emeralds, + * so winning literally funds the next upgrade. + */ +public final class PartShop { + private PartShop() { + } + + public static void init() { + // --- tier 1: starter parts and building blocks ------------------- + TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 1, factories -> { + factories.add(buy(ModItems.part(Tune.TIRES), 2, 9, 8)); + factories.add(buy(ModItems.part(Tune.BRAKES), 1, 8, 8)); + }); + TradeOfferHelper.registerVillagerOffers(VillagerProfession.MASON, 1, factories -> { + factories.add(buy(ModItems.BLOCK_ASPHALT_ITEM, 16, 7, 6)); + factories.add(buy(ModItems.BLOCK_KERB_ITEM, 16, 5, 6)); + }); + + // --- tier 2: real tuning ---------------------------------------- + TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 2, factories -> { + factories.add(buy(ModItems.part(Tune.ENGINE), 1, 18, 6)); + factories.add(buy(ModItems.part(Tune.GEARBOX), 1, 16, 6)); + factories.add(buy(ModItems.part(Tune.CHASSIS), 1, 14, 6)); + }); + + // --- tier 3: aero, boost and the cars themselves ----------------- + TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 3, factories -> { + factories.add(buy(ModItems.part(Tune.AERO), 1, 26, 5)); + factories.add(buy(ModItems.part(Tune.BOOST), 1, 24, 5)); + factories.add(PartShop::carOffer); + }); + TradeOfferHelper.registerVillagerOffers(VillagerProfession.WEAPONSMITH, 3, + factories -> factories.add(PartShop::carOffer)); + + // --- the racing village ------------------------------------------------- + TradeOfferHelper.registerVillagerOffers(VillagerProfession.CLERIC, 2, factories -> { + factories.add(buy(ModItems.RACE_PASS, 1, 12, 6)); + factories.add(buy(ModItems.STOPWATCH, 1, 10, 5)); + factories.add(buy(ModItems.WRENCH, 1, 16, 4)); + }); + + // --- wandering trader: exotica ----------------------------------- + TradeOfferHelper.registerWanderingTraderOffers(1, factories -> { + factories.add(buy(ModItems.WRENCH, 1, 30, 4)); + factories.add(buy(ModItems.BLOCK_BOOST_PAD_ITEM, 4, 22, 5)); + factories.add(PartShop::carOffer); + }); + } + + /** A plain "give emeralds, get item" trade. */ + private static TradeOffers.Factory buy(Item item, int amount, int price, int maxUses) { + return (entity, random) -> new TradeOffer(new ItemStack(Items.EMERALD, price), + new ItemStack(item, amount), maxUses, 5, 0.05F); + } + + /** Sells a random stock car, priced by its market value. */ + private static TradeOffer carOffer(net.minecraft.entity.Entity entity, Random random) { + List sellable = new ArrayList<>(CarType.stock()); + CarType type = sellable.get(random.nextInt(sellable.size())); + return new TradeOffer(new ItemStack(Items.EMERALD, Math.max(4, type.price() * 3)), + ModItems.carStack(type, null), 2, 12, 0.1F); + } + + /** Parts needed to reach {@code level} of a system. */ + public static int costFor(Tune tune, int level) { + return Math.max(1, level); + } + + public static String costLabel(Tune tune, int level) { + return costFor(tune, level) + "x " + tune.displayName + " part"; + } +} diff --git a/src/main/java/dev/qynl/racers/stats/RacerProfile.java b/src/main/java/dev/qynl/racers/stats/RacerProfile.java new file mode 100644 index 0000000..a6733f4 --- /dev/null +++ b/src/main/java/dev/qynl/racers/stats/RacerProfile.java @@ -0,0 +1,157 @@ +package dev.qynl.racers.stats; + +import java.util.LinkedHashMap; +import java.util.Map; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +/** Everything the mod remembers about one player across worlds. */ +public final class RacerProfile { + public String name = ""; + public int racesStarted; + public int racesFinished; + public int wins; + public int podiums; + public int dnfs; + public int laps; + public int overtakes; + public int driftPoints; + public int carsPlaced; + public int tracksRecorded; + public int prizesWon; + public int trophies; + public double distance; + public double topSpeedKmh; + /** Track id -> best lap in milliseconds. */ + public final Map bestLaps = new LinkedHashMap<>(); + /** Track id -> number of completed races. */ + public final Map trackRaces = new LinkedHashMap<>(); + + public JsonObject toJson() { + JsonObject json = new JsonObject(); + json.addProperty("name", name); + json.addProperty("racesStarted", racesStarted); + json.addProperty("racesFinished", racesFinished); + json.addProperty("wins", wins); + json.addProperty("podiums", podiums); + json.addProperty("dnfs", dnfs); + json.addProperty("laps", laps); + json.addProperty("overtakes", overtakes); + json.addProperty("driftPoints", driftPoints); + json.addProperty("carsPlaced", carsPlaced); + json.addProperty("tracksRecorded", tracksRecorded); + json.addProperty("prizesWon", prizesWon); + json.addProperty("trophies", trophies); + json.addProperty("distance", distance); + json.addProperty("topSpeedKmh", topSpeedKmh); + JsonObject lapsJson = new JsonObject(); + bestLaps.forEach(lapsJson::addProperty); + json.add("bestLaps", lapsJson); + JsonObject racesJson = new JsonObject(); + trackRaces.forEach(racesJson::addProperty); + json.add("trackRaces", racesJson); + return json; + } + + public static RacerProfile fromJson(JsonObject json) { + RacerProfile profile = new RacerProfile(); + profile.name = string(json, "name"); + profile.racesStarted = intOr(json, "racesStarted"); + profile.racesFinished = intOr(json, "racesFinished"); + profile.wins = intOr(json, "wins"); + profile.podiums = intOr(json, "podiums"); + profile.dnfs = intOr(json, "dnfs"); + profile.laps = intOr(json, "laps"); + profile.overtakes = intOr(json, "overtakes"); + profile.driftPoints = intOr(json, "driftPoints"); + profile.carsPlaced = intOr(json, "carsPlaced"); + profile.tracksRecorded = intOr(json, "tracksRecorded"); + profile.prizesWon = intOr(json, "prizesWon"); + profile.trophies = intOr(json, "trophies"); + profile.distance = json.has("distance") ? json.get("distance").getAsDouble() : 0.0; + profile.topSpeedKmh = json.has("topSpeedKmh") ? json.get("topSpeedKmh").getAsDouble() : 0.0; + if (json.has("bestLaps")) { + for (Map.Entry entry : json.getAsJsonObject("bestLaps").entrySet()) { + profile.bestLaps.put(entry.getKey(), entry.getValue().getAsLong()); + } + } + if (json.has("trackRaces")) { + for (Map.Entry entry : json.getAsJsonObject("trackRaces").entrySet()) { + profile.trackRaces.put(entry.getKey(), entry.getValue().getAsInt()); + } + } + return profile; + } + + private static String string(JsonObject json, String key) { + return json.has(key) ? json.get(key).getAsString() : ""; + } + + private static int intOr(JsonObject json, String key) { + return json.has(key) ? json.get(key).getAsInt() : 0; + } + + public int totalRaces() { + return racesStarted; + } + + public String summary() { + return String.format("%s: %d races, %d wins, %d podiums, %d laps, %d overtakes, " + + "%.0f km driven, top speed %.0f km/h, %d drift points, %d trophies", + name.isEmpty() ? "Driver" : name, racesStarted, wins, podiums, laps, overtakes, + distance / 1000.0, topSpeedKmh, driftPoints, trophies); + } + + public int titles() { + int titles = 0; + if (racesFinished >= 1) { + titles++; + } + if (wins >= 1) { + titles++; + } + if (podiums >= 5) { + titles++; + } + if (racesFinished >= 10) { + titles++; + } + if (wins >= 10) { + titles++; + } + if (driftPoints >= 2000) { + titles++; + } + if (topSpeedKmh >= 160.0) { + titles++; + } + if (tracksRecorded >= 1) { + titles++; + } + if (laps >= 50) { + titles++; + } + return titles; + } + + public String titleName() { + int titles = titles(); + if (titles >= 9) { + return "Legend"; + } + if (titles >= 7) { + return "Champion"; + } + if (titles >= 5) { + return "Pro"; + } + if (titles >= 3) { + return "Racer"; + } + if (titles >= 1) { + return "Rookie"; + } + return "Walk-on"; + } +} diff --git a/src/main/java/dev/qynl/racers/stats/StatsManager.java b/src/main/java/dev/qynl/racers/stats/StatsManager.java new file mode 100644 index 0000000..88271e9 --- /dev/null +++ b/src/main/java/dev/qynl/racers/stats/StatsManager.java @@ -0,0 +1,275 @@ +package dev.qynl.racers.stats; + +import java.io.IOException; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.HashMap; +import java.util.Map; +import java.util.UUID; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.minecraft.entity.player.PlayerEntity; +import net.minecraft.item.ItemStack; +import net.minecraft.item.Items; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtList; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.net.ModNetworking; +import dev.qynl.racers.race.RaceEntry; +import dev.qynl.racers.registry.ModItems; +import dev.qynl.racers.track.Track; +import dev.qynl.racers.util.Stopwatch; + +/** + * Per-player racing records: races, wins, best laps per circuit, drift points, + * odometers and earned trophies. + * + *

Stored as JSON under {@code /fabricracers/stats/.json} so a + * world keeps its championship history when it is copied around. + */ +public final class StatsManager { + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final Map INSTANCES = new HashMap<>(); + + private final MinecraftServer server; + private final Map profiles = new HashMap<>(); + private boolean dirty; + + private StatsManager(MinecraftServer server) { + this.server = server; + } + + public static StatsManager get(MinecraftServer server) { + if (server == null) { + return null; + } + return INSTANCES.computeIfAbsent(server, StatsManager::new); + } + + public static void forget(MinecraftServer server) { + INSTANCES.remove(server); + } + + private Path dir() { + return server.getSavePath(net.minecraft.util.WorldSavePath.ROOT) + .resolve("fabricracers").resolve("stats"); + } + + public RacerProfile get(PlayerEntity player) { + RacerProfile profile = profiles.computeIfAbsent(player.getUuid(), id -> new RacerProfile()); + profile.name = player.getName().getString(); + return profile; + } + + public void load() { + profiles.clear(); + Path path = dir(); + if (!Files.isDirectory(path)) { + return; + } + try (var stream = Files.list(path)) { + for (Path file : stream.filter(f -> f.toString().endsWith(".json")).toList()) { + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); + String fileName = file.getFileName().toString(); + UUID id = UUID.fromString(fileName.substring(0, fileName.length() - 5)); + profiles.put(id, RacerProfile.fromJson(json)); + } catch (Exception exception) { + FabricRacers.LOGGER.warn("Skipping unreadable profile {}", file, exception); + } + } + } catch (IOException exception) { + FabricRacers.LOGGER.error("Could not read the stats folder", exception); + } + FabricRacers.LOGGER.info("Loaded {} racer profiles", profiles.size()); + } + + public void save() { + if (!dirty) { + return; + } + try { + Files.createDirectories(dir()); + for (Map.Entry entry : profiles.entrySet()) { + Path file = dir().resolve(entry.getKey().toString() + ".json"); + try (Writer writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { + GSON.toJson(entry.getValue().toJson(), writer); + } + } + dirty = false; + } catch (IOException exception) { + FabricRacers.LOGGER.error("Could not write the stats folder", exception); + } + } + + public void markDirty() { + dirty = true; + } + + public void onJoin(ServerPlayerEntity player) { + RacerProfile profile = get(player); + profile.name = player.getName().getString(); + player.sendMessage(Text.translatable("race.fabricracers.welcome", + Text.literal(profile.titleName()).formatted(Formatting.GOLD)).formatted(Formatting.GRAY), + false); + ModNetworking.sendProfile(player, toNbt(profile)); + } + + // ------------------------------------------------------------------ + // recording + // ------------------------------------------------------------------ + public void recordLap(ServerPlayerEntity player, Track track, long millis) { + RacerProfile profile = get(player); + Long best = profile.bestLaps.get(track.id); + if (best == null || millis < best) { + profile.bestLaps.put(track.id, millis); + player.sendMessage(Text.translatable("race.fabricracers.track_record", + track.name, Stopwatch.format(millis)).formatted(Formatting.LIGHT_PURPLE), + false); + } + markDirty(); + } + + public void recordRace(ServerPlayerEntity player, Track track, RaceEntry entry) { + RacerProfile profile = get(player); + profile.racesStarted++; + profile.laps += Math.max(0, entry.lap - 1); + profile.overtakes += entry.overtakes; + profile.distance += entry.car.odometer(); + profile.topSpeedKmh = Math.max(profile.topSpeedKmh, Math.abs(entry.car.getSpeed()) * 3.6); + profile.driftPoints += (int) entry.car.getDriftScore(); + profile.trackRaces.merge(track.id, 1, Integer::sum); + if (entry.finished) { + profile.racesFinished++; + if (entry.finishPosition == 1) { + profile.wins++; + profile.podiums++; + } else if (entry.finishPosition <= 3) { + profile.podiums++; + } + } else { + profile.dnfs++; + } + markDirty(); + ModNetworking.sendProfile(player, toNbt(profile)); + } + + public void recordDrive(CarEntity car) { + if (car.getDriver() instanceof ServerPlayerEntity player) { + RacerProfile profile = get(player); + profile.distance = Math.max(profile.distance, car.odometer()); + profile.topSpeedKmh = Math.max(profile.topSpeedKmh, Math.abs(car.getSpeed()) * 3.6); + profile.driftPoints = Math.max(profile.driftPoints, (int) car.getDriftScore()); + markDirty(); + } + } + + public void awardTrophy(ServerPlayerEntity player, int position) { + RacerProfile profile = get(player); + profile.trophies++; + markDirty(); + String itemName = switch (position) { + case 1 -> "trophy_gold"; + case 2 -> "trophy_silver"; + default -> "trophy_bronze"; + }; + ItemStack stack = new ItemStack(switch (position) { + case 1 -> ModItems.TROPHY_GOLD; + case 2 -> ModItems.TROPHY_SILVER; + default -> ModItems.TROPHY_BRONZE; + }); + if (!player.getInventory().insertStack(stack)) { + player.dropItem(stack, false); + } + player.sendMessage(Text.translatable("race.fabricracers.trophy", position, + Text.translatable("item.fabricracers." + itemName)).formatted(Formatting.GOLD), false); + } + + public void givePrize(ServerPlayerEntity player, int emeralds) { + if (emeralds <= 0) { + return; + } + RacerProfile profile = get(player); + profile.prizesWon += emeralds; + ItemStack stack = new ItemStack(Items.EMERALD, emeralds); + if (!player.getInventory().insertStack(stack)) { + player.dropItem(stack, false); + } + markDirty(); + } + + public void recordWreck(ServerPlayerEntity player) { + RacerProfile profile = get(player); + profile.dnfs++; + markDirty(); + } + + public void sendRecord(ServerPlayerEntity player) { + RacerProfile profile = get(player); + player.sendMessage(Text.literal(profile.summary()).formatted(Formatting.GOLD), false); + if (!profile.bestLaps.isEmpty()) { + player.sendMessage(Text.literal("Best laps:").formatted(Formatting.GRAY), false); + profile.bestLaps.forEach((track, millis) -> player.sendMessage( + Text.literal(" - " + track + ": " + Stopwatch.format(millis)) + .formatted(Formatting.WHITE), false)); + } + ModNetworking.sendProfile(player, toNbt(profile)); + } + + public NbtCompound toNbt(RacerProfile profile) { + NbtCompound nbt = new NbtCompound(); + nbt.putString("name", profile.name); + nbt.putInt("races", profile.racesStarted); + nbt.putInt("finished", profile.racesFinished); + nbt.putInt("wins", profile.wins); + nbt.putInt("podiums", profile.podiums); + nbt.putInt("laps", profile.laps); + nbt.putInt("overtakes", profile.overtakes); + nbt.putInt("drift", profile.driftPoints); + nbt.putInt("cars", profile.carsPlaced); + nbt.putInt("tracks", profile.tracksRecorded); + nbt.putInt("trophies", profile.trophies); + nbt.putDouble("distance", profile.distance); + nbt.putDouble("topSpeed", profile.topSpeedKmh); + nbt.putString("title", profile.titleName()); + NbtList laps = new NbtList(); + profile.bestLaps.forEach((track, millis) -> { + NbtCompound entry = new NbtCompound(); + entry.putString("track", track); + entry.putLong("ms", millis); + laps.add(entry); + }); + nbt.put("bestLaps", laps); + return nbt; + } + + public java.util.Collection profiles() { + return profiles.values(); + } + + /** Leaderboard for a track: best laps, sorted. */ + public java.util.List> leaderboard(String trackId) { + java.util.List> list = new java.util.ArrayList<>(); + for (RacerProfile profile : profiles.values()) { + Long best = profile.bestLaps.get(trackId); + if (best != null) { + list.add(new java.util.AbstractMap.SimpleEntry<>(profile.name, best)); + } + } + list.sort(Map.Entry.comparingByValue()); + return list; + } +} diff --git a/src/main/java/dev/qynl/racers/track/Track.java b/src/main/java/dev/qynl/racers/track/Track.java new file mode 100644 index 0000000..29fa9f8 --- /dev/null +++ b/src/main/java/dev/qynl/racers/track/Track.java @@ -0,0 +1,140 @@ +package dev.qynl.racers.track; + +import java.util.ArrayList; +import java.util.List; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; + +import net.minecraft.util.math.MathHelper; + +/** + * A circuit definition, stored as JSON (stock tracks ship inside the jar, player + * made ones live in {@code /fabricracers/tracks/}). + * + *

All coordinates are "local": 0,0 is the corner of the structure file and + * the road always starts heading +X. A {@link TrackPlacement} turns them into + * world coordinates. + */ +public final class Track { + public final String id; + public final String name; + public final String shortName; + public final String structure; + public final String author; + public final int width; + public final int defaultLaps; + public final int[] size; + public final List grid = new ArrayList<>(); + public final List checkpoints = new ArrayList<>(); + public double[] pitEntry; + public double[] pitExit; + public double[] pitBox; + public final boolean stock; + + private Track(String id, String name, String shortName, String structure, String author, + int width, int defaultLaps, int[] size, boolean stock) { + this.id = id; + this.name = name; + this.shortName = shortName; + this.structure = structure; + this.author = author; + this.width = Math.max(3, width); + this.defaultLaps = Math.max(1, defaultLaps); + this.size = size; + this.stock = stock; + } + + public static Track fromJson(String id, JsonObject json, int[] size, boolean stock) { + Track track = new Track(id, + string(json, "name", id), + string(json, "short_name", id.substring(0, Math.min(3, id.length())).toUpperCase()), + string(json, "structure", "fabricracers:tracks/" + id), + string(json, "author", stock ? "FabricRacers" : "player"), + intOr(json, "width", 7), + intOr(json, "default_laps", 2), + size == null ? new int[]{1, 1, 1} : size, + stock); + if (json.has("grid")) { + for (JsonElement element : json.getAsJsonArray("grid")) { + JsonArray point = element.getAsJsonArray(); + track.grid.add(new double[]{num(point, 0), num(point, 2)}); + } + } + if (json.has("checkpoints")) { + for (JsonElement element : json.getAsJsonArray("checkpoints")) { + JsonArray point = element.getAsJsonArray(); + track.checkpoints.add(new double[]{num(point, 0), num(point, 2), + point.size() > 3 ? num(point, 3) : 0.0}); + } + } + if (json.has("pit") && json.get("pit").isJsonObject()) { + JsonObject pit = json.getAsJsonObject("pit"); + track.pitEntry = pitArray(pit, "entry"); + track.pitExit = pitArray(pit, "exit"); + track.pitBox = pitArray(pit, "box"); + } + return track; + } + + private static double[] pitArray(JsonObject pit, String key) { + if (!pit.has(key)) { + return null; + } + JsonArray array = pit.getAsJsonArray(key); + double[] out = new double[array.size()]; + for (int i = 0; i < array.size(); i++) { + out[i] = array.get(i).getAsDouble(); + } + return out; + } + + private static double num(JsonArray array, int index) { + return index < array.size() ? array.get(index).getAsDouble() : 0.0; + } + + private static String string(JsonObject json, String key, String fallback) { + return json.has(key) ? json.get(key).getAsString() : fallback; + } + + private static int intOr(JsonObject json, String key, int fallback) { + return json.has(key) ? json.get(key).getAsInt() : fallback; + } + + public int checkpointCount() { + return checkpoints.size(); + } + + public double halfWidth() { + return (width - 1) / 2.0; + } + + public int lapLength() { + double total = 0.0; + for (int i = 0; i < checkpoints.size(); i++) { + double[] a = checkpoints.get(i); + double[] b = checkpoints.get((i + 1) % checkpoints.size()); + total += Math.hypot(b[0] - a[0], b[1] - a[1]); + } + return (int) Math.round(total); + } + + public String sizeLabel() { + return size[0] + "x" + size[2]; + } + + public int gridSlots() { + return Math.max(4, grid.size()); + } + + public String describe() { + return name + " (" + sizeLabel() + ", " + checkpointCount() + " checkpoints, ~" + + lapLength() + " blocks per lap)"; + } + + public int suggestLaps(double desiredLength) { + int lap = Math.max(60, lapLength()); + return MathHelper.clamp((int) Math.round(desiredLength / lap), 1, 12); + } +} diff --git a/src/main/java/dev/qynl/racers/track/TrackManager.java b/src/main/java/dev/qynl/racers/track/TrackManager.java new file mode 100644 index 0000000..ba320c1 --- /dev/null +++ b/src/main/java/dev/qynl/racers/track/TrackManager.java @@ -0,0 +1,428 @@ +package dev.qynl.racers.track; + +import java.io.IOException; +import java.io.InputStream; +import java.io.InputStreamReader; +import java.io.Reader; +import java.io.Writer; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import dev.qynl.racers.FabricRacers; +import dev.qynl.racers.net.ModNetworking; + +/** + * Owns every circuit definition and every placed copy of one. + * + *

Stock circuits live inside the jar, player made ones in the world folder. + * Placements are stored in {@code placements.json} so a world remembers its + * tracks and the race engine can find them again after a restart. + */ +public final class TrackManager { + private static final Map INSTANCES = new HashMap<>(); + private static final Gson GSON = new GsonBuilder().setPrettyPrinting().create(); + private static final String[] STOCK_FALLBACK = { + "asphalt_park", "kart_circuit", "speed_oval", "chaos_junction", "drift_arena"}; + + private final MinecraftServer server; + private final Map tracks = new LinkedHashMap<>(); + private final List placements = new ArrayList<>(); + private final Map structures = new HashMap<>(); + private final Map wandAnchors = new HashMap<>(); + private final Map wandRotations = new HashMap<>(); + private final Map recorders = new HashMap<>(); + + private TrackManager(MinecraftServer server) { + this.server = server; + } + + public static TrackManager get(MinecraftServer server) { + if (server == null) { + return null; + } + return INSTANCES.computeIfAbsent(server, TrackManager::new); + } + + public static void forget(MinecraftServer server) { + INSTANCES.remove(server); + } + + public Path worldDir() { + return server.getSavePath(net.minecraft.util.WorldSavePath.ROOT) + .resolve("fabricracers").resolve("tracks"); + } + + // ------------------------------------------------------------------ + // loading + // ------------------------------------------------------------------ + public void load() { + tracks.clear(); + placements.clear(); + structures.clear(); + + // stock tracks from the jar + for (String id : stockIds()) { + InputStream stream = TrackManager.class.getResourceAsStream( + "/data/fabricracers/tracks/" + id + ".json"); + if (stream == null) { + continue; + } + try (Reader reader = new InputStreamReader(stream, StandardCharsets.UTF_8)) { + Track track = Track.fromJson(id, JsonParser.parseReader(reader).getAsJsonObject(), + sizeOf(id), true); + tracks.put(id, track); + } catch (Exception exception) { + FabricRacers.LOGGER.error("Could not load stock track {}", id, exception); + } + } + + // player tracks from the world + Path path = worldDir(); + if (Files.isDirectory(path)) { + try (var stream = Files.list(path)) { + for (Path file : stream.filter(f -> f.toString().endsWith(".json") + && !f.getFileName().toString().startsWith("_")).toList()) { + String fileName = file.getFileName().toString(); + String id = fileName.substring(0, fileName.length() - 5); + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + Track track = Track.fromJson(id, JsonParser.parseReader(reader).getAsJsonObject(), + sizeOf(id), false); + tracks.put(id, track); + } catch (Exception exception) { + FabricRacers.LOGGER.warn("Skipping unreadable track {}", id, exception); + } + } + } catch (IOException exception) { + FabricRacers.LOGGER.error("Could not read the tracks folder", exception); + } + } + + loadPlacements(); + FabricRacers.LOGGER.info("Loaded {} circuits and {} placed tracks", + tracks.size(), placements.size()); + } + + private List stockIds() { + InputStream index = TrackManager.class.getResourceAsStream("/data/fabricracers/tracks/_index.json"); + if (index != null) { + try (Reader reader = new InputStreamReader(index, StandardCharsets.UTF_8)) { + JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); + List ids = new ArrayList<>(); + for (JsonElement element : json.getAsJsonArray("tracks")) { + ids.add(element.getAsString()); + } + if (!ids.isEmpty()) { + return ids; + } + } catch (Exception exception) { + FabricRacers.LOGGER.warn("Falling back to the built-in track list", exception); + } + } + return List.of(STOCK_FALLBACK); + } + + public int[] sizeOf(String id) { + TrackStructure structure = structure(id); + return structure == null ? new int[]{1, 1, 1} : structure.size(); + } + + public TrackStructure structure(String id) { + TrackStructure cached = structures.get(id); + if (cached != null) { + return cached; + } + TrackStructure loaded = TrackStructure.load(worldDir(), id); + if (loaded != null) { + structures.put(id, loaded); + } + return loaded; + } + + private void loadPlacements() { + Path file = worldDir().resolve("placements.json"); + if (!Files.exists(file)) { + return; + } + try (Reader reader = Files.newBufferedReader(file, StandardCharsets.UTF_8)) { + JsonObject json = JsonParser.parseReader(reader).getAsJsonObject(); + for (JsonElement element : json.getAsJsonArray("placements")) { + JsonObject entry = element.getAsJsonObject(); + Track track = tracks.get(entry.get("track").getAsString()); + if (track == null) { + continue; + } + TrackPlacement placement = new TrackPlacement( + UUID.fromString(entry.get("id").getAsString()), track, + new BlockPos(entry.get("x").getAsInt(), entry.get("y").getAsInt(), + entry.get("z").getAsInt()), + entry.has("rotation") ? entry.get("rotation").getAsInt() : 0); + placements.add(placement); + } + } catch (Exception exception) { + FabricRacers.LOGGER.error("Could not read placements.json", exception); + } + } + + public void save() { + Path file = worldDir().resolve("placements.json"); + JsonObject json = new JsonObject(); + JsonArray array = new JsonArray(); + for (TrackPlacement placement : placements) { + JsonObject entry = new JsonObject(); + entry.addProperty("id", placement.id.toString()); + entry.addProperty("track", placement.track.id); + entry.addProperty("x", placement.anchor.getX()); + entry.addProperty("y", placement.anchor.getY()); + entry.addProperty("z", placement.anchor.getZ()); + entry.addProperty("rotation", placement.rotation); + array.add(entry); + } + json.add("placements", array); + try { + Files.createDirectories(worldDir()); + try (Writer writer = Files.newBufferedWriter(file, StandardCharsets.UTF_8)) { + GSON.toJson(json, writer); + } + } catch (IOException exception) { + FabricRacers.LOGGER.error("Could not save placements.json", exception); + } + } + + public void tick() { + for (TrackRecorder recorder : recorders.values()) { + recorder.tick(); + } + } + + // ------------------------------------------------------------------ + // accessors + // ------------------------------------------------------------------ + public List all() { + return new ArrayList<>(tracks.values()); + } + + public Track track(String id) { + return tracks.get(id); + } + + public List placements() { + return placements; + } + + public TrackPlacement placementNear(Vec3d pos, double radius) { + TrackPlacement best = null; + double bestDistance = radius; + for (TrackPlacement placement : placements) { + double distance = placement.distanceTo(pos); + if (distance < bestDistance && placement.contains(pos, 40.0)) { + bestDistance = distance; + best = placement; + } + } + if (best == null) { + for (TrackPlacement placement : placements) { + double distance = placement.distanceTo(pos); + if (distance < bestDistance) { + bestDistance = distance; + best = placement; + } + } + } + return best; + } + + public TrackPlacement placementAt(BlockPos pos) { + for (TrackPlacement placement : placements) { + if (placement.contains(pos.toCenterPos(), 0.0)) { + return placement; + } + } + return null; + } + + // ------------------------------------------------------------------ + // wand bookkeeping + // ------------------------------------------------------------------ + public void markWandAnchor(ServerPlayerEntity player, BlockPos pos) { + wandAnchors.put(player.getUuid(), pos.toImmutable()); + wandRotations.put(player.getUuid(), suggestedRotation(player)); + } + + public BlockPos wandAnchor(ServerPlayerEntity player) { + return wandAnchors.get(player.getUuid()); + } + + /** Tracks are laid out heading away from the way the player is looking. */ + public int suggestedRotation(ServerPlayerEntity player) { + float yaw = player.getYaw(); + if (yaw >= -45.0F && yaw < 45.0F) { + return 0; // south, design +X is south-east, close enough + } + if (yaw >= 45.0F && yaw < 135.0F) { + return 90; + } + if (yaw >= -135.0F && yaw < -45.0F) { + return 270; + } + return 180; + } + + // ------------------------------------------------------------------ + // placing / removing + // ------------------------------------------------------------------ + public boolean placeTrack(ServerPlayerEntity player, Track track, BlockPos anchor, int rotation) { + TrackStructure structure = structure(track.id); + if (structure == null) { + ModNetworking.send(player, ModNetworking.ServerAction.of( + ModNetworking.ACT_PLACE_RESULT, 0, 0, 0, 0, + "That track's structure file is missing.")); + return false; + } + if (overlapsExisting(anchor, rotation, structure)) { + ModNetworking.send(player, ModNetworking.ServerAction.of( + ModNetworking.ACT_PLACE_RESULT, 0, 0, 0, 0, + "There is already a track here. Remove it first (sneak + wand).")); + return false; + } + long start = System.currentTimeMillis(); + int placed = structure.place(player.getServerWorld(), anchor, rotation, null, null); + TrackPlacement placement = new TrackPlacement(UUID.randomUUID(), track, anchor, rotation); + placements.add(placement); + save(); + syncAll(); + player.sendMessage(Text.translatable("message.fabricracers.track_placed", + track.name, placed, track.sizeLabel()).formatted(Formatting.GREEN), false); + player.sendMessage(Text.translatable("message.fabricracers.track_placed_tip") + .formatted(Formatting.GRAY), false); + ModNetworking.send(player, ModNetworking.ServerAction.of( + ModNetworking.ACT_PLACE_RESULT, 0, 1, placed, 0, + "Placed " + track.name + " (" + placed + " blocks in " + + (System.currentTimeMillis() - start) + "ms)")); + // sanity: are there checkpoint markers where the JSON says? + FabricRacers.LOGGER.info("Placed {} at {} ({}) - {} blocks", track.id, anchor, rotation, placed); + return true; + } + + private boolean overlapsExisting(BlockPos anchor, int rotation, TrackStructure structure) { + int sx = structure.size()[0]; + int sz = structure.size()[2]; + int width = rotation % 180 == 0 ? sx : sz; + int depth = rotation % 180 == 0 ? sz : sx; + for (TrackPlacement placement : placements) { + boolean xOverlap = anchor.getX() < placement.anchor.getX() + placement.track.size[0] + && placement.anchor.getX() < anchor.getX() + width; + boolean zOverlap = anchor.getZ() < placement.anchor.getZ() + placement.track.size[2] + && placement.anchor.getZ() < anchor.getZ() + depth; + if (xOverlap && zOverlap) { + return true; + } + } + return false; + } + + public boolean removePlacement(ServerPlayerEntity player, TrackPlacement placement, boolean clearBlocks) { + placements.remove(placement); + save(); + int cleared = 0; + if (clearBlocks) { + TrackStructure structure = structure(placement.track.id); + if (structure != null) { + cleared = structure.clear(player.getServerWorld(), placement.anchor, placement.rotation); + } + } + syncAll(); + player.sendMessage(Text.translatable("message.fabricracers.track_removed", + placement.track.name, cleared).formatted(Formatting.YELLOW), false); + return true; + } + + /** Registers a player made track: writes its structure + definition next to the world. */ + public Track registerTrack(String id, Track track, TrackStructure structure) { + try { + Files.createDirectories(worldDir()); + Path nbt = worldDir().resolve(id + ".nbt"); + if (!Files.exists(nbt)) { + TrackStructure.write(nbt, structure::writeCompressed); + } + } catch (IOException exception) { + FabricRacers.LOGGER.error("Could not write track structure {}", id, exception); + } + tracks.put(id, track); + JsonObject json = new JsonObject(); + json.addProperty("name", track.name); + json.addProperty("short_name", track.shortName); + json.addProperty("structure", track.structure); + json.addProperty("width", track.width); + json.addProperty("default_laps", track.defaultLaps); + json.addProperty("author", track.author); + JsonArray grid = new JsonArray(); + for (double[] slot : track.grid) { + JsonArray point = new JsonArray(); + point.add(slot[0]); + point.add(TrackStructure.CAR_Y); + point.add(slot[1]); + grid.add(point); + } + json.add("grid", grid); + JsonArray checkpoints = new JsonArray(); + for (double[] cp : track.checkpoints) { + JsonArray point = new JsonArray(); + point.add(cp[0]); + point.add(TrackStructure.CAR_Y); + point.add(cp[1]); + point.add(cp[2]); + checkpoints.add(point); + } + json.add("checkpoints", checkpoints); + try (Writer writer = Files.newBufferedWriter(worldDir().resolve(id + ".json"), + StandardCharsets.UTF_8)) { + GSON.toJson(json, writer); + } catch (IOException exception) { + FabricRacers.LOGGER.error("Could not write track definition {}", id, exception); + } + syncAll(); + return track; + } + + public TrackRecorder recorder(ServerPlayerEntity player) { + return recorders.computeIfAbsent(player.getUuid(), id -> new TrackRecorder(server, player)); + } + + public void stopRecording(ServerPlayerEntity player) { + recorders.remove(player.getUuid()); + } + + // ------------------------------------------------------------------ + // sync + // ------------------------------------------------------------------ + public void syncTo(ServerPlayerEntity player) { + ModNetworking.sendPlacements(player, this); + } + + public void syncAll() { + for (ServerPlayerEntity player : ModNetworking.players(server)) { + syncTo(player); + } + } +} diff --git a/src/main/java/dev/qynl/racers/track/TrackPlacement.java b/src/main/java/dev/qynl/racers/track/TrackPlacement.java new file mode 100644 index 0000000..a6552b9 --- /dev/null +++ b/src/main/java/dev/qynl/racers/track/TrackPlacement.java @@ -0,0 +1,173 @@ +package dev.qynl.racers.track; + +import java.util.ArrayList; +import java.util.List; +import java.util.UUID; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import dev.qynl.racers.ai.RacingLine; + +/** + * One physical copy of a {@link Track} in the world. + * + *

The structure file is always written with its lowest corner at 0,0, so a + * placement is just: corner position + rotation + the dimensions of the file. + * Everything the race engine needs (grid, checkpoints, racing line) is then + * transformed on the fly. + */ +public final class TrackPlacement { + public final UUID id; + public final Track track; + public final BlockPos anchor; + /** 0, 90, 180 or 270 degrees clockwise (Minecraft yaw). */ + public final int rotation; + private RacingLine line; + + public TrackPlacement(UUID id, Track track, BlockPos anchor, int rotation) { + this.id = id; + this.track = track; + this.anchor = anchor.toImmutable(); + this.rotation = ((rotation % 360) + 360) % 360; + } + + /** Rotation matrix for this placement. */ + private double[] rotate(double x, double z) { + return switch (rotation) { + case 90 -> new double[]{-z, x}; + case 180 -> new double[]{-x, -z}; + case 270 -> new double[]{z, -x}; + default -> new double[]{x, z}; + }; + } + + /** The offset applied after rotation so the structure still grows towards +X/+Z. */ + private double[] shift() { + int sx = Math.max(1, track.size[0]); + int sz = Math.max(1, track.size[2]); + return switch (rotation) { + case 90 -> new double[]{sz - 1.0, 0.0}; + case 180 -> new double[]{sx - 1.0, sz - 1.0}; + case 270 -> new double[]{0.0, sx - 1.0}; + default -> new double[]{0.0, 0.0}; + }; + } + + /** Local block coordinate (structure space) -> world block position, unrotated. */ + public BlockPos localToWorld(int x, int y, int z) { + double[] r = rotate(x, z); + double[] s = shift(); + return new BlockPos((int) Math.round(anchor.getX() + r[0] + s[0]), + anchor.getY() + y, + (int) Math.round(anchor.getZ() + r[1] + s[1])); + } + + /** Local point (usually a checkpoint) -> world position at the given local Y. */ + public Vec3d pointToWorld(double x, double z, double y) { + double[] r = rotate(x, z); + double[] s = shift(); + return new Vec3d(anchor.getX() + r[0] + s[0], anchor.getY() + y, anchor.getZ() + r[1] + s[1]); + } + + public float yawToWorld(double localYaw) { + return (float) (((localYaw + rotation) % 360.0 + 360.0) % 360.0); + } + + /** World position of a grid slot (car feet position). */ + public Vec3d gridPos(int index) { + if (track.grid.isEmpty()) { + return pointToWorld(track.halfWidth(), track.halfWidth(), TrackStructure.CAR_Y); + } + double[] slot = track.grid.get(Math.floorMod(index, track.grid.size())); + return pointToWorld(slot[0], slot[1], TrackStructure.CAR_Y); + } + + public int gridSlots() { + return Math.max(4, track.grid.size()); + } + + /** World position of the start line centre. */ + public Vec3d startPos() { + if (track.checkpoints.isEmpty()) { + return gridPos(0); + } + double[] cp = track.checkpoints.get(0); + return pointToWorld(cp[0], cp[1], TrackStructure.CAR_Y); + } + + public float startYaw() { + if (track.checkpoints.isEmpty()) { + return yawToWorld(0); + } + double[] cp = track.checkpoints.get(0); + return yawToWorld(cp.length > 2 ? cp[2] : 0.0); + } + + public Vec3d checkpointPos(int index) { + if (track.checkpoints.isEmpty()) { + return startPos(); + } + double[] cp = track.checkpoints.get(Math.floorMod(index, track.checkpoints.size())); + return pointToWorld(cp[0], cp[1], TrackStructure.CAR_Y); + } + + public int checkpointCount() { + return Math.max(1, track.checkpoints.size()); + } + + /** Lazily built closed racing line in world coordinates. */ + public RacingLine line() { + if (line == null) { + List points = new ArrayList<>(); + for (double[] cp : track.checkpoints) { + Vec3d world = pointToWorld(cp[0], cp[1], TrackStructure.CAR_Y); + points.add(new double[]{world.x, world.z}); + } + if (points.size() >= 3) { + line = RacingLine.fromWaypoints(points, track.halfWidth(), 40); + } + if (line == null) { + List fallback = new ArrayList<>(); + for (int i = 0; i < gridSlots(); i++) { + Vec3d world = gridPos(i); + fallback.add(new double[]{world.x, world.z}); + } + if (fallback.size() >= 3) { + line = RacingLine.fromWaypoints(fallback, track.halfWidth(), 30); + } + } + } + return line; + } + + public Vec3d pitBox() { + if (track.pitBox == null) { + return null; + } + return pointToWorld(track.pitBox[0], track.pitBox.length > 2 ? track.pitBox[2] : 0.0, + track.pitBox.length > 1 ? track.pitBox[1] : TrackStructure.CAR_Y); + } + + /** Rough bounds of the placed structure, used for the "you are here" checks. */ + public boolean contains(Vec3d pos, double margin) { + int sx = Math.max(1, track.size[0]); + int sz = Math.max(1, track.size[2]); + double xs = anchor.getX() - margin; + double zs = anchor.getZ() - margin; + double xe = anchor.getX() + (rotation % 180 == 0 ? sx : sz) + margin; + double ze = anchor.getZ() + (rotation % 180 == 0 ? sz : sx) + margin; + return pos.x >= Math.min(xs, xe) && pos.x <= Math.max(xs, xe) + && pos.z >= Math.min(zs, ze) && pos.z <= Math.max(zs, ze); + } + + public double distanceTo(Vec3d pos) { + Vec3d centre = new Vec3d(anchor.getX() + track.size[0] / 2.0, anchor.getY(), + anchor.getZ() + track.size[2] / 2.0); + return centre.distanceTo(pos); + } + + public String label() { + return track.name + " @ " + anchor.getX() + "," + anchor.getY() + "," + anchor.getZ(); + } +} diff --git a/src/main/java/dev/qynl/racers/track/TrackRecorder.java b/src/main/java/dev/qynl/racers/track/TrackRecorder.java new file mode 100644 index 0000000..a85b584 --- /dev/null +++ b/src/main/java/dev/qynl/racers/track/TrackRecorder.java @@ -0,0 +1,288 @@ +package dev.qynl.racers.track; + +import java.util.ArrayList; +import java.util.List; + +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +import net.minecraft.util.Formatting; +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Vec3d; + +import dev.qynl.racers.car.CarEntity; +import dev.qynl.racers.net.ModNetworking; + +/** + * "Build a circuit by driving it": records where a car goes, then bakes the + * route into a real track structure. + * + *

Everything the Python generator produces can also be produced from a lap + * driven in game, because both write the same structure layout. + */ +public final class TrackRecorder { + private static final double SAMPLE_SPACING = 1.0; + private static final int MAX_SAMPLES = 3000; + + private final MinecraftServer server; + private final ServerPlayerEntity player; + private final List path = new ArrayList<>(); + private final List headings = new ArrayList<>(); + private int width = 7; + private boolean barriers = true; + private boolean finished; + + public TrackRecorder(MinecraftServer server, ServerPlayerEntity player) { + this.server = server; + this.player = player; + Vec3d pos = player.getPos(); + path.add(new double[]{pos.x, pos.y, pos.z}); + headings.add(player.getYaw()); + } + + public void setWidth(int width) { + this.width = Math.max(3, Math.min(15, width)); + } + + public void setBarriers(boolean barriers) { + this.barriers = barriers; + } + + public int samples() { + return path.size(); + } + + public boolean isFinished() { + return finished; + } + + public double length() { + double total = 0.0; + for (int i = 1; i < path.size(); i++) { + total += Math.hypot(path.get(i)[0] - path.get(i - 1)[0], + path.get(i)[2] - path.get(i - 1)[2]); + } + return total; + } + + /** Called every server tick while the recording is running. */ + public void tick() { + CarEntity car = player.getVehicle() instanceof CarEntity c ? c : null; + if (car == null) { + return; + } + Vec3d pos = car.getPos(); + double[] last = path.get(path.size() - 1); + double distance = Math.hypot(pos.x - last[0], pos.z - last[2]); + if (distance < SAMPLE_SPACING) { + return; + } + if (path.size() >= MAX_SAMPLES) { + stop(); + return; + } + path.add(new double[]{pos.x, pos.y, pos.z}); + headings.add(car.getYaw()); + if (path.size() % 25 == 0) { + ModNetworking.sendRecordState(player, 1, + String.format("%d samples, %.0f blocks", path.size(), length())); + } + } + + /** Bakes the recording into a track and registers it. */ + public Track stop() { + finished = true; + if (path.size() < 40) { + player.sendMessage(Text.translatable("message.fabricracers.record_too_short") + .formatted(Formatting.RED), false); + TrackManager.get(server).stopRecording(player); + return null; + } + // drop the tail: the start and the end would otherwise overlap + int count = path.size(); + if (Math.hypot(path.get(0)[0] - path.get(count - 1)[0], + path.get(0)[2] - path.get(count - 1)[2]) < 12.0) { + path.remove(count - 1); + headings.remove(headings.size() - 1); + count--; + } + + double minX = Double.MAX_VALUE; + double minZ = Double.MAX_VALUE; + double minY = Double.MAX_VALUE; + for (double[] point : path) { + minX = Math.min(minX, point[0]); + minZ = Math.min(minZ, point[2]); + minY = Math.min(minY, point[1]); + } + int pad = width + 6; + int sizeX = (int) Math.ceil((maxX() - minX)) + pad * 2 + 2; + int sizeZ = (int) Math.ceil((maxZ() - minZ)) + pad * 2 + 2; + int sizeY = 10; + + TrackStructure.Builder builder = new TrackStructure.Builder(sizeX, sizeY, sizeZ); + // recorded circuits are flattened to the level the lap started on, which + // keeps grid slots, checkpoints and the road on the same plane + double baseY = path.get(0)[1]; + int half = (width - 1) / 2; + + // road, kerbs, verge + for (int i = 0; i < path.size(); i++) { + double[] point = path.get(i); + double[] next = path.get((i + 1) % path.size()); + double dx = next[0] - point[0]; + double dz = next[2] - point[2]; + double length = Math.max(1.0e-4, Math.hypot(dx, dz)); + double rx = -dz / length; + double rz = dx / length; + int px = (int) Math.round(point[0] - minX) + pad; + int pz = (int) Math.round(point[2] - minZ) + pad; + final int py = 1; + builder.set(px, 0, pz, "minecraft:dirt"); + for (int o = -half; o <= half; o++) { + int x = (int) Math.round(point[0] - minX) + pad + (int) Math.round(rx * o); + int z = (int) Math.round(point[2] - minZ) + pad + (int) Math.round(rz * o); + builder.set(x, 0, z, "minecraft:dirt"); + builder.set(x, py, z, "fabricracers:asphalt"); + } + for (int side = -1; side <= 1; side += 2) { + int x = (int) Math.round(point[0] - minX) + pad + + (int) Math.round(rx * (half + 1) * side); + int z = (int) Math.round(point[2] - minZ) + pad + + (int) Math.round(rz * (half + 1) * side); + String color = ((i / 2) % 2 == 0) ? "red" : "white"; + builder.set(x, py, z, "fabricracers:kerb", "color", color); + if (barriers) { + int bx = (int) Math.round(point[0] - minX) + pad + + (int) Math.round(rx * (half + 2) * side); + int bz = (int) Math.round(point[2] - minZ) + pad + + (int) Math.round(rz * (half + 2) * side); + builder.set(bx, py + 1, bz, "fabricracers:safety_barrier"); + builder.set(bx, py + 2, bz, "fabricracers:safety_barrier"); + } + } + } + + // checkpoints + start line every lap-ish + int checkpointSpacing = Math.max(20, path.size() / 12); + for (int i = 0; i < path.size(); i += checkpointSpacing) { + double[] point = path.get(i); + double[] next = path.get((i + 8) % path.size()); + double dx = next[0] - point[0]; + double dz = next[2] - point[2]; + double length = Math.max(1.0e-4, Math.hypot(dx, dz)); + double rx = -dz / length; + double rz = dx / length; + for (int o = -half; o <= half; o++) { + int x = (int) Math.round(point[0] - minX) + pad + (int) Math.round(rx * o); + int z = (int) Math.round(point[2] - minZ) + pad + (int) Math.round(rz * o); + builder.set(x, 2, z, "fabricracers:checkpoint_marker", "lit", "false"); + } + } + + TrackStructure structure = builder.build(); + String id = "player_" + player.getName().getString().toLowerCase().replaceAll("[^a-z0-9]", ""); + if (TrackManager.get(server).track(id) != null) { + id = id + "_" + (int) (System.currentTimeMillis() % 10000); + } + ModNetworking.sendRecordState(player, 2, id); + return finishRegistration(id, structure, baseY, pad, minX, minZ); + } + + private Track finishRegistration(String id, TrackStructure structure, double baseY, int pad, + double minX, double minZ) { + TrackManager manager = TrackManager.get(server); + String json = "{\"name\":\"" + player.getName().getString() + "'s circuit\"," + + "\"short_name\":\"REC\",\"structure\":\"fabricracers:tracks/" + id + "\"," + + "\"width\":" + width + ",\"default_laps\":2,\"author\":\"" + + player.getName().getString() + "\"" + + ",\"grid\":" + gridJson(pad, minX, minZ) + + ",\"checkpoints\":" + checkpointJson(pad, minX, minZ) + + "}"; + Track track; + try { + track = Track.fromJson(id, com.google.gson.JsonParser.parseString(json).getAsJsonObject(), + structure.size(), false); + } catch (Exception exception) { + player.sendMessage(Text.translatable("message.fabricracers.record_failed") + .formatted(Formatting.RED), false); + TrackManager.get(server).stopRecording(player); + return null; + } + manager.registerTrack(id, track, structure); + BlockPos anchor = new BlockPos((int) Math.floor(minX) - pad, (int) Math.floor(baseY), + (int) Math.floor(minZ) - pad); + manager.placeTrack(player, track, anchor, 0); + player.sendMessage(Text.translatable("message.fabricracers.record_done", track.name, + structure.size()[0] + "x" + structure.size()[2]).formatted(Formatting.GREEN), false); + dev.qynl.racers.stats.StatsManager.get(server).get(player).tracksRecorded++; + dev.qynl.racers.stats.StatsManager.get(server).markDirty(); + manager.stopRecording(player); + return track; + } + + private double maxX() { + double max = -Double.MAX_VALUE; + for (double[] point : path) { + max = Math.max(max, point[0]); + } + return max; + } + + private double maxZ() { + double max = -Double.MAX_VALUE; + for (double[] point : path) { + max = Math.max(max, point[2]); + } + return max; + } + + private String gridJson(int pad, double minX, double minZ) { + StringBuilder builder = new StringBuilder("["); + int rows = Math.min(8, Math.max(4, path.size() / 60)); + for (int i = 0; i < rows; i++) { + int index = (path.size() - 1 - i * 3 + path.size()) % path.size(); + double[] point = path.get(index); + double[] behind = path.get((index + 2) % path.size()); + double dx = point[0] - behind[0]; + double dz = point[2] - behind[2]; + double length = Math.max(1.0e-4, Math.hypot(dx, dz)); + double rx = -dz / length; + double rz = dx / length; + double lane = (i % 2 == 0 ? 1.4 : -1.4); + double x = point[0] + rx * lane - minX + pad; + double z = point[2] + rz * lane - minZ + pad; + if (i > 0) { + builder.append(','); + } + builder.append('[').append(round(x)).append(",2,").append(round(z)).append(']'); + } + return builder.append(']').toString(); + } + + private String checkpointJson(int pad, double minX, double minZ) { + StringBuilder builder = new StringBuilder("["); + int spacing = Math.max(20, path.size() / 10); + boolean first = true; + for (int i = 0; i < path.size(); i += spacing) { + double[] point = path.get(i); + double[] next = path.get((i + 6) % path.size()); + double dx = next[0] - point[0]; + double dz = next[2] - point[2]; + double yaw = Math.toDegrees(Math.atan2(-dx, dz)); + double x = point[0] - minX + pad; + double z = point[2] - minZ + pad; + if (!first) { + builder.append(','); + } + first = false; + builder.append('[').append(round(x)).append(",2,").append(round(z)).append(',') + .append(round(yaw)).append(']'); + } + return builder.append(']').toString(); + } + + private static String round(double value) { + return String.format(java.util.Locale.ROOT, "%.1f", value); + } +} diff --git a/src/main/java/dev/qynl/racers/track/TrackStructure.java b/src/main/java/dev/qynl/racers/track/TrackStructure.java new file mode 100644 index 0000000..bdf5837 --- /dev/null +++ b/src/main/java/dev/qynl/racers/track/TrackStructure.java @@ -0,0 +1,376 @@ +package dev.qynl.racers.track; + +import java.io.DataInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.Optional; + +import net.minecraft.block.Block; +import net.minecraft.block.BlockState; +import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtElement; +import net.minecraft.nbt.NbtIo; +import net.minecraft.nbt.NbtList; +import net.minecraft.nbt.NbtSizeTracker; +import net.minecraft.registry.Registries; +import net.minecraft.state.property.Property; +import net.minecraft.util.Identifier; +import net.minecraft.util.math.BlockPos; +import net.minecraft.world.World; + +import dev.qynl.racers.FabricRacers; + +/** + * Reads and writes structure files in the vanilla layout (palette + block list). + * + *

Deliberately hand rolled: the mod needs exact control over palette + * remapping (repainting a whole circuit) and it must work with the files the + * Python track generator produces, plus the ones players create by driving. + */ +public final class TrackStructure { + /** Local Y of the road surface blocks inside a track structure. */ + public static final int ROAD_Y = 1; + /** Local Y where cars stand (the structure is placed anchor + this). */ + public static final int CAR_Y = 2; + + private final int[] size; + private final List entries; + private final NbtCompound raw; + + public record Entry(int x, int y, int z, String name, Map properties) { + } + + private TrackStructure(int[] size, List entries, NbtCompound raw) { + this.size = size; + this.entries = entries; + this.raw = raw; + } + + public int[] size() { + return size; + } + + public List entries() { + return entries; + } + + public NbtCompound rawNbt() { + return raw; + } + + public static TrackStructure read(InputStream stream) throws IOException { + NbtCompound root = NbtIo.readCompressed(stream, NbtSizeTracker.ofUnlimitedBytes()); + return fromNbt(root); + } + + @SuppressWarnings("unchecked") + public static TrackStructure fromNbt(NbtCompound root) { + NbtList sizeList = root.getList("size", NbtElement.INT_TYPE); + int[] size = new int[]{ + sizeList.size() > 0 ? sizeList.getInt(0) : 1, + sizeList.size() > 1 ? sizeList.getInt(1) : 1, + sizeList.size() > 2 ? sizeList.getInt(2) : 1}; + + List> palette = new ArrayList<>(); + NbtList paletteList = root.getList("palette", NbtElement.COMPOUND_TYPE); + for (int i = 0; i < paletteList.size(); i++) { + NbtCompound entry = paletteList.getCompound(i); + Map props = new HashMap<>(); + if (entry.contains("Properties")) { + NbtCompound properties = entry.getCompound("Properties"); + for (String key : properties.getKeys()) { + props.put(key, properties.getString(key)); + } + } + props.put("__name", entry.getString("Name")); + palette.add(props); + } + + List entries = new ArrayList<>(); + NbtList blocks = root.getList("blocks", NbtElement.COMPOUND_TYPE); + for (int i = 0; i < blocks.size(); i++) { + NbtCompound block = blocks.getCompound(i); + NbtList pos = block.getList("pos", NbtElement.INT_TYPE); + int state = block.getInt("state"); + if (state < 0 || state >= palette.size()) { + continue; + } + Map props = palette.get(state); + String name = props.get("__name"); + Map clean = new HashMap<>(props); + clean.remove("__name"); + entries.add(new Entry(pos.getInt(0), pos.getInt(1), pos.getInt(2), name, clean)); + } + return new TrackStructure(size, entries, root); + } + + /** Turns "minecraft:stone_slab" + {"type":"top"} into a real block state. */ + public static BlockState parseState(String name, Map properties) { + Identifier identifier = Identifier.tryParse(name); + if (identifier == null || !Registries.BLOCK.containsId(identifier)) { + return null; + } + Block block = Registries.BLOCK.get(identifier); + BlockState state = block.getDefaultState(); + if (properties != null) { + for (Map.Entry entry : properties.entrySet()) { + Property property = state.getBlock().getStateManager().getProperty(entry.getKey()); + if (property == null) { + continue; + } + state = withProperty(state, property, entry.getValue()); + } + } + return state; + } + + @SuppressWarnings({"unchecked", "rawtypes"}) + private static BlockState withProperty(BlockState state, Property property, String value) { + Optional parsed = property.parse(value); + if (parsed.isEmpty()) { + return state; + } + return state.with(property, (Comparable) parsed.get()); + } + + /** + * Places the whole structure into the world. + * + * @param mirror the block-symmetry handler, or null for a straight build + * @param progress receives (placed, total) so the caller can report progress + */ + public int place(World world, BlockPos anchor, int rotation, BlockMirror mirror, + java.util.function.BiConsumer progress) { + double[] shift = shiftFor(rotation, size); + int placed = 0; + int total = entries.size(); + for (Entry entry : entries) { + double[] rotated = rotate(entry.x(), entry.z(), rotation); + int x = (int) Math.round(anchor.getX() + rotated[0] + shift[0]); + int y = anchor.getY() + entry.y(); + int z = (int) Math.round(anchor.getZ() + rotated[1] + shift[1]); + String name = entry.name(); + Map props = entry.properties(); + if (mirror != null) { + String mapped = mirror.map(name); + if (mapped != null) { + name = mapped; + } + props = mirror.mapProperties(name, props); + } + BlockState state = parseState(name, props); + if (state == null) { + continue; + } + BlockPos pos = new BlockPos(x, y, z); + world.setBlockState(pos, state, Block.NOTIFY_LISTENERS); + placed++; + if (progress != null && placed % 512 == 0) { + progress.accept(placed, total); + } + } + if (progress != null) { + progress.accept(placed, total); + } + return placed; + } + + /** Repaint hook: swaps block names or properties (e.g. red wool -> blue wool). */ + public interface BlockMirror { + String map(String blockName); + + default Map mapProperties(String blockName, Map properties) { + return properties; + } + } + + public static double[] rotate(double x, double z, int rotation) { + return switch (((rotation % 360) + 360) % 360) { + case 90 -> new double[]{-z, x}; + case 180 -> new double[]{-x, -z}; + case 270 -> new double[]{z, -x}; + default -> new double[]{x, z}; + }; + } + + public static double[] shiftFor(int rotation, int[] size) { + int sx = Math.max(1, size[0]); + int sz = Math.max(1, size[2]); + return switch (((rotation % 360) + 360) % 360) { + case 90 -> new double[]{sz - 1.0, 0.0}; + case 180 -> new double[]{sx - 1.0, sz - 1.0}; + case 270 -> new double[]{0.0, sx - 1.0}; + default -> new double[]{0.0, 0.0}; + }; + } + + /** Writes the raw structure back out (verbatim, keeps custom tags). */ + public void writeCompressed(java.io.OutputStream out) throws IOException { + NbtIo.writeCompressed(raw, out); + } + + /** + * Removes a placed copy: only blocks that still match the structure are + * cleared, so player edits and buildings next to a track survive. + */ + public int clear(World world, BlockPos anchor, int rotation) { + double[] shift = shiftFor(rotation, size); + int cleared = 0; + for (Entry entry : entries) { + double[] rotated = rotate(entry.x(), entry.z(), rotation); + BlockPos pos = new BlockPos((int) Math.round(anchor.getX() + rotated[0] + shift[0]), + anchor.getY() + entry.y(), + (int) Math.round(anchor.getZ() + rotated[1] + shift[1])); + BlockState current = world.getBlockState(pos); + Identifier currentId = Registries.BLOCK.getId(current.getBlock()); + if (currentId.toString().equals(entry.name()) + || currentId.getNamespace().equals("fabricracers")) { + world.setBlockState(pos, net.minecraft.block.Blocks.AIR.getDefaultState(), + Block.NOTIFY_LISTENERS); + cleared++; + } + } + return cleared; + } + + /** Loads a structure from a world folder first, then from the jar. */ + public static TrackStructure load(java.nio.file.Path worldDir, String id) { + java.nio.file.Path file = worldDir.resolve(id + ".nbt"); + try { + if (java.nio.file.Files.exists(file)) { + try (InputStream in = java.nio.file.Files.newInputStream(file)) { + return read(in); + } + } + InputStream in = TrackStructure.class.getResourceAsStream( + "/data/fabricracers/structure/tracks/" + id + ".nbt"); + if (in != null) { + try (InputStream stream = in) { + return read(stream); + } + } + } catch (IOException exception) { + FabricRacers.LOGGER.error("Could not read structure for track {}", id, exception); + } + return null; + } + + /** Writes a structure file (used by the drive-in track recorder). */ + public static void write(java.nio.file.Path file, StructureWriter writer) throws IOException { + java.nio.file.Files.createDirectories(file.getParent()); + try (java.io.OutputStream out = java.nio.file.Files.newOutputStream(file)) { + writer.writeTo(out); + } + } + + public interface StructureWriter { + void writeTo(java.io.OutputStream out) throws IOException; + } + + /** Minimal vanilla-compatible structure NBT writer. */ + public static final class Builder { + private final Map blocks = new HashMap<>(); + private final int[] size; + + public Builder(int sizeX, int sizeY, int sizeZ) { + this.size = new int[]{sizeX, sizeY, sizeZ}; + } + + public void set(int x, int y, int z, String name, String... properties) { + if (x < 0 || y < 0 || z < 0 || x >= size[0] || y >= size[1] || z >= size[2]) { + return; + } + Map props = new HashMap<>(); + for (int i = 0; i + 1 < properties.length; i += 2) { + props.put(properties[i], properties[i + 1]); + } + blocks.put(((long) x << 40) | ((long) z << 20) | y, new Entry(x, y, z, name, props)); + } + + public void box(int x0, int y0, int z0, int x1, int y1, int z1, String name) { + for (int x = Math.min(x0, x1); x <= Math.max(x0, x1); x++) { + for (int y = Math.min(y0, y1); y <= Math.max(y0, y1); y++) { + for (int z = Math.min(z0, z1); z <= Math.max(z0, z1); z++) { + set(x, y, z, name); + } + } + } + } + + public int size() { + return blocks.size(); + } + + public NbtCompound toNbt() { + List> palette = new ArrayList<>(); + Map, Integer> index = new HashMap<>(); + NbtList blockList = new NbtList(); + List ordered = new ArrayList<>(blocks.values()); + ordered.sort((a, b) -> a.y() != b.y() ? Integer.compare(a.y(), b.y()) + : (a.z() != b.z() ? Integer.compare(a.z(), b.z()) : Integer.compare(a.x(), b.x()))); + for (Entry entry : ordered) { + Map key = new HashMap<>(entry.properties()); + key.put("__name", entry.name()); + Integer state = index.get(key); + if (state == null) { + state = palette.size(); + index.put(key, state); + palette.add(key); + } + NbtCompound block = new NbtCompound(); + block.putInt("state", state); + NbtList pos = new NbtList(); + pos.add(net.minecraft.nbt.NbtInt.of(entry.x())); + pos.add(net.minecraft.nbt.NbtInt.of(entry.y())); + pos.add(net.minecraft.nbt.NbtInt.of(entry.z())); + block.put("pos", pos); + blockList.add(block); + } + NbtList paletteList = new NbtList(); + for (Map entry : palette) { + NbtCompound compound = new NbtCompound(); + compound.putString("Name", entry.get("__name")); + NbtCompound props = new NbtCompound(); + for (Map.Entry prop : entry.entrySet()) { + if (!prop.getKey().equals("__name")) { + props.putString(prop.getKey(), prop.getValue()); + } + } + if (!props.isEmpty()) { + compound.put("Properties", props); + } + paletteList.add(compound); + } + NbtCompound root = new NbtCompound(); + root.putInt("DataVersion", 3953); + NbtList sizeList = new NbtList(); + sizeList.add(net.minecraft.nbt.NbtInt.of(size[0])); + sizeList.add(net.minecraft.nbt.NbtInt.of(size[1])); + sizeList.add(net.minecraft.nbt.NbtInt.of(size[2])); + root.put("size", sizeList); + root.put("palette", paletteList); + root.put("blocks", blockList); + root.put("entities", new NbtList()); + root.putString("Author", "FabricRacers"); + return root; + } + + public void writeTo(java.io.OutputStream out) throws IOException { + NbtIo.writeCompressed(toNbt(), out); + } + + /** Convenience for reading back a written structure in tests. */ + public TrackStructure build() { + return TrackStructure.fromNbt(toNbt()); + } + } + + /** NBT io needs a random access writer in some versions; this keeps it simple. */ + public static DataInputStream streamOf(byte[] data) { + return new DataInputStream(new java.io.ByteArrayInputStream(data)); + } +} diff --git a/src/main/java/dev/qynl/racers/util/Cars.java b/src/main/java/dev/qynl/racers/util/Cars.java new file mode 100644 index 0000000..d06bee8 --- /dev/null +++ b/src/main/java/dev/qynl/racers/util/Cars.java @@ -0,0 +1,64 @@ +package dev.qynl.racers.util; + +import net.minecraft.util.math.BlockPos; +import net.minecraft.util.math.Box; +import net.minecraft.util.math.Vec3d; +import net.minecraft.world.World; + +import dev.qynl.racers.car.CarEntity; + +/** Small world queries the car code needs in a lot of places. */ +public final class Cars { + private Cars() { + } + + /** Height of the first solid-ish surface under {@code pos}, or NaN if there is none nearby. */ + public static double groundHeight(World world, Vec3d pos) { + BlockPos.Mutable mutable = BlockPos.ofFloored(pos.x, pos.y, pos.z).mutableCopy(); + int start = (int) Math.floor(pos.y) + 2; + for (int y = start; y > start - 8; y--) { + mutable.set(pos.x, y, pos.z); + var state = world.getBlockState(mutable); + if (!state.isAir() && state.isSolidBlock(world, mutable)) { + return y + 1.0; + } + } + return Double.NaN; + } + + /** Snaps a spawn position to the surface so cars never spawn inside the ground. */ + public static double snapCarY(World world, Vec3d pos) { + double ground = groundHeight(world, pos); + if (Double.isNaN(ground)) { + return pos.y; + } + return ground; + } + + public static boolean groundIsSolid(World world, BlockPos pos) { + BlockPos below = pos.down(); + var state = world.getBlockState(below); + return !state.isAir() && state.isSolidBlock(world, below); + } + + /** Finds the closest car to a point within {@code radius} blocks. */ + public static CarEntity carNear(World world, Vec3d pos, double radius) { + double best = radius * radius; + CarEntity found = null; + for (CarEntity car : world.getEntitiesByClass(CarEntity.class, + Box.of(pos, radius * 2.0, radius * 2.0, radius * 2.0), car -> !car.isRemoved())) { + double d = car.getPos().squaredDistanceTo(pos); + if (d < best) { + best = d; + found = car; + } + } + return found; + } + + /** How much room a car needs, used to sanity check the spawn spot. */ + public static Box carBox(Vec3d pos, float width, float length) { + return new Box(pos.x - width / 2.0, pos.y, pos.z - length / 2.0, + pos.x + width / 2.0, pos.y + 1.1, pos.z + length / 2.0); + } +} diff --git a/src/main/java/dev/qynl/racers/util/Interp.java b/src/main/java/dev/qynl/racers/util/Interp.java new file mode 100644 index 0000000..bdb34a7 --- /dev/null +++ b/src/main/java/dev/qynl/racers/util/Interp.java @@ -0,0 +1,51 @@ +package dev.qynl.racers.util; + +import net.minecraft.util.math.MathHelper; + +/** Tiny math helpers shared by the physics, HUD and AI code. */ +public final class Interp { + private Interp() { + } + + public static double clamp(double value, double min, double max) { + return value < min ? min : Math.min(value, max); + } + + public static float clamp(float value, float min, float max) { + return value < min ? min : Math.min(value, max); + } + + public static double lerp(double from, double to, double delta) { + return from + (to - from) * delta; + } + + public static float lerp(float from, float to, float delta) { + return from + (to - from) * delta; + } + + public static double approach(double current, double target, double step) { + if (current < target) { + return Math.min(target, current + step); + } + return Math.max(target, current - step); + } + + /** Difference between two Minecraft yaw angles, in -180..180. */ + public static double yawDelta(double from, double to) { + return MathHelper.wrapDegrees(to - from); + } + + /** Seconds as a one decimal string. */ + public static String seconds(long millis) { + return String.format("%.1fs", millis / 1000.0); + } + + public static int percent(double value) { + return (int) Math.round(clamp(value, 0.0, 1.0) * 100.0); + } + + public static double smoothstep(double value) { + double t = clamp(value, 0.0, 1.0); + return t * t * (3.0 - 2.0 * t); + } +} diff --git a/src/main/java/dev/qynl/racers/util/Sounds.java b/src/main/java/dev/qynl/racers/util/Sounds.java new file mode 100644 index 0000000..221c00d --- /dev/null +++ b/src/main/java/dev/qynl/racers/util/Sounds.java @@ -0,0 +1,22 @@ +package dev.qynl.racers.util; + +import net.minecraft.registry.entry.RegistryEntry; +import net.minecraft.sound.SoundEvent; + +/** + * Bridges the two shapes {@code SoundEvents} constants come in across 1.21 + * builds: plain {@link SoundEvent} and {@link RegistryEntry} wrappers. The + * overload is resolved at compile time, so both worlds type check. + */ +public final class Sounds { + private Sounds() { + } + + public static SoundEvent of(SoundEvent sound) { + return sound; + } + + public static SoundEvent of(RegistryEntry entry) { + return entry.value(); + } +} diff --git a/src/main/java/dev/qynl/racers/util/Stopwatch.java b/src/main/java/dev/qynl/racers/util/Stopwatch.java new file mode 100644 index 0000000..08ad345 --- /dev/null +++ b/src/main/java/dev/qynl/racers/util/Stopwatch.java @@ -0,0 +1,118 @@ +package dev.qynl.racers.util; + +import java.util.ArrayList; +import java.util.List; + +import dev.qynl.racers.car.CarEntity; + +/** + * Lap timer. The same class is used by the stopwatch item (client side, one + * instance) and by the race engine (one instance per {@code Track}). + */ +public final class Stopwatch { + private final List laps = new ArrayList<>(); + private long startedAt = -1L; + private long stoppedAt = -1L; + + public void start(long now) { + startedAt = now; + stoppedAt = -1L; + laps.clear(); + } + + public void stop(long now) { + if (startedAt >= 0) { + stoppedAt = now; + } + } + + public void reset() { + startedAt = -1L; + stoppedAt = -1L; + laps.clear(); + } + + public boolean running() { + return startedAt >= 0 && stoppedAt < 0; + } + + public long elapsed(long now) { + if (startedAt < 0) { + return 0L; + } + return (stoppedAt < 0 ? now : stoppedAt) - startedAt; + } + + public long split(long now) { + if (!running()) { + return 0L; + } + long lap = elapsed(now); + long previous = laps.isEmpty() ? 0L : laps.get(laps.size() - 1); + laps.add(lap); + return lap - previous; + } + + public List laps() { + return laps; + } + + public long best() { + long best = Long.MAX_VALUE; + long previous = 0L; + for (long lap : laps) { + best = Math.min(best, lap - previous); + previous = lap; + } + return best == Long.MAX_VALUE ? 0L : best; + } + + private static final Stopwatch CLIENT = new Stopwatch(); + + /** The client side singleton used by the stopwatch item + HUD. */ + public static Stopwatch items() { + return CLIENT; + } + + public static String format(long millis) { + if (millis <= 0) { + return "--:--.---"; + } + long minutes = millis / 60000L; + long seconds = (millis % 60000L) / 1000L; + long ms = millis % 1000L; + return String.format("%d:%02d.%03d", minutes, seconds, ms); + } + + public static String formatShort(long millis) { + if (millis <= 0) { + return "-.---"; + } + return String.format("%d.%03d", millis / 1000L, millis % 1000L); + } + + /** Used by the stopwatch item: first press starts, second press stops, third resets. */ + public void toggle() { + long now = System.currentTimeMillis(); + if (running()) { + stop(now); + } else if (startedAt < 0) { + start(now); + } else { + reset(); + } + } + + /** The live split while running, without recording a lap. */ + public long currentLap(long now) { + if (!running()) { + return 0L; + } + long previous = laps.isEmpty() ? 0L : laps.get(laps.size() - 1); + return elapsed(now) - previous; + } + + public static String trackLabel(CarEntity car) { + return car == null ? "-" : car.data().type.displayName; + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/asphalt.json b/src/main/resources/assets/fabricracers/blockstates/asphalt.json new file mode 100644 index 0000000..87c11f5 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/asphalt.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/asphalt" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/asphalt_cracked.json b/src/main/resources/assets/fabricracers/blockstates/asphalt_cracked.json new file mode 100644 index 0000000..3ed9d2c --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/asphalt_cracked.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/asphalt_cracked" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/boost_pad.json b/src/main/resources/assets/fabricracers/blockstates/boost_pad.json new file mode 100644 index 0000000..1ef1310 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/boost_pad.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/boost_pad" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/checkpoint_marker.json b/src/main/resources/assets/fabricracers/blockstates/checkpoint_marker.json new file mode 100644 index 0000000..fdb1eac --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/checkpoint_marker.json @@ -0,0 +1,10 @@ +{ + "variants": { + "lit=false": { + "model": "fabricracers:block/checkpoint_marker_off" + }, + "lit=true": { + "model": "fabricracers:block/checkpoint_marker_on" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/fake_ice.json b/src/main/resources/assets/fabricracers/blockstates/fake_ice.json new file mode 100644 index 0000000..d9d8818 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/fake_ice.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/fake_ice" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/fake_slime.json b/src/main/resources/assets/fabricracers/blockstates/fake_slime.json new file mode 100644 index 0000000..5284010 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/fake_slime.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/fake_slime" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/finish_line.json b/src/main/resources/assets/fabricracers/blockstates/finish_line.json new file mode 100644 index 0000000..d75f7f8 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/finish_line.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/finish_line" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/kerb.json b/src/main/resources/assets/fabricracers/blockstates/kerb.json new file mode 100644 index 0000000..a23743f --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/kerb.json @@ -0,0 +1,10 @@ +{ + "variants": { + "color=red": { + "model": "fabricracers:block/kerb_red" + }, + "color=white": { + "model": "fabricracers:block/kerb_white" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/pit_lane.json b/src/main/resources/assets/fabricracers/blockstates/pit_lane.json new file mode 100644 index 0000000..d5c7144 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/pit_lane.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/pit_lane" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/road_block.json b/src/main/resources/assets/fabricracers/blockstates/road_block.json new file mode 100644 index 0000000..f2ba036 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/road_block.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/road_block" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/safety_barrier.json b/src/main/resources/assets/fabricracers/blockstates/safety_barrier.json new file mode 100644 index 0000000..800a5fd --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/safety_barrier.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/safety_barrier" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/start_gate.json b/src/main/resources/assets/fabricracers/blockstates/start_gate.json new file mode 100644 index 0000000..13b9195 --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/start_gate.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/start_gate" + } + } +} diff --git a/src/main/resources/assets/fabricracers/blockstates/station.json b/src/main/resources/assets/fabricracers/blockstates/station.json new file mode 100644 index 0000000..f3a6cfa --- /dev/null +++ b/src/main/resources/assets/fabricracers/blockstates/station.json @@ -0,0 +1,7 @@ +{ + "variants": { + "": { + "model": "fabricracers:block/station" + } + } +} diff --git a/src/main/resources/assets/fabricracers/lang/en_us.json b/src/main/resources/assets/fabricracers/lang/en_us.json new file mode 100644 index 0000000..2ea40f9 --- /dev/null +++ b/src/main/resources/assets/fabricracers/lang/en_us.json @@ -0,0 +1,144 @@ +{ + "itemGroup.fabricracers.racing": "FabricRacers", + "item.fabricracers.car_kart": "Go-Kart", + "item.fabricracers.car_kart.desc": "Tiny, light and endlessly slidey - the cheapest way onto the grid.", + "item.fabricracers.car_rally": "Rally Hatch", + "item.fabricracers.car_rally.desc": "All-surface hatchback. Loves kerbs, grass and questionable shortcuts.", + "item.fabricracers.car_sport": "Sport Coupe", + "item.fabricracers.car_sport.desc": "The all-rounder: quick, stable and cheap to run.", + "item.fabricracers.car_hyper": "Hypercar", + "item.fabricracers.car_hyper.desc": "Enormous top speed. Enormous brake bills.", + "item.fabricracers.car_f1": "Formula Racer", + "item.fabricracers.car_f1.desc": "Open wheels, huge downforce, no excuses.", + "item.fabricracers.car_truck": "Baja Truck", + "item.fabricracers.car_truck.desc": "Heavy, tough and happy on rough ground.", + "item.fabricracers.car_drift": "Drift Machine", + "item.fabricracers.car_drift.desc": "Built sideways. Grip is for other people.", + "item.fabricracers.car_hotrod": "Hot Rod", + "item.fabricracers.car_hotrod.desc": "No brakes, all noise, deeply questionable.", + "item.fabricracers.car_shadow": "Shadow Prototype", + "item.fabricracers.car_shadow.desc": "Prototype chassis. Officially does not exist.", + "block.fabricracers.asphalt": "Asphalt", + "block.fabricracers.asphalt_cracked": "Cracked Asphalt", + "block.fabricracers.road_block": "Road Line", + "block.fabricracers.finish_line": "Finish Line", + "block.fabricracers.pit_lane": "Pit Lane", + "block.fabricracers.kerb": "Racing Kerb", + "block.fabricracers.safety_barrier": "Safety Barrier", + "block.fabricracers.start_gate": "Start Gate", + "block.fabricracers.checkpoint_marker": "Checkpoint Marker", + "block.fabricracers.station": "Pit Box Marker", + "block.fabricracers.boost_pad": "Boost Pad", + "block.fabricracers.fake_ice": "Low Grip Panel", + "block.fabricracers.fake_slime": "Bounce Panel", + "item.fabricracers.carrot_key": "Car Key", + "item.fabricracers.carrot_key.desc": "Right click a parked car to claim it as yours.", + "item.fabricracers.wrench": "Mechanic's Wrench", + "item.fabricracers.wrench.desc": "Repairs, tunes and picks up cars.", + "item.fabricracers.track_wand": "Track Wand", + "item.fabricracers.track_wand.desc": "Right click to open the circuit catalogue, then build one.", + "item.fabricracers.track_wand.desc2": "Sneak to move the anchor. Rotation cycles in the menu.", + "item.fabricracers.stopwatch": "Lap Stopwatch", + "item.fabricracers.stopwatch.desc": "Tracks your lap times while you drive.", + "item.fabricracers.race_pass": "Race Pass", + "item.fabricracers.race_pass.desc": "Shows the state of the race you are in.", + "item.fabricracers.trophy_gold": "Gold Trophy", + "item.fabricracers.trophy_silver": "Silver Trophy", + "item.fabricracers.trophy_bronze": "Bronze Trophy", + "item.fabricracers.trophy_gold.desc": "Awarded for winning a race. Right click to read your record.", + "item.fabricracers.trophy_silver.desc": "Awarded for finishing second.", + "item.fabricracers.trophy_bronze.desc": "Awarded for finishing third.", + "item.fabricracers.paint.desc": "Right click a car you own to repaint it.", + "item.fabricracers.paint_white": "Paint Can (White)", + "item.fabricracers.paint_black": "Paint Can (Black)", + "item.fabricracers.paint_red": "Paint Can (Red)", + "item.fabricracers.paint_orange": "Paint Can (Orange)", + "item.fabricracers.paint_yellow": "Paint Can (Yellow)", + "item.fabricracers.paint_green": "Paint Can (Green)", + "item.fabricracers.paint_blue": "Paint Can (Blue)", + "item.fabricracers.paint_purple": "Paint Can (Purple)", + "item.fabricracers.engine_turbo": "Turbocharger Kit", + "item.fabricracers.engine_turbo.desc": "Engine upgrades add power and top speed.", + "tooltip.fabricracers.tune_part.engine_turbo": "Engine upgrades add power and top speed.", + "item.fabricracers.gearbox_dct": "Race Gearbox", + "item.fabricracers.gearbox_dct.desc": "Tighter ratios: quicker acceleration out of corners.", + "tooltip.fabricracers.tune_part.gearbox_dct": "Tighter ratios: quicker acceleration out of corners.", + "item.fabricracers.brakes_carbon": "Carbon Brakes", + "item.fabricracers.brakes_carbon.desc": "Brake later, brake harder, brake calmer.", + "tooltip.fabricracers.tune_part.brakes_carbon": "Brake later, brake harder, brake calmer.", + "item.fabricracers.tires_street": "Competition Tires", + "item.fabricracers.tires_street.desc": "The single biggest grip upgrade.", + "tooltip.fabricracers.tune_part.tires_street": "The single biggest grip upgrade.", + "item.fabricracers.aero_wing": "Aero Package", + "item.fabricracers.aero_wing.desc": "Downforce for the fast stuff, drag at the top end.", + "tooltip.fabricracers.tune_part.aero_wing": "Downforce for the fast stuff, drag at the top end.", + "item.fabricracers.chassis_plate": "Chassis Reinforcement", + "item.fabricracers.chassis_plate.desc": "Stiffer body: less crash damage, better composure.", + "tooltip.fabricracers.tune_part.chassis_plate": "Stiffer body: less crash damage, better composure.", + "item.fabricracers.boost_nitro": "Nitro System", + "item.fabricracers.boost_nitro.desc": "More boost capacity and a harder shove.", + "tooltip.fabricracers.tune_part.boost_nitro": "More boost capacity and a harder shove.", + "tooltip.fabricracers.tune_part": "%s", + "tooltip.fabricracers.tune_part_use": "Apply it in the garage (G) to fit the upgrade.", + "tooltip.fabricracers.stats": "Top speed %s km/h Power %s Owner %s", + "tooltip.fabricracers.color": "Paint: %s / %s", + "message.fabricracers.wrench_none": "No car nearby.", + "message.fabricracers.not_your_car": "This car belongs to %s.", + "message.fabricracers.car_occupied": "Someone is sitting in that car.", + "message.fabricracers.car_picked_up": "Car picked up.", + "message.fabricracers.claimed": "This %s is yours now.", + "message.fabricracers.key_owner": "This car already belongs to %s.", + "message.fabricracers.painted": "Painted %s.", + "message.fabricracers.tuned": "Fitted %s level %s/%s.", + "message.fabricracers.already_fine": "That car needs no repairs.", + "message.fabricracers.no_room": "Your inventory is full.", + "message.fabricracers.repaired": "Car repaired.", + "message.fabricracers.recovered": "That could have been a long walk - car recovered.", + "message.fabricracers.wand_needs_op": "Only operators may place circuits here.", + "message.fabricracers.track_placed": "Built %s (%s blocks, footprint %s).", + "message.fabricracers.track_placed_tip": "Sit in a car on the start line and run /race start, or press T.", + "message.fabricracers.track_removed": "Removed %s (%s blocks cleared).", + "message.fabricracers.record_too_short": "That lap was too short to build a circuit from.", + "message.fabricracers.record_done": "Recorded %s (%s footprint). The wand can build it now.", + "message.fabricracers.record_failed": "Recording failed - could not create the structure.", + "message.fabricracers.car_given": "Enjoy your %s.", + "race.fabricracers.welcome": "Welcome back, driver. Rank: %s", + "race.fabricracers.lap": "Lap %s/%s - %s", + "race.fabricracers.finished": "Finished P%s in %s!", + "race.fabricracers.track_record": "New record at %s: %s", + "race.fabricracers.trophy": "P%s! You earned a %s.", + "race.fabricracers.need_car": "You need to be sitting in a car.", + "race.fabricracers.pass_free": "Not racing. Find a circuit, then /race start or press T.", + "screen.fabricracers.garage": "Garage", + "screen.fabricracers.tracks": "Circuit Catalogue", + "screen.fabricracers.panel": "Race Control", + "screen.fabricracers.panel_hint": "Start a race where you stand, or join one on the grid.", + "screen.fabricracers.stats": "%s km/h %s power %s grip %s%% condition", + "screen.fabricracers.hint_parts": "Each upgrade costs parts - buy them from a mechanic or win races.", + "screen.fabricracers.colors": "Primary: %s Secondary: %s", + "screen.fabricracers.paint_primary": "Repaint body", + "screen.fabricracers.paint_secondary": "Repaint trim", + "screen.fabricracers.repair": "Repair", + "screen.fabricracers.ai_on": "Hand to AI", + "screen.fabricracers.ai_off": "Take the wheel", + "screen.fabricracers.start_race": "Start: %s laps, %s AI, %s", + "screen.fabricracers.laps": "Laps: %s", + "screen.fabricracers.ai_count": "AI cars: %s", + "screen.fabricracers.skill": "Difficulty: %s", + "screen.fabricracers.records": "My record", + "screen.fabricracers.join_grid": "Join a grid", + "screen.fabricracers.leave_race": "Leave race", + "screen.fabricracers.no_race": "No race running", + "screen.fabricracers.not_racing": "Free roam", + "screen.fabricracers.profile": "Driver profile", + "screen.fabricracers.best_laps": "Personal bests", + "screen.fabricracers.anchor": "Anchor: %s Rotation: %s", + "screen.fabricracers.rotate": "Rotate %s", + "screen.fabricracers.record": "Record a lap", + "category.fabricracers.racing": "FabricRacers", + "key.fabricracers.garage": "Open garage", + "key.fabricracers.tracks": "Race control / circuits", + "key.fabricracers.handbrake": "Handbrake", + "key.fabricracers.boost": "Boost", + "key.fabricracers.hud": "Toggle race HUD" +} diff --git a/src/main/resources/assets/fabricracers/models/block/asphalt.json b/src/main/resources/assets/fabricracers/models/block/asphalt.json new file mode 100644 index 0000000..d48449a --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/asphalt.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/asphalt" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/asphalt_cracked.json b/src/main/resources/assets/fabricracers/models/block/asphalt_cracked.json new file mode 100644 index 0000000..1664c7d --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/asphalt_cracked.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/asphalt_cracked" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/boost_pad.json b/src/main/resources/assets/fabricracers/models/block/boost_pad.json new file mode 100644 index 0000000..8f50086 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/boost_pad.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/boost_pad" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/checkpoint_marker_off.json b/src/main/resources/assets/fabricracers/models/block/checkpoint_marker_off.json new file mode 100644 index 0000000..85a798e --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/checkpoint_marker_off.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/checkpoint_marker" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/checkpoint_marker_on.json b/src/main/resources/assets/fabricracers/models/block/checkpoint_marker_on.json new file mode 100644 index 0000000..85a798e --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/checkpoint_marker_on.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/checkpoint_marker" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/fake_ice.json b/src/main/resources/assets/fabricracers/models/block/fake_ice.json new file mode 100644 index 0000000..c0a876b --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/fake_ice.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/fake_ice" + }, + "render_type": "translucent" +} diff --git a/src/main/resources/assets/fabricracers/models/block/fake_slime.json b/src/main/resources/assets/fabricracers/models/block/fake_slime.json new file mode 100644 index 0000000..297e23e --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/fake_slime.json @@ -0,0 +1,7 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/fake_slime" + }, + "render_type": "translucent" +} diff --git a/src/main/resources/assets/fabricracers/models/block/finish_line.json b/src/main/resources/assets/fabricracers/models/block/finish_line.json new file mode 100644 index 0000000..1658ea2 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/finish_line.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/finish_line" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/kerb_red.json b/src/main/resources/assets/fabricracers/models/block/kerb_red.json new file mode 100644 index 0000000..c52dd9e --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/kerb_red.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/kerb_red" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/kerb_white.json b/src/main/resources/assets/fabricracers/models/block/kerb_white.json new file mode 100644 index 0000000..94bfa07 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/kerb_white.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/kerb_white" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/pit_lane.json b/src/main/resources/assets/fabricracers/models/block/pit_lane.json new file mode 100644 index 0000000..8e076ff --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/pit_lane.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/pit_lane" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/road_block.json b/src/main/resources/assets/fabricracers/models/block/road_block.json new file mode 100644 index 0000000..1e7f4c4 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/road_block.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/road_block" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/safety_barrier.json b/src/main/resources/assets/fabricracers/models/block/safety_barrier.json new file mode 100644 index 0000000..c7b89eb --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/safety_barrier.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/safety_barrier" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/start_gate.json b/src/main/resources/assets/fabricracers/models/block/start_gate.json new file mode 100644 index 0000000..085ce1f --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/start_gate.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/start_gate" + } +} diff --git a/src/main/resources/assets/fabricracers/models/block/station.json b/src/main/resources/assets/fabricracers/models/block/station.json new file mode 100644 index 0000000..5716369 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/block/station.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:block/cube_all", + "textures": { + "all": "fabricracers:block/station" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/aero_wing.json b/src/main/resources/assets/fabricracers/models/item/aero_wing.json new file mode 100644 index 0000000..c725ee0 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/aero_wing.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/aero_wing" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/asphalt.json b/src/main/resources/assets/fabricracers/models/item/asphalt.json new file mode 100644 index 0000000..91fb33f --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/asphalt.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/asphalt" +} diff --git a/src/main/resources/assets/fabricracers/models/item/asphalt_cracked.json b/src/main/resources/assets/fabricracers/models/item/asphalt_cracked.json new file mode 100644 index 0000000..767dd70 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/asphalt_cracked.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/asphalt_cracked" +} diff --git a/src/main/resources/assets/fabricracers/models/item/boost_nitro.json b/src/main/resources/assets/fabricracers/models/item/boost_nitro.json new file mode 100644 index 0000000..d709daf --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/boost_nitro.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/boost_nitro" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/boost_pad.json b/src/main/resources/assets/fabricracers/models/item/boost_pad.json new file mode 100644 index 0000000..d516bf4 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/boost_pad.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/boost_pad" +} diff --git a/src/main/resources/assets/fabricracers/models/item/brakes_carbon.json b/src/main/resources/assets/fabricracers/models/item/brakes_carbon.json new file mode 100644 index 0000000..3b804cd --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/brakes_carbon.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/brakes_carbon" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_drift.json b/src/main/resources/assets/fabricracers/models/item/car_drift.json new file mode 100644 index 0000000..e93b3cd --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_drift.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_drift" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_f1.json b/src/main/resources/assets/fabricracers/models/item/car_f1.json new file mode 100644 index 0000000..8ac5895 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_f1.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_f1" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_hotrod.json b/src/main/resources/assets/fabricracers/models/item/car_hotrod.json new file mode 100644 index 0000000..2b42f99 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_hotrod.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_hotrod" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_hyper.json b/src/main/resources/assets/fabricracers/models/item/car_hyper.json new file mode 100644 index 0000000..b018525 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_hyper.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_hyper" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_kart.json b/src/main/resources/assets/fabricracers/models/item/car_kart.json new file mode 100644 index 0000000..b20f794 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_kart.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_kart" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_rally.json b/src/main/resources/assets/fabricracers/models/item/car_rally.json new file mode 100644 index 0000000..cd11a2c --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_rally.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_rally" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_shadow.json b/src/main/resources/assets/fabricracers/models/item/car_shadow.json new file mode 100644 index 0000000..1ee45a8 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_shadow.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_shadow" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_sport.json b/src/main/resources/assets/fabricracers/models/item/car_sport.json new file mode 100644 index 0000000..285c956 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_sport.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_sport" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/car_truck.json b/src/main/resources/assets/fabricracers/models/item/car_truck.json new file mode 100644 index 0000000..09a277f --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/car_truck.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/car_truck" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/carrot_key.json b/src/main/resources/assets/fabricracers/models/item/carrot_key.json new file mode 100644 index 0000000..f0c5cce --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/carrot_key.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/carrot_key" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/chassis_plate.json b/src/main/resources/assets/fabricracers/models/item/chassis_plate.json new file mode 100644 index 0000000..633eca7 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/chassis_plate.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/chassis_plate" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/checkpoint_marker.json b/src/main/resources/assets/fabricracers/models/item/checkpoint_marker.json new file mode 100644 index 0000000..c7357b2 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/checkpoint_marker.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/checkpoint_marker_off" +} diff --git a/src/main/resources/assets/fabricracers/models/item/engine_turbo.json b/src/main/resources/assets/fabricracers/models/item/engine_turbo.json new file mode 100644 index 0000000..b76d3e6 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/engine_turbo.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/engine_turbo" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/fake_ice.json b/src/main/resources/assets/fabricracers/models/item/fake_ice.json new file mode 100644 index 0000000..7b1cfe9 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/fake_ice.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/fake_ice" +} diff --git a/src/main/resources/assets/fabricracers/models/item/fake_slime.json b/src/main/resources/assets/fabricracers/models/item/fake_slime.json new file mode 100644 index 0000000..99fa804 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/fake_slime.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/fake_slime" +} diff --git a/src/main/resources/assets/fabricracers/models/item/finish_line.json b/src/main/resources/assets/fabricracers/models/item/finish_line.json new file mode 100644 index 0000000..78e9aca --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/finish_line.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/finish_line" +} diff --git a/src/main/resources/assets/fabricracers/models/item/gearbox_dct.json b/src/main/resources/assets/fabricracers/models/item/gearbox_dct.json new file mode 100644 index 0000000..eca8967 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/gearbox_dct.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/gearbox_dct" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/kerb.json b/src/main/resources/assets/fabricracers/models/item/kerb.json new file mode 100644 index 0000000..1d94db3 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/kerb.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/kerb_red" +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_black.json b/src/main/resources/assets/fabricracers/models/item/paint_black.json new file mode 100644 index 0000000..a7eee04 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_black.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_black" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_blue.json b/src/main/resources/assets/fabricracers/models/item/paint_blue.json new file mode 100644 index 0000000..6823aba --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_blue.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_blue" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_green.json b/src/main/resources/assets/fabricracers/models/item/paint_green.json new file mode 100644 index 0000000..9d6a9fb --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_green.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_green" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_orange.json b/src/main/resources/assets/fabricracers/models/item/paint_orange.json new file mode 100644 index 0000000..70eaca4 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_orange.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_orange" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_purple.json b/src/main/resources/assets/fabricracers/models/item/paint_purple.json new file mode 100644 index 0000000..351d3c4 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_purple.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_purple" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_red.json b/src/main/resources/assets/fabricracers/models/item/paint_red.json new file mode 100644 index 0000000..d4320e9 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_red.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_red" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_white.json b/src/main/resources/assets/fabricracers/models/item/paint_white.json new file mode 100644 index 0000000..6671864 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_white.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_white" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/paint_yellow.json b/src/main/resources/assets/fabricracers/models/item/paint_yellow.json new file mode 100644 index 0000000..c1fba72 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/paint_yellow.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/paint_yellow" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/pit_lane.json b/src/main/resources/assets/fabricracers/models/item/pit_lane.json new file mode 100644 index 0000000..0fae3fc --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/pit_lane.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/pit_lane" +} diff --git a/src/main/resources/assets/fabricracers/models/item/race_pass.json b/src/main/resources/assets/fabricracers/models/item/race_pass.json new file mode 100644 index 0000000..0dc7e3f --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/race_pass.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/race_pass" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/road_block.json b/src/main/resources/assets/fabricracers/models/item/road_block.json new file mode 100644 index 0000000..ef88dd1 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/road_block.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/road_block" +} diff --git a/src/main/resources/assets/fabricracers/models/item/safety_barrier.json b/src/main/resources/assets/fabricracers/models/item/safety_barrier.json new file mode 100644 index 0000000..c5b1447 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/safety_barrier.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/safety_barrier" +} diff --git a/src/main/resources/assets/fabricracers/models/item/start_gate.json b/src/main/resources/assets/fabricracers/models/item/start_gate.json new file mode 100644 index 0000000..6e673f9 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/start_gate.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/start_gate" +} diff --git a/src/main/resources/assets/fabricracers/models/item/station.json b/src/main/resources/assets/fabricracers/models/item/station.json new file mode 100644 index 0000000..7de2c7c --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/station.json @@ -0,0 +1,3 @@ +{ + "parent": "fabricracers:block/station" +} diff --git a/src/main/resources/assets/fabricracers/models/item/stopwatch.json b/src/main/resources/assets/fabricracers/models/item/stopwatch.json new file mode 100644 index 0000000..c2052df --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/stopwatch.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/stopwatch" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/tires_street.json b/src/main/resources/assets/fabricracers/models/item/tires_street.json new file mode 100644 index 0000000..36bb577 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/tires_street.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/tires_street" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/track_wand.json b/src/main/resources/assets/fabricracers/models/item/track_wand.json new file mode 100644 index 0000000..d37ab15 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/track_wand.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/track_wand" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/trophy_bronze.json b/src/main/resources/assets/fabricracers/models/item/trophy_bronze.json new file mode 100644 index 0000000..517df61 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/trophy_bronze.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/trophy_bronze" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/trophy_gold.json b/src/main/resources/assets/fabricracers/models/item/trophy_gold.json new file mode 100644 index 0000000..237454b --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/trophy_gold.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/trophy_gold" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/trophy_silver.json b/src/main/resources/assets/fabricracers/models/item/trophy_silver.json new file mode 100644 index 0000000..6da9df1 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/trophy_silver.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/trophy_silver" + } +} diff --git a/src/main/resources/assets/fabricracers/models/item/wrench.json b/src/main/resources/assets/fabricracers/models/item/wrench.json new file mode 100644 index 0000000..ec2e5d4 --- /dev/null +++ b/src/main/resources/assets/fabricracers/models/item/wrench.json @@ -0,0 +1,6 @@ +{ + "parent": "minecraft:item/generated", + "textures": { + "layer0": "fabricracers:item/wrench" + } +} diff --git a/src/main/resources/assets/fabricracers/textures/block/asphalt.png b/src/main/resources/assets/fabricracers/textures/block/asphalt.png new file mode 100644 index 0000000000000000000000000000000000000000..1a7e58244ac0048e066f4d94ea72c9797846a91b GIT binary patch literal 409 zcmV;K0cQS*P)6x8!I$i5HPIbFI)ZMPH zn@v|AkMDZ9+}r(p{?vK?s(pWMw*%wImHmEb!LB>D8;RKAaH{wFXH@{ukvkqQe_}AFI_{ou-f08|w(S+k5+$%X~QW zO+?`$c|PA7*il^)hzqj;Bu?0!w&aTG3c3MR_RZCLy>CEqMVyF>DOdm{@{AIcmqYYV zj5{+x#Nnt&AOa(6$<2k=FeAMq=$~6B4h!|D5<&_fOO>HyeNw{x_vQn||i|Hc|`ff1YlDF{j=XEu4wt%G^>w}8 z*5$IR_xpD}9NaRzImpG$(7A!*MeO)G!KNV@5g$*epU?>GPz+m{)!cy zUIR#8|B>x>hdP_hC!=^GAX_&|By>5QZvTSRWwc3wkNNzqUM?SPHqrsk=lAw6m-nI2 zHxiAD@!Zv`-4q$$2Sris?gcT7`3UX9=G-~8Dpr(zWQ|3gJ-REe;bm!F~ z1N3c!#kzoj$j3}tq>3p<$f8)&_69j$awQQV2q0tW2BZ!lVpho1iANA{W{qqFIWV>2 zl$7iiJD-Bc&DjF$o?I~P5?^mS3Ub~#>8Sd<#wQ(|sr*L8{C)rc002ovPDHLkV1g!R Bwm<*? literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/block/boost_pad.png b/src/main/resources/assets/fabricracers/textures/block/boost_pad.png new file mode 100644 index 0000000000000000000000000000000000000000..f9100929717255acb0bcfa51a67ff72459d3650d GIT binary patch literal 207 zcmeAS@N?(olHy`uVBq!ia0vp^0zmA*!3HFSYrjteQqw$L978H@C2z3&_>jfy&+F^| z|G$u!ZN;Wg$kDbj^H6JIm4wH|7T)HV4ZLlhC5#dLCmMKf81VT@ymDY$vxudcC&G!7 zP12xW=!}AjQaO|KF)_9qR}2L%wQwKiU0@*_@P=ts8>3}~fJB3~zCgG8=R+BB5^j}= zN3QoSu1qx1?|IgGu}o>uP%=>{ZWbkzLb6Mw< G&;$Uyvr04o literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/block/boost_pad.png.mcmeta b/src/main/resources/assets/fabricracers/textures/block/boost_pad.png.mcmeta new file mode 100644 index 0000000..5cc283a --- /dev/null +++ b/src/main/resources/assets/fabricracers/textures/block/boost_pad.png.mcmeta @@ -0,0 +1,6 @@ +{ + "animation": { + "frametime": 2, + "interpolate": true + } +} \ No newline at end of file diff --git a/src/main/resources/assets/fabricracers/textures/block/checkpoint_marker.png b/src/main/resources/assets/fabricracers/textures/block/checkpoint_marker.png new file mode 100644 index 0000000000000000000000000000000000000000..04d8eeb3940f62749416ab0b3624c8b8591d5531 GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`-kvUwAr-fh6AmyPimO@O@JQH0 zLg$zRPlKl2$LIfLH}N!NsoZB3k#;DQ2sp}}!RV2$&tu0tfoW<9keK`>L&aK?|6T+` dC1}fy}X^**^q}N;GKO# z@wttKeb48VePEC~cvo`E2gb(M98)d71I+wWo_u|_d54Nr^`d&^B4ek`=Vp5JcV6ns zkc*i%U)WUOF89QlJAA&pi0yncWkpWMx0A7dy1kUP_8s})C?e)k``k$0w>Re5;;Xuf z3qSsIRNc1ORn=r(nw`~--DljvJg`Vy}9y=czYoFLG0(Q;$) z59K@Qe2#CA-8}YDZ`-!T4jeO*c2B*RWgoIXYdO!f{sU)qFU~ie4C;~QJ!>@VSl!g#OEDq jOsWeN1B9foQlHqjysEkW?)yBD>li#;{an^LB{Ts5KEY9! literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/block/finish_line.png b/src/main/resources/assets/fabricracers/textures/block/finish_line.png new file mode 100644 index 0000000000000000000000000000000000000000..a514c3d92b238ec31d265e2c0be0968ed4ec1b68 GIT binary patch literal 142 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`p`I>|Ar-fhD+=D<`p^G<-rxF< zZ|?o?e?R|!c<|+)>({LL86STA$NsJ^u?QX&{$O??W5bP2tCJzopr0Iotap8x;= literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/block/kerb_red.png b/src/main/resources/assets/fabricracers/textures/block/kerb_red.png new file mode 100644 index 0000000000000000000000000000000000000000..3a47145aa71e1c7a5df9d42ddfa9e709de6bc4ab GIT binary patch literal 126 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`uAVNAAr-fhcf>}0m@jn3VIyPu z8lQjji#Pq;e>>#=|HR_M|CfA{)VRp=$T(n;Jd)n{ir{7|2Grsrd zr^mjV*Zr^?`+gbvcKYbY{Z!`ub(+f;?D@Fu-ffFlra15t0P+Vvq&&Hqw&tEHK?*&J%OJJtRD zqNdYjO(yetd3mk<{#-xbU-kL%UYE;rEtlJV3K(&{zEzveRy{eNultEiY_Ztf1OSEF zdc7}AlWRH+6GVpp#8#`_jeIy~>GarVJD;CA zAtR_|KOFidqH$B)ZjW_5KJ?IC5hwzH#0fhymc2FGm2?8??3=6cc-DdPnuv(YY1jZ2 za*YagTe~DWM4fa$zY- literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/block/start_gate.png b/src/main/resources/assets/fabricracers/textures/block/start_gate.png new file mode 100644 index 0000000000000000000000000000000000000000..a1242a34d5590f87ce4ffb0b64d23edc3103f8ad GIT binary patch literal 231 zcmeAS@N?(olHy`uVBq!ia0vp^0zmA*!3HFSYrjteQtLcj978H@B?klr{pLT^FL#F_ zIp`;kh_u5xH;MU0%xrc%-|o%+Hecw>hyVXK|Ceo-eK;e;?kO9~v{nC?v-B@(zPQIg ziY23QzvjWkFB44kd)gRvc=YuD_;a#_zfZr` ze#mfT%_-g$r%e9&bISR$FRn~9;aR{|W4rs0z3PmIcP~{Gh_PNY@ZkbF;nyVH|G`b} fmk%bK;AWWdIOh59&&%fmUC!X?>gTe~DWM4fd_!GX literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/block/start_gate.png.mcmeta b/src/main/resources/assets/fabricracers/textures/block/start_gate.png.mcmeta new file mode 100644 index 0000000..432a384 --- /dev/null +++ b/src/main/resources/assets/fabricracers/textures/block/start_gate.png.mcmeta @@ -0,0 +1,6 @@ +{ + "animation": { + "frametime": 4, + "interpolate": true + } +} \ No newline at end of file diff --git a/src/main/resources/assets/fabricracers/textures/block/station.png b/src/main/resources/assets/fabricracers/textures/block/station.png new file mode 100644 index 0000000000000000000000000000000000000000..ea8cb808d5ad3099589e324df1ac76c6fbe5a5aa GIT binary patch literal 279 zcmV+y0qFjTP)Hdf^1|5!g@%KL(Lf|5giSU(HkjINUX?l@xA-|kN=GF!>vxTlaAA) z_JUG}o4w38$w8<2S;lL>kbdRmvK~Y`InP~1Gnvkv0HNX)CW~b>3f#$xi&lAg?Gb4G z>4Ek8ZT3gN<0^`)9>M$d6tw5`-$&IaAP!psqXK@{RRL}chR}vd0ai9P4k@V7RUj_d z{6USb0>U@iXsbXHX$(PE0cmf{L|X;ag~AHgRHn$95M0otG6O7uu`XTW1MFcna(RrB d=L~z?`2jbp;L)yC2z>wm002ovPDHLkV1mCmcYy!^ literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/drift.png b/src/main/resources/assets/fabricracers/textures/entity/car/drift.png new file mode 100644 index 0000000000000000000000000000000000000000..a27c5d50cd4d66d09316407e663e397bf733ab14 GIT binary patch literal 2081 zcmV++2;TRJP)6t*B6Lf(BzG1VpM<;*&8s^4sB?tl4vB4>M;vGZdSA+2^da&&>Y5z1G@$pJ{4# zXEv%Qnwpvup{1oQ5mJpHz?3Pic}S--MPd6@Re+oCUd%(S?7jdZ+T1+30KN#N1Q;6H zl|xAZetr3q=;oUzq8nR2kA8pki`)SJdgE*7`(xXeu9bas>v7lb`YWHh=WPGqHh<E7VD^au8yOP7pLwup)8-s}0YIDpA@Wv)!XkKkz)A(#888GHyv=DyYL;zIs~=Gj zCnP?<2E@%iq%)aLhhidF0c_5TXg1rIgG~t=1$i|<;cs)AfiruwHUba~s214B01&k` z15x>0`Qrp2+=Vy+tiS34D3l?^tlNRB4%mUyPZ3~=YKDC~&nf~!DG(R_cNzhH3itxV z)q%Vs$iNt+O#!=nS-X}@pD3Y^fV8&1mRUc=O4R^G!?@P~%`}8<1ZYjiW~;XZQ;o2x z1bNcMuL1N|8q{JH@Kd7XD!`!Q)_cvKtpQr@u*dbMwZ%@$CIZ-+(J=dfqc7~rL#^zk zc7nyW#CNh2j6?CWz^$2NmXyP?YgJHXx1p|mfX!*{i3p{FT~7HNEr$(x7EqP8gdnM~ zw#%cIOw|Xw;ekATEm8HCzhREU{wb~2m^A6WL`eGmbr+zi=>dmKro*A!`?q?wBtnYt zCRYi4ZESSFQ996SA1~vd_+5+pvMYRRsb9F${HK!nJ(lHewxF3%c(5p3lU$s&okpT67E#5#8ph8Hi zB~dRvHFNf)XJ3};)Tkm#tDPUde@1uYpjC(dbzcUxrt6%p7n<7tD%D-E&e}rT ztu+1Y=OGfrGntHQwI65*0YqMdx0@kFhBieESnvB)I91s->ssrz36c;t0_-dJY6&nr zJd(q}z+fJ%uRs7B!aAsklnynD3hfYqNa&4`s=F0HpREHE_lhcAkB0z0$)Es9LntZg zo&pm|fNXZDL+JbbK6Mu$>2Wx*06%Q}C_1%rPjr6WA&1ke_eI!fFKqZQy7on<>xe;00${^05oi$tyM%(o>9zZF0zeY-*iH<*=fuDcajx-W$ItzYxy#RPA38Qn z8+VNWp+1ZImPcb_qp_IRvm_cDy;2m2mV$gbGcy_(-0IqLt`Q7!(e~{HfT$t?vX{HM z3V^?3&a(3WQgPP^5bEPd-Xd#%1OeEHgpdG;mtsaM6#(%d8b{7X1JM!J2YY7L(hDIV zK*&!R7Zad&$umI#5HCgkS1JH00Z~XQ$4Lo@k>lJm`}kE0xTh~I{`Q%@7ehdRkUzVy zzo-ECgoTTra)NYqFLsfk`j9%e_MppeT4t2o21Ewp0Odb+!d^9i{vriQ3LwN0Km`y; z5JFzHK)!V6!XHCGfRLZUR;&h)r+PRK{=mc!fkt_b?Xut8o+qXhB$;a8Ohz!gD-I3&3i$b0f1J7Bie$3_MDObXCKOm(2Y|Jgif zuYh1GnzU2XvwjHy0YZLezS7?$+&+NWR=z{J)K5u@4kCkV<+F;MW>z82J;G?#W#^t} z9&LN#atH_z@-w@YZo}P9kQs%DX1A082nBgXhe$$(Yi*6WmkmK|i(Sj%BhzMF2>}5@ zenMK?Uc#T2(AUx@AL)UOh=I7$C6)9q={v!PrcS>a0s@5mZRw7vb7pr`X`8C|--DCe zuZ4gBA%7C`v~?HY{)ZpC9s&Y{{C9l>G&Q&02mt{?{=^ZWTs-vr+Yag04hP+jx8MI) z`EpUUBGzaEw6(Pt;GPgbWc^p$AWn#J7oepjoe1R!;1jpA@7rrCK(XUyDU>6CB^ej~ zz7P^&!U-UvyLTThfEA%G0{H*zSPbO~;LmIvc`JhLTXO+yev7bgt5g7g_Vur>pjZ_A zFTg}ffrb}gyo%smQv>eVRiG9dp@!WD)N(7_kUPP9{wy%jPlNvf;`j>xO<@&a00000 LNkvXXu0mjfA=S%- literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/f1.png b/src/main/resources/assets/fabricracers/textures/entity/car/f1.png new file mode 100644 index 0000000000000000000000000000000000000000..29550d3e1eca3886baa16cad3b436fe49d1d262d GIT binary patch literal 2138 zcmV-g2&MOlP)Dsl9FZY2Qvi_iA78@w2Y^{UJY=emlP9gC~63ybqr# zYj3^uq-%QbaWA#!^Uaym-rkW(IZcGg<=;8%C(2dr0O6TQH(q?qSzrLr=hV&^05INy zWE{_YKcBS#=FeYLrq0ens_eL$0(jxe%VnCX_7H#!ZEkL>Pza$KfZ^d!OR5Th)x>34 zJyzYiF3+qet7ym5$7k${;`)S#yuf}B&v*-;O#+Z)_{EEt5<$cO$mqeronA2zB>+rL zZf#u}x5XIxxRwlRYcEsDpyFp0N@a!?+W-h4n@a>B$@m*LzViUMCP7F7AVaTRyIumo zJxYR50)TOvyp3H=n8}xUMhXDs3az;PejxJw79>W5ZjZr|07x+a*7!7q5OOxD$<1x{ z#u(ahYwPN10D#2Uij}?#mVOIT0K%0=$Bw;NQV0Mg0f-rIAyfchvP>L4sBmlU(lmx)`*BpQ*&P45guQ~t#W61&X0C|CJN6HUl@qkd(JRl5#5I|BM z$k_=hbK4LA468ilMG*#`@ zD#5gpcr2A*l2R){Rd1|bc!;qlxiDvF-?#0`_F+lQ$|s!$r^Q)-*k{6ofStIut+0aA zmPx-YkaTQT)n~s)`N6i={Wu(+(xzIoW<8imS^s|B0p#}aGx3U@o;QRHeOlOW-=70$UP!3QsW1{}VR$~BomR_##H}&43?f_Jf)Jt^- zU|)mjeSlU=$x4--JVmRg71a+RN^&~->g-Sd-*+yQq&(o?HPm|>)QL{JfNFf5>t%!u z6~WK}T%@r(#EKk_sU}Sr1S_ z?_ zi~`8&LRnS!5SU2-`ua9|#r|G6rtSc;9)~jnn0;cGoBP&b*Y@@i$9>b;{jT-RLlrL$ z8cy$ZE$0vVvCZcWxcsR-|9$TKA=h|vk83)$*Gu=aU)$jtPV6qVwVc`SpXa>3(_hs$ z{Mk1*V;Xw-!WXfATT{rmSltnh@c=Lm0Rh8+5CUjCv(JNJ9QT5je`A+71cNXdLwRmJ zd%z2W1%$E9r;DFGchElrZ|SP--yR?SYJ_XjJpf{3`qmE?4Y`x3jRV`==$+fCTD7|0 z<>s8+?K%s~+|bdN{8jfMfTS@1V0GQ`;}yb4tv-g|>glNvu$HXc`aQy^qOk{Zs#!!ssin0Ivb-hd^P*zt4>(|_B0+ft^^yk*hi z-k)OvK&&4_WjyYVQN3#hyu0J(x+mdr&y}%^1D@Xo(*Rh}7LHR6m?!|?E$mwPOH2TW z^8)#=d&&mf%W-4@){00Yim&qnZ zq{O(ObNR0^0U*}Ts!sGb2_FI~elQG!s{ND%2mq84WHF909)q;+tJvY0d({FenC#9m zuj7$hF##afkDE`l497Ek2w(^RfKd&AKmoyfL(xFFvjFgHw?vi2jKy2HYW&=VOK-;n zfLK4Ph%4jrxVN@BJVVd8J9&UM5~OkiVUf%FFY7zOmIaG{iwOX+{*KNiu6tRpo9H!H z?|)5gUBAZ!fLMPPf0ZAvYL>*N8-R|Et_s~30Ay_VY8xeim~;T`?VXu49sr@?cJ;&dDFu*r+^nYY z0I)_Txqk>k5M~?z8G7~V=M}OL>H;AA&yHytFMx1mlZ>|z?AR#>VB=c`hiwxD5U#%d zt1C+Lg8u=`q!5_)046I5-ZMGizTE|;q7a(4b-+}X!qc)6yzkEfGyOF9FYv$#B&HM< QLI3~&07*qoM6N<$f(|7J1^@s6 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/flame.png b/src/main/resources/assets/fabricracers/textures/entity/car/flame.png new file mode 100644 index 0000000000000000000000000000000000000000..77e0bd232843110a7a5645c58a2aba0cfa939deb GIT binary patch literal 167 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`d7dtgAr-fhZzQX4>uMBYFk)ys zY4pH(>Y+#pwbKj!yEn-xNjq(FRA8Q9Xx98es_iVpq?oV&Z+BiV=t+|ZWLqfVutd?I z*}-HPql!dR7K4W(=e+}x9WK9RXEW|FdD!a~Q>SdD7&vE&<~g4!Y3U3H7DkGN1)CQE PZD#Ov^>bP0l+XkK*OfQ* literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/hotrod.png b/src/main/resources/assets/fabricracers/textures/entity/car/hotrod.png new file mode 100644 index 0000000000000000000000000000000000000000..82c173f64faf35872801206558a8a5e2ea8ae830 GIT binary patch literal 2136 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/shade.png b/src/main/resources/assets/fabricracers/textures/entity/car/shade.png new file mode 100644 index 0000000000000000000000000000000000000000..48359490ba4bf5a97a51bc54e37d41630c916fbb GIT binary patch literal 2039 zcmV=^s}I-ehLE+IrHwW}0Jkq|;ONU5kOQ=~wyD5%pQ zA}nYG-HW_U8NjJ?1xij&}Aw zPr&rnN-|!aPcB|rPu{q+ny7zjb2*tYrSSqQOqKwAUg6{mspt4fPy*oYLYe?}yek3#B|IAf zc!i(UTO~o}TT82+Km@y+ggn4UD-ZBPz!xAb4@A~Na#3qJ6)(87U3iuN%u(X>lPS-T zN41*+zr0D=gpGT92uv+1)XayNGYKO};?0OS z3otbFl0zI%Ivn)=WZ_IMM5^^wxhZ~inp51W#ML&b^MPKVC?p6I)efpDv@`Gw9f!4x z^j!e^mPRK&7FoUVZbu>|z!|+A}V{k5dv7- z<`}(W?7ILeNcx4o3xG@NjIOOoZCOd-p-N7NCRKGh`U1TRP+rmY_|MZjb=agk5On|x z(u)xLMn`)&S7mR#)lsRd^MFca5#y@zmpKrR}%rX#KQhlcKgQSy;kpt_=@WinhX3DCvcW zMCgV9KFOc}d0i+k`W^yDNq};B+adIQ{+zxGkoP#8TY#zcT6I>b6(}3a$yjM7;l8kR zvFXJ@TwX}dFU}^fFVEH6`V99MR_BxPl{ek}#9GPSb7hQYGN0!%PMhzffA@=TABC{< z!JV&yF%9Uyov6w${7N*@&LRi!h&h!+G#8H75@q+`XR1Rao>Tb)u51I z%zbWl^QRCHAT++bdMQ%?TpW@swU}R8*>YH0zmniJwDFKgZtSe_EC*D9H%NoC`zyC(!f0^kX58}x~}yztCju`*U;!TvnC zJM(x>y8_4q`0UwO-ngDWl?fbO-d59zH zJCWKHz=q11@r7SQK!DIVWJHjd$NG?iJRX23+%GO~x_Qjuo__iwB{2wNMcvzi1)?fw zR8Wjm+>N=K>xQK+#aApR#2i}DD}tTda{+98i?H8! zSOI+N>t9_#mKXdlz)=c;fft}#N$|PJ0mpV1=tUtkuysH$OW}d61dsh$;HaMl{{v+E Vz-@&Ytrq|Q002ovPDHLkV1nzBwdVi; literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/shadow.png b/src/main/resources/assets/fabricracers/textures/entity/car/shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..2769f7d04b0b5ca841d825645cc189ad9fc36365 GIT binary patch literal 2019 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/truck.png b/src/main/resources/assets/fabricracers/textures/entity/car/truck.png new file mode 100644 index 0000000000000000000000000000000000000000..1c0adcc2cc411dd7a88d2c24f1ec5a96bfbe9f61 GIT binary patch literal 2064 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 Wj#SS${`L>hGzL#sKbLh*2~7ZnVkuYv literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/boost_nitro.png b/src/main/resources/assets/fabricracers/textures/item/boost_nitro.png new file mode 100644 index 0000000000000000000000000000000000000000..c1fba4123d38848c6ce87d3c0ac65374a8bec4b5 GIT binary patch literal 128 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`?w&4=Ar-fh6Am!@&Dr%uzD4o` zQU#PE{p~3uxM`y#Mi75_OI{70EJ=t=8evoIqe4n9fkHLqH4uL^x bq6`eKs;~IFe>L3(8p+`4>gTe~DWM4f{K72f literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/brakes_carbon.png b/src/main/resources/assets/fabricracers/textures/item/brakes_carbon.png new file mode 100644 index 0000000000000000000000000000000000000000..0a48838494238d9c6e37c6c0b2717e38baf94c58 GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`ah@)YAr-fh6C|!2ICk!>JO79Da^@=xgj-@ehOis0q3+LIxsIF!5PdRzgVTL6y z4>L&{sHHGmi*PV+cv8~vj&)P8Qs^fpO$LUth<960mL2s2+Qi`L>gTe~DWM4fcug{` literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_drift.png b/src/main/resources/assets/fabricracers/textures/item/car_drift.png new file mode 100644 index 0000000000000000000000000000000000000000..e1142a4bf89f536c1f993b27778f51afc9db6ebd GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ii4<#Ar-fh6C_w&8spk8{eK~` z`jEM$`@iJF3cL!l7WgW-N>znL80PZ+uz38czQ~~K?0&mB4aqTyjEBzOxpP3`sB4HJ zgY4`VHy0%2WPLbhY$&0skef5--(iDw2ac_F^YVIU&-2)*LBdppnW6EO!qQ)Y4Y5Fb O89ZJ6T-G@yGywppWjwb4 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_f1.png b/src/main/resources/assets/fabricracers/textures/item/car_f1.png new file mode 100644 index 0000000000000000000000000000000000000000..47c578de5199df9d1255e9e9761402ea3ec133e8 GIT binary patch literal 145 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`5uPrNAr-fh6C_xhMLc{ufA;sB zG;;9sW9{RVlG(lVxxJ;t(QEPYet+5%OJ+~_Xl!n7aIC=WDBA+zopr059Y){{R30 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_hotrod.png b/src/main/resources/assets/fabricracers/textures/item/car_hotrod.png new file mode 100644 index 0000000000000000000000000000000000000000..4d038463efac18ae2472af2b1a5d3a3932959d2e GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ii4<#Ar-fh6C_w&8Vxk3{(m8{ z`Vgm;+5e4+6W9cNFQ`pW;$KT^^j#3ULj;9qu|DSl7T^ODqp&l Sy;nNWat2RVKbLh*2~7YShCF5f literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_kart.png b/src/main/resources/assets/fabricracers/textures/item/car_kart.png new file mode 100644 index 0000000000000000000000000000000000000000..5247d9c11b88e9426789b3ce8a43f9659920dcac GIT binary patch literal 151 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`ah@)YAr-fh6C_v{GjQE2N&TnJ zzF6YZ>%*P@4gRP&akDC~u739Hn|zC5OyhZml%C5BC9VJELl-2(=rJ+#^6vd_(4)WR z!>Y|23xDw+-obNVwVRjMJA0nTMhz0CD$ES72WM|qTh#giXcL2{tDnm{r-UW|FE2I( literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_rally.png b/src/main/resources/assets/fabricracers/textures/item/car_rally.png new file mode 100644 index 0000000000000000000000000000000000000000..8fdde44ad172a086413b60fd329ceee42ca43062 GIT binary patch literal 152 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`@t!V@Ar-fh6C_x#u$I+c|M&Q? zi{yfZ>8+xduQh!7+W&8Ib70QDuMb&d9&@*+=jA2zJW^UE(a^WQ?r&Gq$75Yt8HekO z47?m}-LUxEo*2{E8{Ru*%AI=SsrW*oR^uqo=uHm23p17>FVdQ&MBb@05s7% Ay8r+H literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_shadow.png b/src/main/resources/assets/fabricracers/textures/item/car_shadow.png new file mode 100644 index 0000000000000000000000000000000000000000..097e4197248c3733b97892d2a148beecfda3ab35 GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`g`O^sAr-fh6C_w&8ecBm^Z%k@ zSZjS>$KT^^j#3ULj;9qu|DSl7T^ODqp&l Sy;nNWat2RVKbLh*2~7YShCF5f literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_sport.png b/src/main/resources/assets/fabricracers/textures/item/car_sport.png new file mode 100644 index 0000000000000000000000000000000000000000..34332492a10133db6cc41cc8afd24aa972931ab5 GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ii4<#Ar-fh6C_w&8rd>m{(m8{ z`cQUN)&Gr&6W9cNFQ`pW;bQJWu{@PmEy`SlpJvz_9h`iv}$_6H}nQ N44$rjF6*2UngA!?IywLV literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/car_truck.png b/src/main/resources/assets/fabricracers/textures/item/car_truck.png new file mode 100644 index 0000000000000000000000000000000000000000..bf16c137c9534d2cc6c784105fa83cca69fb56f6 GIT binary patch literal 166 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`xt=bLAr-fh6C_x#uu2Q@{9Bwn ziTA>V%}vRRcpbJ0-er)OC$*xn@gbYc`}4p3`P>Q*uIE>OAQ2sLAgA`(Gqz(IdJDQ3 zU+h@4h>!Jg??NB%ng5?8OqsD^W8p9U!#k3=*Gp+??)-1ibDAOP$^;&UMKLcE6<V!Z literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/carbon_weave.png b/src/main/resources/assets/fabricracers/textures/item/carbon_weave.png new file mode 100644 index 0000000000000000000000000000000000000000..3d3a8f0b4f2f7a519c91e5ac36ce8cdb42e8a969 GIT binary patch literal 106 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`hMq2tAr-fhJG#7n_;aQO1O@%% zKcuFXmf)i`zcHrQ!0stqz?;^K1`@o@2L;)tq@^?5n>)o|QI^A0pk@Y7S3j3^P6V4 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/carrot_key.png b/src/main/resources/assets/fabricracers/textures/item/carrot_key.png new file mode 100644 index 0000000000000000000000000000000000000000..293b4ab969889243849832750b141ed5c72dbe26 GIT binary patch literal 170 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`g`O^sAr-fh6C{it+_jtj-@s>L z+9ckC%NkXjSEM=IVB7vbj{D8O$vF%h4JL9+%*4hTCXZ?Ey3M>GZUzf~V(5XQVHDd?ZD^J9|)i5(3}%xrdwyb=rysuFAxt_~9(0S#gB MboFyt=akR{0HBv3Gynhq literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/engine_turbo.png b/src/main/resources/assets/fabricracers/textures/item/engine_turbo.png new file mode 100644 index 0000000000000000000000000000000000000000..ce5ac927ea31237e119d52630dc59f781e771a07 GIT binary patch literal 158 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`sh%#5Ar-fh6C{EZ)-QNu&y(!o zH+$m)d!uDtjMd*{fAb$!k!HB`W#J*VKF69pYzG)N9qU@9X5nVWk?(Mj$KhH}4o4jG z1=HI%b{;egY~$I(DALXtn#5?yxNtf1B9mm}j+EXd3=9v=R2QbY>~aTM$l&Sf=d#Wz Gp$PyJ0W{nI literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/engine_v8.png b/src/main/resources/assets/fabricracers/textures/item/engine_v8.png new file mode 100644 index 0000000000000000000000000000000000000000..1e92685d9c7576e57a00fdf0bdf9abbd44722756 GIT binary patch literal 125 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`E}kxqAr-fh6C_L$ZuPMKcbpON zO~H7<^88!%MFw{oHgvT7f0D3=ZB9f&b5r{QMv-<#Pqv1J-nqBy1=ObXGHRG59AIEL XbIw9csmC=5XdZ*7tDnm{r-UW|Au%aV literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/gearbox_dct.png b/src/main/resources/assets/fabricracers/textures/item/gearbox_dct.png new file mode 100644 index 0000000000000000000000000000000000000000..5a194a444cdf5caeb7704f0d2cc6805afabed90e GIT binary patch literal 117 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Hl8kyAr-fh6C~mu9ATRM-=L@0 z{siv~v*d&IK?ZG%oSu>Y3lqC|8Zrw$b2KEcY2%4w?%-n%O3Iw^RGz`=_gmX}o=5zE P<}i4=`njxgN@xNAXLKb3 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/gearbox_sequential.png b/src/main/resources/assets/fabricracers/textures/item/gearbox_sequential.png new file mode 100644 index 0000000000000000000000000000000000000000..9075a599531e6af287695b989747447ed0fa1632 GIT binary patch literal 129 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`9-c0aAr-fh6BdZ>NJ;)I-==xV zXPP2&KEu&O24-oqw?Fw0xoGBPz5LnVm&led`LMx(v&s+bH6>OvUQW~b;m_l1&8+e1 b5YWVbhCAMzKB*B4G?T&8)z4*}Q$iB}XPYX* literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/ground_effect.png b/src/main/resources/assets/fabricracers/textures/item/ground_effect.png new file mode 100644 index 0000000000000000000000000000000000000000..60d3ad8f328379a851d53f2d1f29070db4c1406b GIT binary patch literal 114 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`mYyz-Ar-fh6C_wI9R2Ta`F~gTe~DWM4f__-ro literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_black.png b/src/main/resources/assets/fabricracers/textures/item/paint_black.png new file mode 100644 index 0000000000000000000000000000000000000000..6e46f7e54677df9a039ae8bed25367b645360658 GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`-kvUwAr-fh6BdYebb0>l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNc^D}xOY5s;G_l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNc^K_}t`+BVfnn|p<%*9h7+oY3o?rMWFW-B;;mVdb f|BocJIkGY|N^0-Rn8l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNd2IR-U>xUjfuUQpuHxegMi+?%aarHw<$JF;T-oyG f|B-|?M^*-&S!zgTe~DWM4fiRCWQ literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_orange.png b/src/main/resources/assets/fabricracers/textures/item/paint_orange.png new file mode 100644 index 0000000000000000000000000000000000000000..81b8230f9055ede8c7fa84bff0b8f63a4797ddb7 GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`KAtX)Ar-fh6BdYebb0>l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNd3?Hih-c4?1&sQuOg=mcWbQCX2wZWdzQ!buIq34H g|H93ZY@!Sd3s;(#sqvji1)9s?>FVdQ&MBb@0IQxYTL1t6 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_purple.png b/src/main/resources/assets/fabricracers/textures/item/paint_purple.png new file mode 100644 index 0000000000000000000000000000000000000000..08b5f86fc4aec8db933240eafe90c639d36b9660 GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`KAtX)Ar-fh6BdYebb0>l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNc@)L|blo#!0i$i%?+=dxnL7*=Ja>P!x071WxH9wR f|IULv%|L5z95a{NTlKgTXfA`NtDnm{r-UW|&08;i literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_red.png b/src/main/resources/assets/fabricracers/textures/item/paint_red.png new file mode 100644 index 0000000000000000000000000000000000000000..7ad9ab8b3cfeba9dba327789dd5c6d58bd4a136c GIT binary patch literal 132 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`-kvUwAr-fh6BdYebb0>l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNd3?$$V~g{-(4cH3Rq=5Jql?4^P4&<6^1as^u55Yp f|42fcBP)Z*L&bM9q<0gTe~DWM4fbD}P> literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_white.png b/src/main/resources/assets/fabricracers/textures/item/paint_white.png new file mode 100644 index 0000000000000000000000000000000000000000..90281d83b733521a80c5a7f5f639ebb40697e539 GIT binary patch literal 133 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`KAtX)Ar-fh6BdYebb0>l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNd3?INyS&2CkvVQpMWcAXltT++x0vp){{Cax4p~v( f>XQr(FJNMbnNiS{Iqi-a&|C&jS3j3^P6l@5#(} za1Fg&A7t>Am!Z+}^R&O}Y>&UNd3?HiG-uC@1&sRNUOqUwm`xz@z@o67|9@ocVOz2+ h^}jlsF^?7l!_h^qVt-PVR{+gr@O1TaS?83{1OP3wFt-2z literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/race_guide.png b/src/main/resources/assets/fabricracers/textures/item/race_guide.png new file mode 100644 index 0000000000000000000000000000000000000000..3cb71e3ba6485c5f78bd9b362f52ffa187d8b449 GIT binary patch literal 121 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`4xTQKAr-fh6C^|yIQc)e=Xl@E zr684T`rzzjZ+RNs1S5>Jc9#y`{`o*6;MVeuff>^MwG82Q-(l_EW T_X$1(n#JJh>gTe~DWM4fJisD7 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/race_pass.png b/src/main/resources/assets/fabricracers/textures/item/race_pass.png new file mode 100644 index 0000000000000000000000000000000000000000..cc15898778590a345fe53ae307c35a7382dac1fc GIT binary patch literal 122 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`j-D=#Ar-fh6Be)*eD3<;-*cnD zIN{co%Kx4RBX}(qE_M`IHka}Jy;>!%$0iQW&hP(si|2D(+EUDTbfd#WMrUybhMO7k V^F)*8W&jOi@O1TaS?83{1OV44DIx#> literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/roll_cage.png b/src/main/resources/assets/fabricracers/textures/item/roll_cage.png new file mode 100644 index 0000000000000000000000000000000000000000..9a352e323d74262fdb864d3c40732d71307c3b7b GIT binary patch literal 126 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`uAVNAAr-fh6C}1gXz4v$U*Z$b zoUmYd{;m2=dpBmCo%UaW*}>#5Z$svyF2)lzlT3oIGA!bC40nlV)><{|pKx<#I)fzx Z!{)^kw6cy~Ee9IN;OXk;vd$@?2>^87D&7D9 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/stopwatch.png b/src/main/resources/assets/fabricracers/textures/item/stopwatch.png new file mode 100644 index 0000000000000000000000000000000000000000..c6b27b7b96b75517f0ff6515c72d49180cd3d039 GIT binary patch literal 165 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`Ii4<#Ar-fh6BejiY&-DKp6Bv? zhLp1gHo3QK4AeXy&o-CuFlUGq=E=@@vGBvkD$ZDAGvEW{{3MRebu!LQ$Z- N44$rjF6*2UngGviIX(aY literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/tires_hard.png b/src/main/resources/assets/fabricracers/textures/item/tires_hard.png new file mode 100644 index 0000000000000000000000000000000000000000..785e4321eda54456634b95557c23f8322ab32533 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`F`h1tAr-fh6Bd{S1O)u-=NC?G z=<8z)>$%KO68%B4;i{>KbV6d{XZbcqA^o^L2f7@KPGo(Q+K_kGN~GsHL&@@o#tkbB w7VKvX;diOI=nHzZK1-Mwthk*+Ha{_{1=_>l>FVdQ&MBb@05D)OKL7v# literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/tires_medium.png b/src/main/resources/assets/fabricracers/textures/item/tires_medium.png new file mode 100644 index 0000000000000000000000000000000000000000..408c9885675a0c6d491566fd2715ea9ff04fcb38 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`F`h1tAr-fh6Bd{S1O)u-=NC?G z=<8z)>$%KO68%B4;i{>KbV6d{XZbcqq5f|>6OSrbrfjX?jkuZK)bvrR;hjkhn}8_W w7xRW!jUD<@om?)HE;AnWoVjXdX9xqsu2PLZK`$?V0@}mi>FVdQ&MBb@0873!R{#J2 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/tires_soft.png b/src/main/resources/assets/fabricracers/textures/item/tires_soft.png new file mode 100644 index 0000000000000000000000000000000000000000..f2c8b7169789b959abca8f3b5cf3ebf19ba3bc23 GIT binary patch literal 149 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`F`h1tAr-fh6Bd{S1O)u-=NC?G z=<8z)>$%KO68%B4;i{>KbV6d{XZbcqq33VjY)G0Qvngs1+nGz3oex&TpE7*M(K-$%KO68%B4;i{>KbV6d{XZbcqAyMvk2}czyQ?^#{cC3o%YWgVE@Xn-$O+b|G vi+RJV#twa{PA->8ml=4&p%~$g_DU4Tpu$n{hnm9-Q>G;#(_^_ h(X93zm)Thu7>+nD-n&(A@noRE44$rjF6*2UngF*wEs6jD literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/trophy_bronze.png b/src/main/resources/assets/fabricracers/textures/item/trophy_bronze.png new file mode 100644 index 0000000000000000000000000000000000000000..e3b9daf6c14ac585080d7a727e7720d86f950007 GIT binary patch literal 129 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`9-c0aAr-fh6C^?&cuc-upJWin z{4C|hUwixA0_tohg-bZ~&bm~yF0nZ8$;o>8I78RrhDQ+x(j|^&Htl2c@!RsNpZD^4 chFwey9(T^pU+>!O0yLAs)78&qol`;+0GVSe{{R30 literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/trophy_gold.png b/src/main/resources/assets/fabricracers/textures/item/trophy_gold.png new file mode 100644 index 0000000000000000000000000000000000000000..f4131e43ec95f8e33cb6f43e3afba4dfd4609acb GIT binary patch literal 130 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`o}Mm_Ar-fh6C^?&+~w^5eKrmw$9bp|VNu;L@2H>l;L#i(Xxj@Jqs>P$EEv`v=cr3*QUZ8zPIv{~vFA ena&W)$k20=XV*0s&TT+L89ZJ6T-G@yGywqJj4#gs literal 0 HcmV?d00001 diff --git a/src/main/resources/assets/fabricracers/textures/item/wrench.png b/src/main/resources/assets/fabricracers/textures/item/wrench.png new file mode 100644 index 0000000000000000000000000000000000000000..18697ec55faeb5e32d7dd2da3fd72f1d0a2fcdef GIT binary patch literal 116 zcmeAS@N?(olHy`uVBq!ia0vp^0wB!61|;P_|4#%`)}AhoAr-fh6C{KZ4llF)KhZFf zEg|{xb%w6J6T$<&&VR!oBXL%N;gXO2?TgN{Qf9B57NOCV-6h1mlYzlXDQk6+p4D!k OF$|urelF{r5}E*Aqa?im literal 0 HcmV?d00001 diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/asphalt.json b/src/main/resources/data/fabricracers/loot_table/blocks/asphalt.json new file mode 100644 index 0000000..a98530d --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/asphalt.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:asphalt" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/asphalt_cracked.json b/src/main/resources/data/fabricracers/loot_table/blocks/asphalt_cracked.json new file mode 100644 index 0000000..44a7082 --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/asphalt_cracked.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:asphalt_cracked" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/boost_pad.json b/src/main/resources/data/fabricracers/loot_table/blocks/boost_pad.json new file mode 100644 index 0000000..9932d9d --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/boost_pad.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:boost_pad" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/checkpoint_marker.json b/src/main/resources/data/fabricracers/loot_table/blocks/checkpoint_marker.json new file mode 100644 index 0000000..8059c0d --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/checkpoint_marker.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:checkpoint_marker" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/fake_ice.json b/src/main/resources/data/fabricracers/loot_table/blocks/fake_ice.json new file mode 100644 index 0000000..6776a46 --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/fake_ice.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:fake_ice" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/fake_slime.json b/src/main/resources/data/fabricracers/loot_table/blocks/fake_slime.json new file mode 100644 index 0000000..3bbdcea --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/fake_slime.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:fake_slime" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/finish_line.json b/src/main/resources/data/fabricracers/loot_table/blocks/finish_line.json new file mode 100644 index 0000000..37984f5 --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/finish_line.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:finish_line" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/kerb.json b/src/main/resources/data/fabricracers/loot_table/blocks/kerb.json new file mode 100644 index 0000000..780035b --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/kerb.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:kerb" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/pit_lane.json b/src/main/resources/data/fabricracers/loot_table/blocks/pit_lane.json new file mode 100644 index 0000000..e2b3af1 --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/pit_lane.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:pit_lane" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/road_block.json b/src/main/resources/data/fabricracers/loot_table/blocks/road_block.json new file mode 100644 index 0000000..dafa597 --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/road_block.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:road_block" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/safety_barrier.json b/src/main/resources/data/fabricracers/loot_table/blocks/safety_barrier.json new file mode 100644 index 0000000..bc1ca38 --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/safety_barrier.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:safety_barrier" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/start_gate.json b/src/main/resources/data/fabricracers/loot_table/blocks/start_gate.json new file mode 100644 index 0000000..556563e --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/start_gate.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:start_gate" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/loot_table/blocks/station.json b/src/main/resources/data/fabricracers/loot_table/blocks/station.json new file mode 100644 index 0000000..ef20ef6 --- /dev/null +++ b/src/main/resources/data/fabricracers/loot_table/blocks/station.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:block", + "pools": [ + { + "rolls": 1, + "bonus_rolls": 0, + "entries": [ + { + "type": "minecraft:item", + "name": "fabricracers:station" + } + ], + "conditions": [ + { + "condition": "minecraft:survives_explosion" + } + ] + } + ] +} diff --git a/src/main/resources/data/fabricracers/recipe/asphalt.json b/src/main/resources/data/fabricracers/recipe/asphalt.json new file mode 100644 index 0000000..908c149 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/asphalt.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "gs", + "sg" + ], + "key": { + "g": { + "item": "minecraft:gravel" + }, + "s": { + "item": "minecraft:stone" + } + }, + "result": { + "id": "fabricracers:asphalt", + "count": 4 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/boost_nitro.json b/src/main/resources/data/fabricracers/recipe/boost_nitro.json new file mode 100644 index 0000000..fdd0d54 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/boost_nitro.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:gunpowder" + }, + { + "item": "minecraft:blaze_powder" + }, + { + "item": "minecraft:glass_bottle" + } + ], + "result": { + "id": "fabricracers:boost_nitro", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/carrot_key.json b/src/main/resources/data/fabricracers/recipe/carrot_key.json new file mode 100644 index 0000000..0f6ebe6 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/carrot_key.json @@ -0,0 +1,19 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "g", + "s" + ], + "key": { + "g": { + "item": "minecraft:gold_nugget" + }, + "s": { + "item": "minecraft:iron_ingot" + } + }, + "result": { + "id": "fabricracers:carrot_key", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_black.json b/src/main/resources/data/fabricracers/recipe/paint_black.json new file mode 100644 index 0000000..924fbcb --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_black.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:black_dye" + } + ], + "result": { + "id": "fabricracers:paint_black", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_blue.json b/src/main/resources/data/fabricracers/recipe/paint_blue.json new file mode 100644 index 0000000..79a7420 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_blue.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:blue_dye" + } + ], + "result": { + "id": "fabricracers:paint_blue", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_green.json b/src/main/resources/data/fabricracers/recipe/paint_green.json new file mode 100644 index 0000000..ac4bf5f --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_green.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:green_dye" + } + ], + "result": { + "id": "fabricracers:paint_green", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_orange.json b/src/main/resources/data/fabricracers/recipe/paint_orange.json new file mode 100644 index 0000000..7252666 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_orange.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:orange_dye" + } + ], + "result": { + "id": "fabricracers:paint_orange", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_purple.json b/src/main/resources/data/fabricracers/recipe/paint_purple.json new file mode 100644 index 0000000..5523e1b --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_purple.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:purple_dye" + } + ], + "result": { + "id": "fabricracers:paint_purple", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_red.json b/src/main/resources/data/fabricracers/recipe/paint_red.json new file mode 100644 index 0000000..634b2d1 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_red.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:red_dye" + } + ], + "result": { + "id": "fabricracers:paint_red", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_white.json b/src/main/resources/data/fabricracers/recipe/paint_white.json new file mode 100644 index 0000000..a1ef645 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_white.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:white_dye" + } + ], + "result": { + "id": "fabricracers:paint_white", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/paint_yellow.json b/src/main/resources/data/fabricracers/recipe/paint_yellow.json new file mode 100644 index 0000000..ee5f98d --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/paint_yellow.json @@ -0,0 +1,15 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:yellow_dye" + } + ], + "result": { + "id": "fabricracers:paint_yellow", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/safety_barrier.json b/src/main/resources/data/fabricracers/recipe/safety_barrier.json new file mode 100644 index 0000000..3d65502 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/safety_barrier.json @@ -0,0 +1,16 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + "iii", + "iii" + ], + "key": { + "i": { + "item": "minecraft:iron_ingot" + } + }, + "result": { + "id": "fabricracers:safety_barrier", + "count": 6 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/stopwatch.json b/src/main/resources/data/fabricracers/recipe/stopwatch.json new file mode 100644 index 0000000..ce8b9ee --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/stopwatch.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:gold_ingot" + }, + { + "item": "minecraft:redstone" + }, + { + "item": "minecraft:glass" + } + ], + "result": { + "id": "fabricracers:stopwatch", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/tires_street.json b/src/main/resources/data/fabricracers/recipe/tires_street.json new file mode 100644 index 0000000..91f2ebc --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/tires_street.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:black_dye" + }, + { + "item": "minecraft:black_dye" + }, + { + "item": "minecraft:slime_ball" + } + ], + "result": { + "id": "fabricracers:tires_street", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/track_wand.json b/src/main/resources/data/fabricracers/recipe/track_wand.json new file mode 100644 index 0000000..1111399 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/track_wand.json @@ -0,0 +1,20 @@ +{ + "type": "minecraft:crafting_shaped", + "pattern": [ + " g", + " s ", + "s " + ], + "key": { + "g": { + "item": "minecraft:gold_ingot" + }, + "s": { + "item": "minecraft:stick" + } + }, + "result": { + "id": "fabricracers:track_wand", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/recipe/wrench.json b/src/main/resources/data/fabricracers/recipe/wrench.json new file mode 100644 index 0000000..4bb8d71 --- /dev/null +++ b/src/main/resources/data/fabricracers/recipe/wrench.json @@ -0,0 +1,18 @@ +{ + "type": "minecraft:crafting_shapeless", + "ingredients": [ + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:iron_ingot" + }, + { + "item": "minecraft:stick" + } + ], + "result": { + "id": "fabricracers:wrench", + "count": 1 + } +} diff --git a/src/main/resources/data/fabricracers/structure/tracks/asphalt_park.nbt b/src/main/resources/data/fabricracers/structure/tracks/asphalt_park.nbt new file mode 100644 index 0000000000000000000000000000000000000000..22ef589dad9c96dc6fc7dd0333885c2f162d9271 GIT binary patch literal 37054 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 Z4IHu0vhj_9gpk!Vt;pb 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 SoZ?w8p$2I_wJAM5?tcMEM;vJY literal 0 HcmV?d00001 diff --git a/src/main/resources/data/fabricracers/structure/tracks/kart_circuit.nbt b/src/main/resources/data/fabricracers/structure/tracks/kart_circuit.nbt new file mode 100644 index 0000000000000000000000000000000000000000..3e5f19c0272e558906b3ee989792021211180159 GIT binary patch literal 12930 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 literal 0 HcmV?d00001 diff --git a/src/main/resources/data/fabricracers/structure/tracks/speed_oval.nbt b/src/main/resources/data/fabricracers/structure/tracks/speed_oval.nbt new file mode 100644 index 0000000000000000000000000000000000000000..b40fcb8213cc0a6c14b8705064086bdacdaf937a GIT binary patch literal 42178 zcmeFa30M=?`ahnHggqh(ve&j^ts;Yfh9w}<<@SouUKbD%2C1wn*of?q0783J=)K;n zBZdICEofPSMGD9^Ar;yhNLpl35kjd6$QBzQ?EFpwDlUk<-+rI}|GD@3JnzFC-ZPn* zGjnFnd*1i+d5_6yW#u}`&0yRdE$r~&k9|T<`X1SQ?9d6!Kkfw_xD=I<9oKeZ?f#X2 z`Ud?NvJs#8;YTfI$*v~2(#zV;Zpwy5p58O8v%9%vhqRxX7H`pCGWYY@q5dj%35PN* z3FPj_Zlfna1x7D&7B0s!e8#6s9X<+9~t-s(HKgD)To1JKwlg!Qa4oG?% z?hhw~O;1(EN5@U}^s>z(rUylL(@EiT$B$3kO^C6X8J-zz==&jSt}r)YJk)4*a^@F_ zh%ftj|Dc;}ZZ^SZF8+I?mPzuh+xOdU`k0S}#@AkV?7ExPHaRGXo)g`j@aeqEPo10k zt&QY3BhCsf=rPfp8I=r-l+ATG+D#Yt zOZkH?Pl`bk*G3 z6GNMmeKFbKcywComVRt@#ynxoRA||~#^JJi2{TSNvl7B+Q8CvOrU&~UrZ;!ZPXD=V zcJ$p4t@pRpCVW2Ua&6{=X>ng;Be;J7U-p@s^!9bn+0Klb`v_9kU#OiuIX*f*!f`7h znqj+?h*Uc5Q7syQSv)TJlW7?8KcM5@T-a;avPzrdid+z$?a%*^G5r4;@5L;H3n_xO~i;l1E13 zVIp0R;IEAMmXB#s0#+dwa6RK9#(yQUp?oZe5@-3Hi`(r-r0YbEZL?Mo}w_c47VWfzixb zokvY)$XgW~m%#|a=9cP9cZ0fCn?dvhrdh>B7rKYqGeU+Lv}i^jKGy`n!bFB%Vu(o! z?LE4llD|4~95$t2Ob*FPq~$U$V*Pax8(75HPY>})rk$tnC;BTPdtnx?gxFT2DL#TJ zQQ_%8chLdNx#SQxj!h7}1^t-u1NIy#7t`jQO#3tX55^Tti5kxs;tTI&ac#`$Q7XKR zus2>xM9RVjhlNZT8jBIV0cFuA)Oot)V{sHQ262Yd=r$?$#*573i#VW#N~S~?L*#w* zO?nU^R|RQ6t?d=1p6ZBWc2Fz|kF2O|)QH3he0jyLV5u;TY(NoU} zd8#e&SiXg9*(w=tseK$X`3tc(_dY3?PjJXK;eQ7|D95b;9*UM4Ng=p@0zYx z@_%|Q08O5San_w~<@z)}KTat-0w=J;_GV&6Trtt>G1c1iPL=J$g#Uc?l^e|MNlYA# z$eXAYyu;BKzeB%5%FWTIxouO*xs)i_<W=?-^`6r}>1;2KN`nTLf^Q9~?Eou>ljq0GI0LqYke*72Rg zxTcD!{WU8_+?8uWS0~P@IwhRSuH7N87NqIX9fj=~+;o@LFTL`M*vbY@sXIcLDXnYELJ9ww?T5Ht7wC?5EcS zNm{)e0eHf!*-;b?lFQto2A7hY@|68u$>AlRRNQOREuiGp?L`w9Lk z$YFRNeZP`u0~CT5;7hQ)#U?Mb*erC>LRLbD;kVHNTnUM13A6B09C87z!ilV*Pc(4;L$|kWH6KwNai?$XdMt(m$Pfw`2+%s`0c#li`+G1{BHO)^@VbR;M z=EN~|+ik>5gQTXjPU8CP|5GpAlQJFJ?z0tn^7~P$KUPHRk<4^ZP8>6|-KLpoklJ+C zSzMn(>k(8O@BKgZQ}-mqqO1kU`a0rRVag0qJUva4D%Y4#U#^eQ#Ap*lYoY((&#`>i z(SXVX#XgJ)BJZI2`s@qZf}1M6_X#7P;G%7?)l9m#>-PUszwDlP@*?1&jO$wemRzmp z@4SXq-vOQ-=#lrsD`p#0?%8?lCnz6tq67fX&f^iTd`yKB06aVJBkxD~SQI4ycy{1N z-jDLJys#$V*?H_N2phBua|NEAN5dSaRNT!3o*np+_oJqE(0`+{CEBh*lyS;iCDWjveNhfEU?82~$Zyi9^S} ze#q*lt1SC671dAgsjdvUmv4q%!!;9K>>wlbL|thF#|F5Au(>3}8=j>1rG}W$X}}%y z*m05+VvnVx z_MViF88YSWphx3=*kEN?6L1I7qcnMHus2SsS~O$Ll)Hn_D!hmanb0Sc+h$e9xO_Ag z>u>VNTd)SvhFuvpc#$EZ25)9m;6(a}0ep-xp(>MZgKO}#wtMM0l_t&+jwwU&>i1^W zT%WQyA@l1UY7o4cHpF|hq$5ONu<(-?7P%ip+x?+B=IGVv{5+WFNw8`tkHXknjM}_l}}jR&{(2K z&8zJd&W41BnZ%Psx-d8$jundKm(j%sP^p=4LPrv-7FnaTl-j3%cEQIfKNmC=Hn?od zz`4@n6z4MRRIMekSA&gas8VWKBKH8NyHF_E7Z$mZQuPw`P+>TGh#TXuTvtY8lcpqg zP%T;=E$-afo^=_W^ zsyp62Gt=rWMi3TGMC}Qt1eZ04GNf>$E^;y!)OYl>oRkFHMV1;>-O=WTayCyEA@-lqKUF^^xMs|`OV~1872|G2zLjO0^!PCQ-TGS?DXpkT~nHXco zsm>I3x`u_WFE7*XxT~_1x-fjs3fD}K_jU=3EO*H*{gUy@f_YTO)K(R{qw6C})vE3& zc@N^)CmHUQX#PQA%VtTi?#tABd*Y8J(!ke^E{j#>X+aGr6GKzw=|N)D3qw=kt%lmr z4h&6==ML9!W~4@l54}Pfye+cSpz4l>_aKpdGReJ?lYdXxa!j(6Izl60YaO)}w5O^S zTP0Tbke{6~&?|@{B60w(!AUic;V_d~9FGhSbxaX{ubjNIUf8+5m<_sLH`U~^L+9~i zY+>|AW0gb(P*?Ztgc4$cc0`qu;&by!Rd?{-gIM+$-8}})rwUt)CBfv#(mz@qVk2$J zuP$bfCA!CO@~ed{-jZPR$Wq;^J8Is8N`J7rQ@?2%D+UcDSKT3b58~Nl4EHHCKTg=< zAPF{!{DajT{{B%;jr>F;_E?hp6eoX1*b*-Zc8n}Fsk%e{qt)OZDo%nIwqPX>Fp=Sy zDqD=V5r(ZvcPF9wFHw*0FThHI3u&*^NsOoFJ4yJ8DqCf5BLZ79(VfJ}PZG8`Ngliw zxuiN?CuGG~i_^GGzEk@v{ovWdua!Jl5gD#rWlQij!m%|O?x|?Lv#@0;b-SXzc=;V) zd{|#_p}vv_P-OU;DqD4LBO+Tf$vu^m&lR@(;p&vE5`9KoIHAfG=WT>#r_tTtNAuqh zwrEKnC`bOmYLvhKELvl$>TRUNPD^xupOgQEu;nAkgSR5XS5^JN>TYqtd%iyM6EWNy z(0o5(i-qLDn#geVDqEtr5&n->w=bOJxa0vPGTgArR@2*v#7;|cZ{Xzj2wPsFo@Tbl ze-Yml{Cj@2aE?q2+m-IV9?jPlwx~!R5F*2IRkm2~rPTDSk_>uWI1Zc)H})z&c<%5k zt87)gGYM?hMECWa{By#V4d2Ejf$9d>F%nM$&z#Fzk1b&0TZf7tM*KU!T^>qt?H8Qv5bu32SE^3KGwn;7n5 zG@mAHv5`D5e5o2->F@l7cV<#TLfHJ7NbII0cQGelDr||8Jg|=pH?FcJduP5_U3kMv zVGj8z0@2ehZT966#vOL#p-hsjDJe$Gsg?>mqr!%lRJ&JQ|FFkX3V>Q(;Z7VuTH44WrXI*@H}X-4LdfW-T&iZ%%aA<>a3h zw!ABO@OospR@HT7?}LOT)pa#5uV3P5;AaHygE;nPhWiCHf2Xizo#esF$Z(aam#SM2 z)hMEPUTtfk_fbPU4aeI61)xkk4a-{xxuafq8lGnc$z47&egqtk6*X1g6m}jC8{SeL zq1REVK6H>M+nf}0f%6LL1N~GJ3~gC+nL1Xj2%DQF#kix@8-<;#!-iGMBa}KSaYF~O zvLgCY>JW=va_3xSS!-Oo`a*!REgZvJzG>4D<>Um=w(fkPWN+B`+mzVVhBd01K1!+V zM6NsMW$N~X%)8Ub?P*sYYmjj)$bW4ZCTj4v5 z2@LOj*nv};pl}7ChRb!Xa`##+n2S}4n~3%iTZI)RSbnBPINa!oyOvzh}69^l`hc&$hIiu=$OC0Yl$S-WLC#;D8 zokLx)mKr>Ds07_3>@QIhKZO7i-tpnuEQ(!KO=>MTcc7`|?y^h@ro1z1Q&OD){()l0 zP|j?X(!c#rsT}*@d-6KHk0?@ssOSi;+5POqmC0GJex?HGwcy$7zSAINDu7-Ko;~56 zfNrJ&=(XV4>%QYIWX=P<<~@6LrJ)=f1<-53v*#6~(=-qCn)mFLkEt@}fnE!qy}Huc zMA|&iYr(SzmrW@1*24~X>2hzN(7PJEf>0N|BBXmz-&J{!pYfj1?xTVKBk`O^zdd$$ zlKe4<6P!ZVBl;7NkKlT`9?2hvd;sUtFB1I~pgv)g1(}C~PNSPRmdEs(_GA55BY9{6 zwnURh^qMkdmMHPILU!nzj1REqQbPEs&0?%rn|I3oq6O^R6K5yVhE`!kX3#J_ip(Ry z4mhdGtGox2cKN7M5z=lH`2W3l^6VisbQ7+Gz}o_CLg98P`zWRGcb%9pzqYrgKBUE6Zv5oiU)yVZ=WX)Qf2Mx> zqw|6LIfcIo_C*E$_oxfc+oYaX#KJ3&y}f-~%b|av-+{TniQU-iS1|YX(B;b2*_TuW zyOer!asT=1(#c;8k<$q|hyN-amq4Yd3hGjry>9_n!qD*7g2l<%vJ5*nUOG{7a(i2B*A#WdFyi3*%;2 z0sqC&ogDSS4^Mx9Y4!lajM0w*&BKAITdq0lDfB5P-*UG5N6vqcx^28`BHg#aL5`Ct zN$?f3(I%hML0^MGB~DRhWY*&B5|mEPst!!AZlBm#3})K@ffa?BV=GSHd4nQW1n;|N zItIHdt(&6MT`Zvmuuly5Hc(@A$0P^axe>nPu2cUh|9DomF3>8RtVMYbVj>G^fHyX) zJJv~UkCDN{_mZk3*!87lTpNIP!Zj3cuhgfdB+uc25*Vwz3DZzin-vp z;$ba;GsA1@b>>s^_VTfCO2A^4i&uLCdA|PXWS6#*YzDz>w<9CH>$D#!ks%6lp<@!iRGX-c8jK1&VgjND2Y#SZR9vW=`?SEf%ER4$G}NE=P8kN1sE-n=D@Y_f%#Q;xp?arlAeHrmn!e~cj!T+ zTs-0mKkfTd+0(@h$+X1q7W*_8x<;(d?yx2+$P(q@EJ-i+=yZ3s(`j27w{iXkh!OmE z^h^4qj^S|Hp1HJBhZ8WV-!$Kqu~K7%j4pV;L&m?-mr5Pys#@o+nHZxsselWmJ)5Mm zLzj@FmMBni>P>v@gwu-auPJx+C_hq`BCVj2b5pT${lOKX!P(WhQoMt>*QM()~v*2Ou-15pNH>FWs)IRrq>9loQr7rwh5oN= zt-f~-{5)0g8acCuRLN z2^uRCi7HM>$=Obe)q8qqp{X=mdS5|3;p=TL*>Q>AKd+880q6Qli0g%J<`W9`W1y~_ zru-O}`W&kl@|k}pUxkk{#C4{K7ota};zrex%He+|cMp4|<{YjQZ1D}e2wiho<7ujH zrk!$L#rbCPV)blO_(Iii-cuDz?EkE~Tc4H1p#6I!W|XPZ&3qyKCXxTkvnbqW`|*6R z3=E3RTS`yfsm#(iy?yu{VaztI-c02YS3>l9T(vf%^E05#j(A;Qw}kp{`b`5VG!~{A z?_v(cp(FSgf1RM+s0-c_&)WpiP#(VILlB62#WP*G&s)Vj_o%N&!K&!Okn)6L{}_m^ zprJp(9lyB6@(1h*aL1Lk=|1Fpd&miSxyIM8{BEgn+av$=?-w6GpUhJ<(UNbuLHRB>YFH>)*+?l5}e^7m-x^ay?^)m6)`)eHxM}RsHP3R~2D|zSZ^v){o^D zB3mEkN`|V@5$xh`wlegEWd9kC%?oL{d;EqbSbOl-_P$tR-M+@&c}iNDx!uhF332~L zD16|p`l@g<2pR@m6PUQHiZ=;grEjKsR_D}vr+0kuZq85t5(It}c}LMn1I8EsE~Hkv)1y5N#1yz2=&?x~Dm@P&cXdw39+DnJIs) z%s-x9I&X`;0^zVgU;A?B3yvwAI=*nC8k70cv`o4@6R!kJgeL=E3f~GZew3HusaGQF zh9yP(gqX4TdjON?03To>42TK8MsJNP4I*^T0JdN?yTjt58Drm5*(WbbLfi`ClIK6<@(_8;?ZV@_h9H zPZN+l#eJAV8G#L>A5%9jOK2Qd z&UmO5OMdv#*^qRcb`skSOdAL;IPZL|qH2)DL*m(pUPHqdy2BIG9Hd_H&L=hX{d=Z4 zd|p_39*q8fv}xWXXQl$kN>~$#A_nP7V8>~YujHkKo$lERM$Bl@Mr~{F{+CeQ00t*N z;_$!ftAA2gbhj$;%Y&a>>Pb8=^9%iu>Cka`)RXeHmSKx>S}@^#>%=egIUk)BUVblZ z&l``_$+a8rdGyc{6bK(QrgL#meu_=2z{U@qJy7nBPk?q=BXT+|^=lQw7D8Gu?tN>< z7a8bByM&ib!}e%Xo~T>Ow`B-vbAU5bl?4)efa{sw{G(~uPVMrqR62ep91FsgSuqY| zpk}*-=jYX};&?S#d*%V-w#dTQATmP*t4%b;xlo~n1U+nE3|8K^D8FwJJ3M^+EI-bq z3D5?X#zCRrc)U#Q430{pFxfwBhb1LPk10@h4J59&N>Zq;MO00(J210mDn%Ras7+?l zq!-r)u&(y`4c$H~KUzrDJ`D1F*8#!WiLR@5Aw1z1V8Y1na7CJ5EZ{_SOodniX79GV zgV1wgJ=79S$y-P?0%+5-vq8G{Zk4d>G9rw+qr`7MhCC;ctdm^kB9UG*VC9t}OFa?5 zFmqssf=B$DHgbPGbkhw!pLHuzCt1jZAT9U-BM4)v>Y}c2`v(l2)0f7pv%>)vG1faE ztm!$f+We7%$h=2lPm6(FUm)N?;Q7tQ$Z`_N8bhVm%IX8OfZ1S~R}HHF(-2H~4SemHx1V*%+u=4N{ zd!uBVMI!EdjrW|`yOFx1Y?W!t3BTDXlBLOEYI69~Wcg+(k)BW%y{fDcomj)l<3cZ$ zVwq-}@oEO`skcJh_m;ie`@XKv3b>o9G=b&3N zVowWz&&x%4(GFi?FPGX&hl&1CKy+9YCD6^6iG-JVs;k6Gt;BvAsU;uPIoV@_G%q8O zq9%@Igr?}k^1{lKLdyxbr>R|2R5HIK3c0_Gv}cLCyMFSZ+Z=qEboEtZYMW!QtO_+K zZyAyB9x@c6a^7ktkzBi$D()%hK(k^9R?q^Rf?a{oPwctt1wX|}A~8AAQ* zMre3mfKvdlS?k2{+lKR@Wuz2&2blCxK7|h}9KNvPSMdk@2ppZSeWU5@K<- z1*whA-GvTtLyTSmOb6}1jcr9$`i05iVRg0?Un6F*rYiu)Psz)(-akK9k=7R^X}5-S z7^E{MqH(=v;S7f z_%1H}D95;TzQAbs7M-v6Fax;6Gnugfl3~C6xPYk6$be&#JX?oi*kLY4l z@0rvikLCIutve3#Bc@zEDWqt?Cw&X3lqNHH%(nnfX#(E`sMW$yOhE2Jts4y2C-yL% zIxnHu=s_TBt4I@yU{sKJaj-E)x&kD3wU=`i+!#?r9u4-!NLM26XcbYU0fjOqv=Bht z(E~{VCRYD8YXIG=XfZ<$+)I4CDKu`|>Mo?XGd{pus=E-8P0$-?EQx0g*QDCcVHzql z0hPlB0Q{c-(Qj4Y8m{0hHFEF(USsrOXe6W(?ZAs1ATm8lnfC_ByCx;X_p|DbzaWYjPY|t#7s$yKAYdo<|ukx0qNIyhU;PgoJ+6w_URessko>iTOlKO z2t(6FV$cqf2n*akQ3MbcWnlnyAV8QUMyu#o<#F%@dIYmAJ&Y-T=AtdN7-@e1?=#J4 z=t2*oCVGbit+%Dy{DcK6*!2X4_Afl5ZGA~JE;=JO4ePu_D@0pTp$j(26}+{)!&t6j<(Drx=zLXJf_RD3j!YKbP3$=$&uooXfI}*jp3a@qK?L9|Wst@w8TR_^TmD;gj^Bd5%vR z{Wghhj2>6yHkR8Mul6ATd?NeBsiEd05YuZK-uy)@Z_k&y&6#$FGPtPTLYHTh<~tOo zPD}Me>b;M0l^dWP3bNPtH`EOS7P5Lj zPU9peHt9p*aBFrR_|PqaLFms14#5}wshHSSkiGUDl!FmNM`2sE11s7L$>SeaBGS8Wl={+C+g3OMGjNT0^ zj5jE13FVef9xdNjtI_tLx~bpR5M-5K{OCqEaF4%@?qMu2(vG1OUCBL`i6O$@^tfyu@21`1y z&zY@W`8Tb{g2<)@4R7|auex>XPpv9?yz!+@FZ%C=bul03sWv=n+V6Z~Da;}C`0weJ zJSsedmtv4qkS-b|`*)**y!=nHjwbfEv;jh1ZcMfQC|q>I;)n767H=LIY8?fmZGVe$ zk^39v-e-l;*3bRZn!Hrai+}Ur_}Odf4GIdO;!S(L7lO3%G_?h4GTWGQT#*Crg>M@5 z**A?rOHge9$UIMew`V^{LhW1Nj-FuOZi+th&(nT?f7o`U8nHjKm)^6gn*vf+L;IA{ zcAx5zBLw|+&A&AD8HqhE+~WZBc+f^jjqNB(fmj1to1 z_5id`7V1>ea2#aZO9NM*pcZWuusUrwK3CJPkaN71ahra1Yc~ZT9=INMVNCG;LaoMz zVlWm%tyn`bNT0W+e||7t)4wWBn{&MB7lb2i>yDe77(N6Tih~sY-szeP;9uDd-fFlA zca8{F3Q&h`Q~V{<%(uR~MUTQn*UX}C#y}SUksrwi2t~blJc$;_G$+!By6{pw)W*;Q zi#UK>;)@|BF01DkP#}ni(6&X^^m7XiEHjcUPI-G5glx32G@^?(q60nk9eVm(`#7gw zy+x8ZE6%pT>#wy#yGG$H$9YO^7puY760CY~6n-6Mp;6U~;HI17;K{i;p8G>-ZhUW$ zV{hM$X=Rs)qHUNA5Ufrx8A@A3PqO?mdmmNKg`OgJ-ckAxX4lNKevOmu*{cEr`3VuApqQ_ZRV%c0$q zk^{Xr$^@rFb9BKjhVs2;m0Zev+`+Z0gCTe17u8!6s<*_dJYP0iAv+YG?&okZCf5OS zk1@=Xubxpu6uk`)Hz|w>O(bOAj;T#}&-`cT!UsuN&0*=*8Z&=(uKiiSw(4lx$sP99 zAL{>#V-O^HPd{G!XUW}5DgDWcnz-u!zzy%SpTo{(ri2VUYMWPnUbko5nj-hq?&$FH z2K!^HL4HT$Bvt@g^{L8jo+7xKah`O)LY*Pk>she+vc~^s+`zQD_b`;t0c4`{q$rpS2xMT$z~}aJKazC66?tTp{Z>_D1Eut{i4E^@{Js!g{YcXOVzq~+{ds)) zebmoJ@+!rxt$v;V_^aP$N%xxZF!fjp(ZLXJlExr6ps9XBuEmSgl4+0ys#F(XZi})& zl9<@^iQmEuX$(*n+mG9j7hg!2beML>uyxRUC5bOK)&pargQmW`_}7NN z3a`E%S)gpZF>$%#Sov5CsUeE&(w4eT;;RztfipReE?e~b$~b{mnGkVP$lWqttnRQW ziTnyh=Z79j_WQ8udr`mDl2T~5*|is)SJ za6;Yj>lJH;a{}g*3RDny?r5wA?=W1072O5KMA!L;#xv0x4N>^$c$_Feo*Tv;j!%+C zgS(Nh{h?UpayOy(B8GY{J7DFAOCsw70S!OWfuU~O&essov`0D!)NM}uT>?v`=-lo6 zRlOH+)aEerAbl&hBvFvSULzx5JKrU}>$?nNah1VgmZaPbkeA{0nn)X}<_ z{!0??A55#duBnJifhc8^2@g&c8dGPC)4(b{U5vh>{YR5TN83$cgE3P3(D9+8lqSXW z+Z>K3LWHV^sx=^(8SB)1}#)}P# z=RELWLyZ^D(R)rLh$A@s%Hq@?HV^oIqZWe<)ES%pZ~U7Oak4w&h;IBBCw;Sit~Qpr zv9VGr#0QR;G2MGf(mP4g>q;?4Iuk*}ASQL}klce~HAmz*I`nkNe7i(5#u>GEU~$P! z>_#xG-wj|+VF%o!1kO@r;7b7$!3OYVRR4pVo$|?0Dz*N)VMFZdTJH#DYx`t<<2vt- z)MDAF)<9MiK^#uVNZ(%4Ym+i#pYH3(?=jA>M32;YI5t=$J#<*rxW1UwrxNJE2>pPG zMt#P*8`dS=8d#&zHBhc0UKj1!?fvtHjtI&rsqLxZe=_STPP3GUHoQ|jW;{Ahopu0y zu_MMvY|{%?+`7c4uq{2Z9Ft0iEIs-il14i%J%*|MA93G?%+~ZTAO-XJ6q#v($~NC! z8qN{J;+V5*hh?nHt}nlEV@`(38(76uJh=D zS_x;YRs8PJuO6%O*bTK>ow3IIHP(61pjP`cr5Hc6I**-D>y|U6IKRR=59>AgU`pUb zY+Iaq4&#}E@pAinV}WZ(T0F;>Y`KNW7ex8 zycJ%6;Z{vmS(yxqfPY}~+^4zx*tC5{1AuEdb?%5Oh#$pFNPv^|&W5amLq^P@1 zcvcWG=-dGI?4nPQpd^#h)P9;?)p5P1{w;wu>KV0+@Jpe>JEkKuvK5w+;~Lfl69**i zWyP*jX4BQz3zE?Ehwgou;}g{Du0Q$Dlqsd4 zaig8wnMA9|H#9o`#0}k{A8*+#DchnUQ~Nor_O+)s@(TgfFWmnhCQ)E$nCmG$IvyPk ztX1c(qht#bh)_`Z^e8PbuVVZ5Rt~N;9X%euV(49BwASRYm``sjiHv&{NzG?5rlVJh z-4pIQBOiAgf2d;>;3QS%ZG2x?_1<==az~jyL#i5AM*Z$fho;?J@vKqctma58DY}Tk z3dt7hcT80hGuRB#u#;4lEshargC~&ChOb+&Qcbo?$=MUO1#@1c#yPInXIWWehrqP` z1&gudf-ueGtUfS_-*hZuoQk8U8mq}q`y#&A?xgsZoMFH}B)R<7qaWP{ znvnbQ;tjudB_3M#`v~c)Im;AJ)!a3+jG|w+f!k4?#>QS z9dY66&sqhth>gwJQTkSVI!hq?=J7G-T2EQ?6IuI_2!5hyzrdc9A>J-E?7fJmO0xru zhwOM<>ZvG!Jvl>nyA&7bjR6K|fY!*SMAo}<1N8iM{!06E+LPWVdntVWbZ$r&y%#;~qfYZ7<@&$(B-fZ59xB2}Y2ZF|sp)MTG3s0))rK4s>OIQ3 zJ9qWQ)T|Zd@qu5j^}3tyL93BMP~8L0!in}L^vCCfj;0`Ds5J!cj7t4+<~D~hQ|HeskOor+ES zY$8tcthJ`!F4W^3)T*(j-}H>NlHV?_hb7dy>5MfgE-Uq_4qWgqg}rV~h+e%XnBw?c{$Y<&b)pykHTxr!^d>2bG&zHD6v2JHrl!W2XxkR*byp4 zjh%jQlZ}0LyKKG6)UU}%hzX+M(ch^&p!aw6fsH@UqDM$$v_BEYV#j;%&Gacv5+mZ9 zzh{9JI;Bk?{ni|jw)sX@3_)h~MtPndhuWw-6!c!0n`L55hE_-O86nI6QB_x>@~qA~ zlCswlYn8O7%{~=|IuR1oULQZOg7aJz%fC0XU`2D|ySeHyIxSq5W712nSBQ)$uIfsq>2Cd zbV=_IZdtpG9OgDff4YER*qHXicRnFAmc!0iPSoflImJrQwCFLBH4My5OKn7nG&i(jy|6<&Y)GXysQ2R07AB$b z0|8Ur-h`!UA5yKxGlMmM0SEeaw}f|6m33RwVjT$H-Uh@V(FcY!8*f^IFnYjHn?^mE z_F09o+RMHbevMoYHK_IVGo=JSGp@%*sP*kLdy}p{fLg)3w$8yPC!p3DE&m;&~oZC_{AbNt&)IfwPYsNKh(o6QIShpC9)95Y6ZMoChC*&0CT{lii?}kVn z>S$j{@9oIYkwg0!SFj6F@Z3wMt(aidZ>om4^Yj(45Thb2yC_Hw-D7DwyQSvjaRtr3 zy+P(+tyvJ%l7|B~rsI>CGr@|@J`{62BpH^Q@gL?W^j^Gd~- z(dzz|Lu9b@@Jf*3l#9w8167^`whkx%T}iOk5=H&!P^(|hnma=4B(W;?J~+PY&|iQn z)7y;U4w8?rj4UNAS$q|5_cNYxoI?Sfyiv$i9WQ=`;y{bcU5>SD3>dFajW0^f$``i0 z-9fri7x(#c8?d&_=**Kv(CaNMhK>58&TfoLW@{ zmYI(j+?`bul0}^>ihIx;FW1&CO0p(LGG~rVN3}SCuuPjg>^Mv@_0MA5 z%8OC{n4gdUedmQ2#Q@tLggYD=6KY5;LvI2tkb8|&fC&%6`18zuVD0Kc3HathDe+bm z{y`NsypwJlik8p}J``(QRw#qZRjfOb^^fh)*p`#>uK=}?BMg?n*mOfX_f4f3!9@ERs~EG)ylO8cB5it;*DzhK$6xn7=TnVXk1!wOsHx z22TBK(vE+UC}8%~z2GKV77ET_!|YW4(9L-L-`+pr}>8e)XaGU!v3FdhxD_ zS?7e}H^*&Mo+&y{<&2WXEbig~Ow(BqOGXsexE5g8pDfgE0>&`v{YhW9iWMM84*bEd z-Q4i~C#v5V-l^UE-8)e0JVtfI9$=RqFH@VIm6K;OGLpoaMQwksy~qH6ZA|@*`at8; zR_TWr)@RVKn)((PK&`vhgs9eMq+Iof3-+%G(X7vyw`JFasMLF=T-^s3>|K*j^lR}g zD5Z%099y#^MkNJNg?@JjG3U&>$yT~F$Y%15AEnJ$(GaWKCG&3BpV+gBdwidek{`yD zLxmciq`Ud6-yznyzSKCQDPNz4`1U#IJNfeG3aIBo@^WXeWbiynZ{`duHzzNsH%LD^ zBZZZVpGrUVXry`sTmd=tpA0*yAhgB({mc3U2l@!&+jXy*j45eY$n#4p#^C2cmmkT^ z-7yx!>*LpS^}c_X_{rZGUY}-_2*sPmZPenM5|=D;H;qGT4!lHiiO{*PPkH17A=(y4 zHDa*RM!>66*?NL1afttPDW4j0Om47$A!z=nx9_cy6RVzKu7M8>k~vhr zDPMm3ipRhg5pF)_8;q^;TnPU@U&O`y2;QXb0lZ@9TLnFaTZ19m>EvHO&wTykJmuRP zb}Uic5!0Sh^;4s}%5QrUWB>V@&${p3RjNF3?DJ^;I!W--#aHs8J?(K*iuJ&n>_bym zOMG9Ys50=b-XZTtnaFc5WxfpnYPYZKIQjMGzrUz5cqLzKt+`m{$37OOQ9C;dXXTbC z`aoc>lCj=N`ep}7Ww7xsIYv5`aDCgbQi!Hnv8Z@O`{^y;ooL(sC>DE~QT>*)i@S7X zpf|2@XLgi|l{o`MVyo0ME;vcIfH74Oh#fh^N`Uvp%%`>~A-jNR>~=*+wya2}-Hq}c z$j=6_EjUpqd_BC#IkW#mvF)u2&1Z_vF=u34<2!3bc5snA(&vZ)A=F3i5_%K~I1OhB zB6E0lRz0*JX+h~^^MY0;Ei->{8=KuKrRH7Dr z(uu#hs>^6nbBAN>rPscj#sSRHv^*^7EY%8hz%{;~J)vx6!Vvih7LD2K7mwMYNus;i z6Y>xu@ai4zOntjJ#f3aD}P@%J<2IPa_RiU2}eegGBeN6*w-T=>Ou%_Q-iz=EOB zo!U+LD&m%8>4O`;0t<2sbEM9gzN;NytP+FS>PtpV2e9qQRLrGf60E&kC9&r0N_~Z2 zMCy6!YB01xlrrvSS7b~T*;!f+`*u~?v`Zd262V8rR<$%UHL2;z|O9mQ|c9H>5 z?%2SZMbwo=R>5&zsg&u5B_r6Dm-y_Cv z+ajs_8>8#2`t-4ng%NMyYYp_q|DY<nJ@S8PmLjUn1iGpUWUSJwojc7Dr$5Z_Nr`C`}BLh3` z;P>ed09QP|o;3RdBjFQamf=!G-S*+Q*CdUV4)99QE5=8!3uL1^T=c<0-CNL)0sQ0> z3u48ZB(F10o9kcG_51v^#rHSX{CY_JDRuSa>5|2(4OPN>0g*KXmN`SbQ#i5GKIX&aO(kGUEgx`v+ElVBUQhdULK~^2_eIy>SZum@ zqi{ks@|B8cC3AJ;g%aHGC;MSU+rQ?kPJF$i=IhVd zOBFYc9Q|>{W8T;NvN!-(r^M^Z`bd2IvXpWw%J6}`X1H;xZMt)2LrzoAhYM=l1AA;Z zi*D<-J#&3_iuQ+GmCHHIsW<1PV9tdz$zN4)+LaSmALLPzu@I|tBh{Sx5VYh5 zqh)p!(P}+!@zV8B0v1f^H6C~+*|udjSzms>n9~$~nP&9BE7i8`=^~=t661CE zL|5v;q%IKH&2Z+)o}v>i_Koz-8hNGGu${Zwa)xt;%~O#8)Se^)WB~@42tU3O={D$K z=n*<#JR2AY&uU2OiB(yZ^B$A!ncK2cE`7+|b-CO0R+x5hx!(ADZl4BWn~Awnz0uo= zlSiE5zRh+x@L`tsVyJoqJd2rM-A5ZK+V(BS;I8oOQAz#QNI!$Bs~Vl{#Q)dYwE#7F zWnsdf2d{)TXpl!lakgR!8c<%6gyIWaQW|ch3LiOlIOjPa?OGp+adjjYK-uxw(UnMFpQru2G9 zZz4j@lh8%MKT@y)Of6({i8>+OwrjzK_H4UmB2Iblx}+uu zTH-K`O0-aO*I}$#Fgi{9%rSBVu&1m3hX`WHYFUJVqtBf59}ZsF*mo*c4WiR&@>Ew{ozgijo! z^q9QCZD9vOf0$AFP6Ci>prV!;3YcD0DH!+8uvCow5?R#*JNsE}x7`zeP0rM)a6cIr zfQXOAL&Cg}RG0{CV(t<;f?$9o^>GQ^osfzUHRf!t0+f9WV|ru4YNdhVY@;|{sebEAs{7;4E8GrvZJf!t z6_#Ffdhvl{usQ$ybrMYEE-{OZpxCH>rNnuBjiDN@95;NJYmIs)% z#Q_#bJ3%xMyltNZIcKaLAPQf$L~t87wH;q-rN)@g`p6}rl*{k9rcJ=|9(tDdt>vc(S13M-|3`|So?VKvginTgZG>mvv3ym zLmQ<37Pj)Ftn{+*weEj-qLr}_ZMZCjz+DG%iZARfSM5Ae?PETY#z@t2?@F8B^wR1& zBoD`Zem$*vI;EXJN^sQxlX>;FlVJvcNjVzdKD3LMYd`a_JRE#u>)=lv1###IN-suw z6Vgr-i^C}m7-=)4wS5}xkzta)Z|?uogu?HBO@zc^G3IYqrSaRGK*4o&n@(H?yT3xe z{l?Pui{CeP-6IbM;7v`8A@+YzO^&o-RF|?MMMDZ&`$nM;Tkv1Rz%k!#7H$W zt>KDOBCN@7a39`~^T>tRb!!tpUsK*R+`W5%8dYg9d@1fnH?riSgO%Xl)xy=il_{{R z<(C5(hOT{JHnPyhE-ZP_ZMFTyvY4mFOR1Y?A{}JAdMS!@!^5!8tBBPd&|q@f_pn@F zHVTBo4$?3ve~8H0Ls<{WoHmLgP#R{RKlBRxC)1zO>&$y>b7N_B9g;_l;0g;O`nHwn zCZJDHQB;Y+EZqGB^>SY`?r?BXtR%@`0%=FM&2ZWghT?6eJWA;VDru_ycy~!6EXrNt zO6w2n%L_S4%5^80%*#z|3!$doRMHIc*wrEOQ@77$ba zL?GT~U<>AQ&tghkPxw{rWc5fQ*fDb-r&oiSnTDH#_>;Ltwx8LU3WVO^HA5tANZN?C zSnSxdbOJYpN)s#z&zIL9m9*K?$f{!>nk1BYvu%jAkhQUZOMcm;0@MjZrBtt(PIUel zGdw4T10tePisAwfxWhCXw^D}DWTR-epssHiDNl$&m${ye1$45J5oidg zOAnyED7{GOO_=r^u^3BfKuVioT4&K+F4dEuDxXRQ-|h>d*hl$og<{Lr!XaGwZ+Wqc zjEjV;E>*I(r`?IVz^M07o&z<_B@C{q zT~d2e`Mgt%xy#C>_`$nZ6l|4vx5+nzExymc zcGS&O(X}yxMO^AKi(fKuGJ0}j?rf{)AyztU868a&ApKGy79TbsvzpwX+v$0TjSgE$ zN0S9eTxtc2Pa2T-oVf9bwAKqhv)f7 zU3dkZLlO-6rJiN+_YBB3Pj1ZJVynv{3u;;XfXO$72wfIQ@JANkW3uH9AzGIeBlwQR z51njzLkQ_+h=OnyA2ZqVCU7Oam7|jVFF|!JSxD&Kw3sp19IaQiuPi-n?p|5mde5^W zRin~JuguTh?G`L7&S~qqONxpV+R+C6Vv(|@@lo`czI5g8a(CURW?4a837Y*Xq%L2F Gvid(g8k+n7 literal 0 HcmV?d00001 diff --git a/src/main/resources/data/fabricracers/tags/block/race_blocks.json b/src/main/resources/data/fabricracers/tags/block/race_blocks.json new file mode 100644 index 0000000..35ed3c4 --- /dev/null +++ b/src/main/resources/data/fabricracers/tags/block/race_blocks.json @@ -0,0 +1,18 @@ +{ + "replace": false, + "values": [ + "fabricracers:asphalt", + "fabricracers:asphalt_cracked", + "fabricracers:road_block", + "fabricracers:finish_line", + "fabricracers:pit_lane", + "fabricracers:kerb", + "fabricracers:safety_barrier", + "fabricracers:start_gate", + "fabricracers:checkpoint_marker", + "fabricracers:station", + "fabricracers:boost_pad", + "fabricracers:fake_ice", + "fabricracers:fake_slime" + ] +} diff --git a/src/main/resources/data/fabricracers/tags/item/paint_cans.json b/src/main/resources/data/fabricracers/tags/item/paint_cans.json new file mode 100644 index 0000000..0faf901 --- /dev/null +++ b/src/main/resources/data/fabricracers/tags/item/paint_cans.json @@ -0,0 +1,13 @@ +{ + "replace": false, + "values": [ + "fabricracers:paint_white", + "fabricracers:paint_black", + "fabricracers:paint_red", + "fabricracers:paint_orange", + "fabricracers:paint_yellow", + "fabricracers:paint_green", + "fabricracers:paint_blue", + "fabricracers:paint_purple" + ] +} diff --git a/src/main/resources/data/fabricracers/tracks/_index.json b/src/main/resources/data/fabricracers/tracks/_index.json new file mode 100644 index 0000000..f3692be --- /dev/null +++ b/src/main/resources/data/fabricracers/tracks/_index.json @@ -0,0 +1,9 @@ +{ + "tracks": [ + "asphalt_park", + "chaos_junction", + "drift_arena", + "kart_circuit", + "speed_oval" + ] +} diff --git a/src/main/resources/data/fabricracers/tracks/asphalt_park.json b/src/main/resources/data/fabricracers/tracks/asphalt_park.json new file mode 100644 index 0000000..0f1749a --- /dev/null +++ b/src/main/resources/data/fabricracers/tracks/asphalt_park.json @@ -0,0 +1,163 @@ +{ + "name": "Asphalt Park", + "short_name": "ASP", + "structure": "fabricracers:tracks/asphalt_park", + "anchor": [ + 22, + 2, + 11 + ], + "start_line": [ + 56, + 2, + 9 + ], + "pit": { + "entry": [ + 27, + 17 + ], + "exit": [ + 63, + 17 + ], + "box": [ + 45, + 1, + 17 + ] + }, + "grid": [ + [ + 22, + 2, + 11 + ], + [ + 24, + 2, + 7 + ], + [ + 27, + 2, + 11 + ], + [ + 29, + 2, + 7 + ], + [ + 32, + 2, + 10 + ], + [ + 35, + 2, + 7 + ], + [ + 37, + 2, + 10 + ], + [ + 40, + 2, + 7 + ], + [ + 42, + 2, + 10 + ], + [ + 45, + 2, + 7 + ], + [ + 47, + 2, + 10 + ], + [ + 50, + 2, + 7 + ] + ], + "checkpoints": [ + [ + 55.8, + 2, + 8.6, + 271.5 + ], + [ + 76.3, + 2, + 16.4, + 319.5 + ], + [ + 81.6, + 2, + 37.8, + 0.4 + ], + [ + 77.8, + 2, + 59.9, + 34.2 + ], + [ + 57.9, + 2, + 69.7, + 88.3 + ], + [ + 33.7, + 2, + 67.6, + 108.1 + ], + [ + 24.6, + 2, + 50.9, + 178.1 + ], + [ + 27.0, + 2, + 31.0, + 162.6 + ], + [ + 12.0, + 2, + 29.4, + 131.3 + ], + [ + 12.0, + 2, + 13.7, + 224.8 + ], + [ + 26.3, + 2, + 8.8, + 268.7 + ] + ], + "width": 7, + "default_laps": 2, + "lap_length": 364 +} diff --git a/src/main/resources/data/fabricracers/tracks/chaos_junction.json b/src/main/resources/data/fabricracers/tracks/chaos_junction.json new file mode 100644 index 0000000..9b7d432 --- /dev/null +++ b/src/main/resources/data/fabricracers/tracks/chaos_junction.json @@ -0,0 +1,133 @@ +{ + "name": "Chaos Junction", + "short_name": "CHS", + "structure": "fabricracers:tracks/chaos_junction", + "anchor": [ + 18, + 2, + 38 + ], + "start_line": [ + 40, + 2, + 35 + ], + "pit": {}, + "grid": [ + [ + 18, + 2, + 38 + ], + [ + 20, + 2, + 34 + ], + [ + 22, + 2, + 38 + ], + [ + 24, + 2, + 35 + ], + [ + 26, + 2, + 38 + ], + [ + 29, + 2, + 35 + ], + [ + 31, + 2, + 38 + ], + [ + 32, + 2, + 34 + ], + [ + 35, + 2, + 38 + ], + [ + 36, + 2, + 34 + ] + ], + "checkpoints": [ + [ + 40.4, + 2, + 34.9, + 257.2 + ], + [ + 56.0, + 2, + 20.5, + 171.4 + ], + [ + 36.2, + 2, + 9.7, + 84.9 + ], + [ + 19.6, + 2, + 23.8, + 6.3 + ], + [ + 32.3, + 2, + 42.6, + 316.3 + ], + [ + 52.0, + 2, + 58.0, + 324.9 + ], + [ + 45.1, + 2, + 75.3, + 74.2 + ], + [ + 22.3, + 2, + 70.5, + 129.4 + ], + [ + 11.5, + 2, + 51.6, + 172.5 + ], + [ + 15.5, + 2, + 36.0, + 276.2 + ] + ], + "width": 7, + "default_laps": 2, + "lap_length": 369 +} diff --git a/src/main/resources/data/fabricracers/tracks/drift_arena.json b/src/main/resources/data/fabricracers/tracks/drift_arena.json new file mode 100644 index 0000000..6256be9 --- /dev/null +++ b/src/main/resources/data/fabricracers/tracks/drift_arena.json @@ -0,0 +1,111 @@ +{ + "name": "Drift Arena", + "short_name": "DRF", + "structure": "fabricracers:tracks/drift_arena", + "anchor": [ + 12, + 2, + 6 + ], + "start_line": [ + 18, + 2, + 10 + ], + "pit": {}, + "grid": [ + [ + 12, + 2, + 6 + ], + [ + 15, + 2, + 8 + ], + [ + 12, + 2, + 10 + ], + [ + 15, + 2, + 12 + ], + [ + 12, + 2, + 14 + ], + [ + 15, + 2, + 16 + ], + [ + 12, + 2, + 18 + ], + [ + 15, + 2, + 20 + ] + ], + "checkpoints": [ + [ + 52.0, + 2, + 22.0, + 90.0 + ], + [ + 45.6, + 2, + 33.3, + 45.0 + ], + [ + 30.0, + 2, + 38.0, + 0.0 + ], + [ + 14.4, + 2, + 33.3, + 315.0 + ], + [ + 8.0, + 2, + 22.0, + 270.0 + ], + [ + 14.4, + 2, + 10.7, + 225.0 + ], + [ + 30.0, + 2, + 6.0, + 180.0 + ], + [ + 45.6, + 2, + 10.7, + 135.0 + ] + ], + "width": 1, + "default_laps": 3, + "arena": true +} diff --git a/src/main/resources/data/fabricracers/tracks/kart_circuit.json b/src/main/resources/data/fabricracers/tracks/kart_circuit.json new file mode 100644 index 0000000..3d9a36d --- /dev/null +++ b/src/main/resources/data/fabricracers/tracks/kart_circuit.json @@ -0,0 +1,111 @@ +{ + "name": "Kart Circuit", + "short_name": "KRT", + "structure": "fabricracers:tracks/kart_circuit", + "anchor": [ + 13, + 2, + 9 + ], + "start_line": [ + 32, + 2, + 8 + ], + "pit": {}, + "grid": [ + [ + 13, + 2, + 9 + ], + [ + 14, + 2, + 6 + ], + [ + 17, + 2, + 9 + ], + [ + 19, + 2, + 6 + ], + [ + 21, + 2, + 9 + ], + [ + 24, + 2, + 6 + ], + [ + 26, + 2, + 9 + ], + [ + 28, + 2, + 6 + ] + ], + "checkpoints": [ + [ + 32.1, + 2, + 8.3, + 280.0 + ], + [ + 40.2, + 2, + 14.5, + 339.6 + ], + [ + 38.9, + 2, + 25.1, + 39.4 + ], + [ + 29.3, + 2, + 27.8, + 127.7 + ], + [ + 20.0, + 2, + 29.0, + 89.7 + ], + [ + 10.9, + 2, + 22.4, + 146.7 + ], + [ + 10.8, + 2, + 11.9, + 199.2 + ], + [ + 15.9, + 2, + 7.7, + 268.8 + ] + ], + "width": 5, + "default_laps": 3, + "lap_length": 174 +} diff --git a/src/main/resources/data/fabricracers/tracks/speed_oval.json b/src/main/resources/data/fabricracers/tracks/speed_oval.json new file mode 100644 index 0000000..60d43a8 --- /dev/null +++ b/src/main/resources/data/fabricracers/tracks/speed_oval.json @@ -0,0 +1,121 @@ +{ + "name": "Bonneville Oval", + "short_name": "OVL", + "structure": "fabricracers:tracks/speed_oval", + "anchor": [ + 23, + 2, + 13 + ], + "start_line": [ + 53, + 2, + 11 + ], + "pit": {}, + "grid": [ + [ + 23, + 2, + 13 + ], + [ + 26, + 2, + 9 + ], + [ + 28, + 2, + 13 + ], + [ + 31, + 2, + 9 + ], + [ + 34, + 2, + 13 + ], + [ + 36, + 2, + 9 + ], + [ + 39, + 2, + 13 + ], + [ + 42, + 2, + 9 + ], + [ + 45, + 2, + 13 + ], + [ + 47, + 2, + 9 + ] + ], + "checkpoints": [ + [ + 53.3, + 2, + 10.8, + 269.7 + ], + [ + 93.9, + 2, + 11.5, + 277.4 + ], + [ + 104.7, + 2, + 37.5, + 359.6 + ], + [ + 96.6, + 2, + 67.8, + 21.3 + ], + [ + 60.1, + 2, + 71.6, + 90.3 + ], + [ + 22.4, + 2, + 68.9, + 125.5 + ], + [ + 11.0, + 2, + 38.4, + 180.2 + ], + [ + 17.3, + 2, + 10.1, + 85.9 + ] + ], + "width": 11, + "default_laps": 2, + "lap_length": 372 +} diff --git a/src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json b/src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json new file mode 100644 index 0000000..f2542e4 --- /dev/null +++ b/src/main/resources/data/minecraft/tags/block/mineable/pickaxe.json @@ -0,0 +1,14 @@ +{ + "replace": false, + "values": [ + "fabricracers:asphalt", + "fabricracers:asphalt_cracked", + "fabricracers:road_block", + "fabricracers:finish_line", + "fabricracers:pit_lane", + "fabricracers:safety_barrier", + "fabricracers:start_gate", + "fabricracers:station", + "fabricracers:boost_pad" + ] +} diff --git a/src/main/resources/data/minecraft/tags/block/mineable/shovel.json b/src/main/resources/data/minecraft/tags/block/mineable/shovel.json new file mode 100644 index 0000000..1bd9a72 --- /dev/null +++ b/src/main/resources/data/minecraft/tags/block/mineable/shovel.json @@ -0,0 +1,6 @@ +{ + "replace": false, + "values": [ + "fabricracers:fake_slime" + ] +} diff --git a/src/main/resources/fabric.mod.json b/src/main/resources/fabric.mod.json new file mode 100644 index 0000000..9891d2a --- /dev/null +++ b/src/main/resources/fabric.mod.json @@ -0,0 +1,34 @@ +{ + "schemaVersion": 1, + "id": "fabricracers", + "version": "${version}", + "name": "FabricRacers", + "description": "Build full race circuits in seconds and race them solo, against AI drivers, or against your friends. Cars you can tune, drift, boost, wreck and paint - with a complete race engine (grids, countdown, laps, checkpoints, pit lane, standings, prizes) and AI drivers that race with skill levels, rubber-banding, overtaking and real mistakes.", + "authors": ["Qynl"], + "contact": { + "homepage": "https://github.com/Qynl/MyFirstMod", + "sources": "https://github.com/Qynl/MyFirstMod", + "issues": "https://github.com/Qynl/MyFirstMod/issues" + }, + "license": "MIT", + "icon": "icon.png", + "environment": "*", + "entrypoints": { + "main": ["dev.qynl.racers.FabricRacers"], + "client": ["dev.qynl.racers.client.FabricRacersClient"] + }, + "depends": { + "fabricloader": ">=0.16.0", + "minecraft": "~1.21.1", + "java": ">=21", + "fabric-api": "*" + }, + "suggests": {}, + "custom": { + "modmenu": { + "links": { + "modmenu.discord": "https://github.com/Qynl/MyFirstMod" + } + } + } +} diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..d253c9029055030ab4462cdac6d634d1e43db473 GIT binary patch literal 480 zcmeAS@N?(olHy`uVBq!ia0vp^4j|0I1|(Ny7T#lEU|jC$;uunK>+KBNyu$`Ot(ARB z9|Tf3ZY|ZYdhT<#NLc@6q>-4d>vE>pE`{Q0Y@8Dg*l%*_D$$M1uju9tdS5$1<8aB3 zzyGpdC2q{|{B2h%$k4;GVBP)oObu5UBl3UVE(Y4fa6o_sMDaLca858gWHL2ul&a5- z^JQh&$rNFqAHviS!tiEZm?sBA0ZRe*62=GVzn@JtZMaZnuF9OCCCdDuNf;zJns^!`+gIj#4BA+dya-+8Kdh{ zQw!qrJuB21@-`k>Al9^GyGjjz!;%~61^fT+@aA^B6)m)n)xm4sO2uZq#g}F(zm$DE z{}|u-Mgi%0+y|?9vkpZwd{~>ysPIE}0jGTSr0O?Y7%cvI3fn!K}(JJ%Rl4vxrLJsul~n=CiQv1$%kkEu|J9NS8d<_g2(Mt*}nJj5`RHK N=;`X`vd$@?2>`?9!;Js{ literal 0 HcmV?d00001 diff --git a/tools/__pycache__/track_lib.cpython-311.pyc b/tools/__pycache__/track_lib.cpython-311.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8c5a49ed7101814f82a906a22f585f5279b0cf3d GIT binary patch literal 52420 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 Z@t}2&FAdcmiqm`-!l0N7dW&QNdjfKLuMGeI literal 0 HcmV?d00001 diff --git a/tools/gen_textures.py b/tools/gen_textures.py new file mode 100644 index 0000000..132aa4e --- /dev/null +++ b/tools/gen_textures.py @@ -0,0 +1,1052 @@ +#!/usr/bin/env python3 +""" +FabricRacers - procedural texture generator. + +Generates every PNG the mod needs (no binary blobs checked in by hand), so the +art can be regenerated or tweaked at any time: + + python3 tools/gen_textures.py + +It only uses the Python standard library (zlib + struct) so it runs anywhere. + +CAR TEXTURE LAYOUT (64x64) - mirrored in +src/client/java/dev/qynl/racers/client/CarTexture.java : + + BODY_TOP ( 0, 0, 32, 16) hood / roof / trunk top surfaces + BODY_SIDE ( 0, 16, 32, 16) side panels (mirrored on both sides) + BODY_FRONT ( 0, 32, 16, 12) front fascia (lights, grille) + BODY_BACK (16, 32, 16, 12) rear fascia (tail lights) + GLASS (32, 32, 16, 16) windows (semi transparent) + DETAIL (48, 32, 16, 16) racing numbers / decals / accents + WHEEL_SIDE (32, 0, 16, 16) tyre sidewall + rim (left/right faces) + WHEEL_TREAD (32, 16, 16, 16) tread band (wraps around the tyre) + WHEEL_HUB (48, 0, 16, 16) inner hub + brake disc + EXTRA (48, 16, 16, 16) spoilers, mirrors, exhausts + ICON ( 0, 48, 32, 16) free area: badge / decal art + TIRE_ICON (32, 48, 16, 16) tyre item icon +""" + +import json +import os +import random +import struct +import zlib + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +ASSETS = os.path.join(ROOT, "src", "main", "resources", "assets", "fabricracers") +TEXTURES = os.path.join(ASSETS, "textures") + + +# --------------------------------------------------------------------------- +# tiny PNG canvas +# --------------------------------------------------------------------------- +class Canvas: + def __init__(self, w, h, bg=(0, 0, 0, 0)): + self.w = w + self.h = h + self.px = [list(bg) for _ in range(w * h)] + + def set(self, x, y, color): + if 0 <= x < self.w and 0 <= y < self.h: + if len(color) == 3: + color = (color[0], color[1], color[2], 255) + self.px[y * self.w + x] = list(color) + + def get(self, x, y): + if 0 <= x < self.w and 0 <= y < self.h: + return tuple(self.px[y * self.w + x]) + return (0, 0, 0, 0) + + def rect(self, x, y, w, h, color): + for yy in range(y, y + h): + for xx in range(x, x + w): + self.set(xx, yy, color) + + def border(self, x, y, w, h, color): + for xx in range(x, x + w): + self.set(xx, y, color) + self.set(xx, y + h - 1, color) + for yy in range(y, y + h): + self.set(x, yy, color) + self.set(x + w - 1, yy, color) + + def hline(self, x0, x1, y, color): + for x in range(min(x0, x1), max(x0, x1) + 1): + self.set(x, y, color) + + def vline(self, x, y0, y1, color): + for y in range(min(y0, y1), max(y0, y1) + 1): + self.set(x, y, color) + + def line(self, x0, y0, x1, y1, color): + dx, dy = abs(x1 - x0), abs(y1 - y0) + sx = 1 if x0 < x1 else -1 + sy = 1 if y0 < y1 else -1 + err = dx - dy + while True: + self.set(x0, y0, color) + if x0 == x1 and y0 == y1: + break + e2 = 2 * err + if e2 > -dy: + err -= dy + x0 += sx + if e2 < dx: + err += dx + y0 += sy + + def disc(self, cx, cy, r, color, fill=True): + for y in range(int(cy - r), int(cy + r) + 1): + for x in range(int(cx - r), int(cx + r) + 1): + d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 + if d <= r - 0.25: + self.set(x, y, color) + elif fill and d <= r + 0.25: + self.set(x, y, color) + + def ring(self, cx, cy, r, color, thickness=1.1): + for y in range(int(cy - r - 2), int(cy + r) + 3): + for x in range(int(cx - r - 2), int(cx + r) + 3): + d = ((x - cx) ** 2 + (y - cy) ** 2) ** 0.5 + if r - thickness <= d <= r: + self.set(x, y, color) + + def blit(self, other, dx, dy, alpha=True): + for y in range(other.h): + for x in range(other.w): + c = other.get(x, y) + if c[3] == 0 and alpha: + continue + self.set(dx + x, dy + y, c) + + def scale(self, factor): + out = Canvas(self.w * factor, self.h * factor) + for y in range(out.h): + for x in range(out.w): + out.set(x, y, self.get(x // factor, y // factor)) + return out + + def save(self, path, mcmeta=None): + os.makedirs(os.path.dirname(path), exist_ok=True) + raw = b"" + for y in range(self.h): + raw += b"\x00" + for x in range(self.w): + r, g, b, a = self.px[y * self.w + x] + raw += bytes((r & 255, g & 255, b & 255, a & 255)) + png = b"\x89PNG\r\n\x1a\n" + png += _chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)) + png += _chunk(b"IDAT", zlib.compress(raw, 9)) + png += _chunk(b"IEND", b"") + with open(path, "wb") as fh: + fh.write(png) + if mcmeta is not None: + with open(path + ".mcmeta", "w") as fh: + json.dump(mcmeta, fh, indent=2) + + +def _chunk(tag, data): + return ( + struct.pack(">I", len(data)) + + tag + + data + + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF) + ) + + +# --------------------------------------------------------------------------- +# color helpers +# --------------------------------------------------------------------------- +def shade(color, amount): + """amount > 0 lightens, < 0 darkens (keeps alpha).""" + r, g, b = color[0], color[1], color[2] + a = color[3] if len(color) > 3 else 255 + if amount >= 0: + r = int(r + (255 - r) * amount) + g = int(g + (255 - g) * amount) + b = int(b + (255 - b) * amount) + else: + r = int(r * (1 + amount)) + g = int(g * (1 + amount)) + b = int(b * (1 + amount)) + return (max(0, min(255, r)), max(0, min(255, g)), max(0, min(255, b)), a) + + +def mix(a, b, t): + return tuple(int(a[i] + (b[i] - a[i]) * t) for i in range(4)) + + +def noise_rect(canvas, x, y, w, h, base, spread=10, rng=None, speckle=0.0, speckle_color=None): + rng = rng or random.Random(1234) + speckle_color = speckle_color or shade(base, -0.35) + for yy in range(y, y + h): + for xx in range(x, x + w): + v = rng.randint(-spread, spread) + c = (base[0] + v, base[1] + v, base[2] + v, base[3] if len(base) > 3 else 255) + if speckle and rng.random() < speckle: + c = speckle_color + canvas.set(xx, yy, c) + + +# --------------------------------------------------------------------------- +# palettes +# --------------------------------------------------------------------------- +PAINTS = { + "red": (196, 44, 40), + "blue": (44, 92, 196), + "yellow": (232, 190, 44), + "green": (56, 168, 78), + "orange": (232, 122, 40), + "purple": (140, 62, 190), + "black": (40, 40, 44), + "white": (232, 232, 232), +} + +CAR_COLORS = { + "kart": ((228, 88, 42), (255, 226, 120)), + "rally": ((62, 142, 214), (245, 245, 245)), + "sport": ((214, 46, 46), (40, 40, 44)), + "hyper": ((252, 196, 22), (36, 36, 40)), + "f1": ((24, 148, 92), (240, 240, 240)), + "truck": ((176, 96, 48), (120, 120, 126)), + "drift": ((236, 108, 196), (52, 46, 60)), + "hotrod": ((144, 44, 60), (206, 206, 198)), +} + + +# --------------------------------------------------------------------------- +# car skins (64x64) - layout mirrors CarTexture.java +# --------------------------------------------------------------------------- +def car_skin(name, primary, secondary, style): + c = Canvas(64, 64, (0, 0, 0, 0)) + rng = random.Random(hash(name) & 0xFFFF) + dark = shade(primary, -0.45) + light = shade(primary, 0.35) + + # --- BODY_TOP (0,0,32,16): hood stripes, roof, trunk ------------------- + noise_rect(c, 0, 0, 32, 16, primary, spread=6, rng=rng) + c.rect(0, 2, 32, 1, light) + c.rect(0, 13, 32, 1, shade(primary, -0.2)) + for i in range(0, 32, 6): + c.rect(i, 0, 2, 16, secondary) + if style == "f1": + c.rect(14, 4, 4, 8, (26, 26, 30)) # airbox / cockpit opening + if style in ("sport", "hyper"): + c.rect(2, 5, 28, 1, shade(secondary, -0.2)) + if style == "truck": + c.rect(0, 0, 32, 3, shade(primary, -0.15)) # ribbed roof + if style == "rally": + c.rect(12, 6, 8, 4, (250, 250, 250)) + c.rect(13, 7, 2, 2, (28, 28, 32)) + c.rect(17, 7, 2, 2, (28, 28, 32)) + if style == "drift": + c.rect(0, 0, 32, 2, secondary) + if style == "hotrod": + c.rect(10, 4, 12, 3, shade(primary, -0.3)) + + # --- BODY_SIDE (0,16,32,16) ------------------------------------------- + noise_rect(c, 0, 16, 32, 16, primary, spread=5, rng=rng) + c.rect(0, 16, 32, 2, light) + c.rect(0, 29, 32, 3, dark) + # window band + c.rect(6, 19, 20, 5, (26, 30, 40)) + c.rect(6, 19, 20, 1, (70, 80, 100)) + if style in ("sport", "hyper", "f1", "drift"): + c.rect(0, 24, 32, 1, secondary) + if style == "hyper": + c.rect(3, 25, 26, 2, shade(primary, -0.35)) # side scoop + if style == "truck": + c.rect(0, 22, 32, 1, dark) + c.rect(26, 25, 3, 3, (250, 236, 140)) # side marker / number roundel + c.rect(27, 26, 1, 1, (40, 40, 40)) + if style == "rally": + c.rect(2, 25, 4, 3, (250, 250, 250)) + if style == "kart": + c.rect(0, 27, 32, 2, (60, 60, 66)) + + # --- BODY_FRONT (0,32,16,12) ----------------------------------------- + noise_rect(c, 0, 32, 16, 12, shade(primary, -0.1), spread=5, rng=rng) + c.rect(1, 34, 14, 3, (36, 40, 48)) # grille + for i in range(2, 15, 3): + c.rect(i, 34, 1, 3, (90, 96, 108)) + head = (252, 244, 196) + c.rect(1, 38, 4, 3, head) # headlights + c.rect(11, 38, 4, 3, head) + c.rect(1, 38, 4, 1, (255, 255, 240)) + c.rect(4, 42, 8, 2, (30, 30, 34)) # splitter + if style == "f1": + c.rect(6, 32, 4, 4, (30, 30, 34)) + if style == "truck": + c.rect(0, 32, 16, 2, shade(primary, -0.35)) + + # --- BODY_BACK (16,32,16,12) ----------------------------------------- + noise_rect(c, 16, 32, 16, 12, shade(primary, -0.12), spread=5, rng=rng) + tail = (226, 54, 54) + c.rect(17, 34, 5, 2, tail) + c.rect(26, 34, 5, 2, tail) + c.rect(17, 34, 5, 1, (255, 120, 120)) + c.rect(19, 38, 10, 3, (40, 40, 46)) # diffuser + c.rect(22, 41, 4, 1, (70, 70, 78)) + if style in ("sport", "hyper", "f1", "drift", "hotrod"): + c.rect(16, 44, 16, 1, secondary) + + # --- GLASS (32,32,16,16) --------------------------------------------- + for y in range(16): + for x in range(16): + t = y / 15.0 + base = mix((46, 58, 78, 210), (18, 22, 32, 235), t) + c.set(32 + x, 32 + y, base) + for i in range(16): + c.set(32 + i, 32 + i % 3, (120, 150, 190, 200)) + c.rect(32, 32, 16, 1, (150, 175, 210, 220)) + c.rect(40, 32, 1, 16, (90, 110, 140, 180)) + + # --- DETAIL (48,32,16,16): plates, lights, decals -------------------- + noise_rect(c, 48, 32, 16, 16, (60, 62, 70), spread=6, rng=rng) + c.rect(49, 33, 6, 5, (240, 240, 236)) # blank number plate + c.rect(50, 34, 4, 3, (60, 60, 66)) + c.rect(56, 33, 7, 3, (240, 236, 228)) # white sticker area + c.rect(56, 37, 7, 2, secondary) + c.rect(49, 41, 14, 1, light) + c.rect(49, 42, 14, 1, dark) + for y in range(44, 48): + for x in range(48, 64): + c.set(x, y, (34, 36, 42) if (x + y) % 2 == 0 else (46, 48, 56)) + if style == "f1": + c.rect(48, 32, 16, 1, secondary) + + # --- WHEEL_SIDE (32,0,16,16) ----------------------------------------- + rubber = (28, 28, 32) + c.rect(32, 0, 16, 16, rubber) + c.disc(40, 8, 7.4, (36, 36, 40)) + c.disc(40, 8, 6.4, (22, 22, 26)) + rim_color = (206, 206, 212) if style != "kart" else (170, 120, 60) + c.disc(40, 8, 4.2, rim_color) + c.disc(40, 8, 2.2, shade(rim_color, -0.5)) + c.rect(40, 3, 1, 3, shade(rim_color, -0.3)) + c.rect(40, 10, 1, 3, shade(rim_color, -0.3)) + c.rect(35, 8, 3, 1, shade(rim_color, -0.3)) + c.rect(43, 8, 3, 1, shade(rim_color, -0.3)) + c.rect(39, 7, 2, 2, (40, 40, 46)) + for i in range(16): + c.set(32 + i, 0, (18, 18, 22)) + c.set(32 + i, 15, (18, 18, 22)) + c.set(32, i, (18, 18, 22)) + c.set(47, i, (18, 18, 22)) + + # --- WHEEL_TREAD (32,16,16,16) --------------------------------------- + for y in range(16): + for x in range(16): + v = 18 if (x // 2) % 2 == 0 else 40 + c.set(32 + x, 16 + y, (v, v, v + 2)) + for i in range(16): + c.set(32 + i, 16, (12, 12, 14)) + c.set(32 + i, 31, (12, 12, 14)) + + # --- WHEEL_HUB (48,0,16,16) ------------------------------------------ + c.rect(48, 0, 16, 16, (30, 30, 34)) + c.disc(56, 8, 6.0, (150, 150, 156)) + c.disc(56, 8, 4.4, (188, 188, 196)) + c.disc(56, 8, 1.6, (60, 60, 66)) + for a in range(0, 360, 90): + import math + + x = 56 + int(4.6 * math.cos(math.radians(a))) + y = 8 + int(4.6 * math.sin(math.radians(a))) + c.rect(x - 1, y - 1, 2, 2, (70, 70, 76)) + + # --- EXTRA (48,16,16,16): spoilers, mirrors, exhausts ---------------- + c.rect(48, 16, 16, 16, dark) + c.rect(48, 16, 16, 1, light) + c.rect(49, 19, 14, 2, (52, 54, 60)) # spoiler blade + c.rect(50, 21, 2, 4, (44, 46, 52)) + c.rect(60, 21, 2, 4, (44, 46, 52)) + c.rect(50, 27, 3, 2, (200, 200, 208)) # exhaust tip + c.rect(57, 27, 3, 2, (200, 200, 208)) + c.rect(53, 27, 4, 1, (90, 90, 96)) + c.rect(51, 30, 2, 3, (60, 60, 68)) # mirror stalks + c.rect(60, 30, 2, 3, (60, 60, 68)) + + # --- ICON (0,48,32,16): big badge / livery accent -------------------- + c.rect(0, 48, 32, 16, shade(primary, -0.25)) + for i in range(0, 32, 4): + c.rect(i, 48, 2, 16, secondary if (i // 4) % 2 == 0 else shade(secondary, -0.3)) + c.rect(8, 51, 16, 10, (30, 30, 36)) + c.rect(9, 52, 14, 8, (240, 240, 236)) + c.rect(11, 54, 10, 4, shade(primary, -0.2)) + c.rect(12, 55, 3, 2, (30, 30, 36)) + c.rect(17, 55, 3, 2, (30, 30, 36)) + + # --- TIRE_ICON (32,48,16,16) ----------------------------------------- + c.rect(32, 48, 16, 16, rubber) + c.disc(40, 56, 7.2, (34, 34, 38)) + c.ring(40, 56, 6.6, (90, 90, 96), 1.4) + c.disc(40, 56, 3.6, (170, 170, 178)) + c.disc(40, 56, 1.4, (70, 70, 76)) + return c + + +# --------------------------------------------------------------------------- +# item icons (16x16) +# --------------------------------------------------------------------------- +def item_car(color_a, color_b, style): + """Side view car icon used by the car item + ghost/spawn items.""" + c = Canvas(16, 16) + body = color_a + dark = shade(body, -0.4) + light = shade(body, 0.3) + if style == "f1": + c.rect(2, 9, 12, 3, body) # tub + c.rect(1, 8, 4, 1, body) # front wing + c.rect(11, 7, 4, 1, body) # rear wing + c.rect(5, 6, 4, 3, body) # cockpit + c.rect(6, 7, 3, 2, (30, 30, 36)) + c.rect(12, 9, 2, 2, (40, 40, 46)) + c.set(13, 8, (226, 60, 60)) + elif style == "kart": + c.rect(2, 10, 11, 2, body) + c.rect(4, 8, 5, 2, body) + c.rect(5, 8, 3, 1, (30, 30, 36)) + c.rect(1, 12, 3, 1, (40, 40, 46)) + c.rect(11, 12, 3, 1, (40, 40, 46)) + elif style == "truck": + c.rect(1, 7, 14, 6, body) + c.rect(3, 5, 5, 3, body) + c.rect(4, 6, 3, 2, (40, 46, 58)) + c.rect(10, 7, 5, 4, shade(body, -0.15)) + c.rect(1, 12, 14, 1, dark) + else: + c.rect(1, 8, 14, 5, body) + c.rect(3, 5, 8, 3, body) + c.rect(4, 6, 6, 2, (40, 46, 58)) + c.rect(1, 12, 14, 1, dark) + if style in ("sport", "hyper", "drift", "hotrod"): + c.rect(9, 4, 5, 1, dark) # spoiler + c.rect(9, 5, 1, 1, dark) + if style == "hyper": + c.rect(1, 9, 3, 2, shade(body, -0.25)) + c.rect(1, 8, 14, 1, light) + c.rect(2, 9, 3, 2, (250, 240, 200)) # lights + c.rect(12, 9, 2, 2, (230, 70, 70)) + c.rect(2, 13, 3, 2, (26, 26, 30)) # wheels + c.rect(11, 13, 3, 2, (26, 26, 30)) + c.rect(2, 13, 3, 1, (60, 60, 66)) + c.rect(11, 13, 3, 1, (60, 60, 66)) + c.rect(6, 11, 4, 1, color_b) + return c + + +def item_carrot_key(): + c = Canvas(16, 16) + gold = (238, 190, 68) + dark_gold = (168, 126, 32) + c.rect(2, 9, 8, 2, gold) # key shaft + c.rect(3, 8, 6, 1, shade(gold, 0.25)) + c.rect(3, 11, 6, 1, dark_gold) + c.rect(3, 12, 1, 1, gold) # teeth + c.rect(5, 12, 1, 1, gold) + c.ring(11, 5, 3.2, gold, 1.6) # bow of the key + c.disc(12, 10, 3.4, (232, 122, 40)) # carrot head + c.disc(12, 10, 2.4, (244, 152, 60)) + c.rect(9, 7, 1, 2, (74, 160, 62)) # leaves + c.rect(10, 6, 1, 2, (86, 176, 70)) + c.rect(11, 6, 1, 2, (74, 160, 62)) + c.set(13, 11, (216, 104, 32)) + return c + + +def item_wrench(): + c = Canvas(16, 16) + steel = (176, 178, 188) + dark = (110, 112, 122) + c.line(4, 12, 10, 5, steel) + c.line(5, 12, 11, 5, dark) + c.line(4, 13, 10, 6, dark) + c.rect(9, 2, 4, 4, steel) + c.rect(10, 3, 2, 2, (0, 0, 0, 0)) + c.rect(2, 11, 4, 4, steel) + c.rect(3, 12, 2, 2, (0, 0, 0, 0)) + c.rect(4, 13, 2, 2, steel) + return c + + +def item_track_wand(): + c = Canvas(16, 16) + c.line(3, 13, 11, 3, (120, 84, 52)) + c.line(4, 13, 12, 3, (150, 108, 66)) + c.disc(12, 3, 3.0, (86, 220, 240)) + c.disc(12, 3, 1.8, (200, 250, 255)) + c.set(9, 6, (150, 245, 255)) + c.set(14, 0, (150, 245, 255)) + return c + + +def item_stopwatch(): + c = Canvas(16, 16) + c.disc(8, 9, 6.2, (198, 160, 64)) + c.disc(8, 9, 4.8, (236, 236, 240)) + c.vline(8, 5, 9, (40, 40, 46)) + c.line(8, 9, 11, 7, (206, 62, 62)) + c.rect(7, 1, 3, 2, (198, 160, 64)) + c.rect(11, 3, 2, 2, (150, 150, 158)) + return c + + +def item_paint_can(color): + c = Canvas(16, 16) + metal = (156, 158, 166) + c.rect(3, 4, 10, 10, metal) + c.rect(3, 4, 10, 1, shade(metal, 0.3)) + c.rect(3, 13, 10, 1, shade(metal, -0.35)) + c.rect(4, 6, 8, 6, color) # paint band + c.rect(4, 6, 8, 1, shade(color, 0.3)) + c.rect(2, 3, 12, 2, (120, 122, 130)) # lid + c.rect(6, 1, 4, 2, (90, 92, 100)) # handle + for x in range(4, 12, 2): + c.set(x, 12, shade(color, -0.3)) + return c + + +def item_trophy(metal, label_colors): + c = Canvas(16, 16) + c.rect(5, 12, 6, 2, shade(metal, -0.25)) + c.rect(6, 9, 4, 3, metal) + c.rect(3, 2, 10, 7, metal) + c.rect(4, 3, 8, 5, shade(metal, 0.2)) + c.rect(4, 3, 1, 5, shade(metal, -0.2)) + c.rect(12, 3, 1, 5, shade(metal, -0.2)) + c.rect(3, 4, 2, 3, (0, 0, 0, 0)) + c.rect(11, 4, 2, 3, (0, 0, 0, 0)) + c.rect(6, 5, 4, 2, label_colors) + return c + + +def item_generic_part(name, c): + if name == "engine_turbo": + c.disc(8, 9, 5.0, (188, 190, 198)) + c.disc(8, 9, 3.2, (120, 122, 130)) + c.rect(2, 3, 4, 3, (150, 152, 160)) + c.rect(11, 2, 3, 4, (110, 112, 120)) + c.disc(8, 9, 1.4, (60, 62, 70)) + c.ring(8, 9, 4.4, (240, 120, 60), 1.0) + elif name == "engine_v8": + c.rect(1, 5, 14, 7, (86, 88, 96)) + c.rect(1, 5, 14, 1, (140, 142, 150)) + for x in range(2, 14, 3): + c.rect(x, 2, 2, 3, (150, 152, 160)) + c.rect(x, 12, 2, 2, (60, 62, 70)) + c.rect(6, 7, 4, 3, (200, 160, 60)) + elif name == "gearbox_dct": + c.rect(2, 6, 12, 6, (96, 98, 106)) + c.rect(2, 6, 12, 1, (150, 152, 160)) + c.disc(11, 9, 3.0, (150, 152, 160)) + c.disc(11, 9, 1.2, (70, 72, 80)) + c.rect(3, 3, 3, 3, (200, 160, 60)) + c.rect(7, 3, 3, 3, (200, 160, 60)) + elif name == "gearbox_sequential": + c.rect(2, 7, 12, 5, (70, 72, 80)) + c.rect(2, 7, 12, 1, (130, 132, 140)) + c.rect(7, 3, 2, 5, (176, 178, 188)) + c.disc(7, 3, 2.2, (210, 60, 60)) + for x in range(3, 12, 2): + c.rect(x, 9, 1, 2, (150, 152, 160)) + elif name == "brakes_carbon": + c.disc(8, 8, 6.0, (60, 62, 70)) + c.disc(8, 8, 4.6, (34, 34, 40)) + c.ring(8, 8, 5.4, (120, 122, 130), 1.0) + c.rect(2, 10, 4, 4, (200, 120, 40)) + c.rect(3, 11, 2, 2, (240, 160, 60)) + elif name.startswith("tires_"): + compound = {"tires_soft": (206, 62, 62), "tires_medium": (226, 190, 70), "tires_hard": (232, 232, 232), "tires_street": (120, 160, 220)}[name] + c.rect(1, 3, 14, 10, (32, 32, 36)) + c.rect(1, 5, 14, 2, (56, 56, 60)) + c.rect(1, 10, 14, 2, (56, 56, 60)) + c.rect(4, 1, 8, 14, (32, 32, 36)) + c.rect(5, 2, 6, 12, (44, 44, 48)) + c.rect(5, 5, 6, 6, compound) + c.rect(6, 6, 4, 4, shade(compound, -0.25)) + elif name == "aero_wing": + c.rect(1, 4, 14, 3, (56, 58, 66)) + c.rect(1, 4, 14, 1, (110, 112, 120)) + c.rect(3, 7, 2, 6, (70, 72, 80)) + c.rect(11, 7, 2, 6, (70, 72, 80)) + c.rect(2, 13, 12, 2, (40, 42, 48)) + c.rect(2, 9, 12, 2, (90, 92, 100)) + elif name == "ground_effect": + c.rect(1, 9, 14, 4, (60, 62, 70)) + c.rect(1, 9, 14, 1, (120, 122, 130)) + for x in range(2, 14, 3): + c.rect(x, 10, 1, 3, (36, 36, 42)) + c.rect(6, 4, 4, 5, (86, 88, 96)) + c.rect(6, 4, 4, 1, (140, 142, 150)) + elif name == "roll_cage": + c.line(3, 14, 8, 3, (170, 172, 180)) + c.line(12, 14, 8, 3, (170, 172, 180)) + c.line(8, 3, 8, 14, (140, 142, 150)) + c.rect(3, 13, 10, 2, (120, 122, 130)) + c.rect(6, 2, 5, 2, (200, 60, 60)) + elif name == "chassis_plate": + c.rect(2, 5, 12, 7, (140, 142, 150)) + c.rect(2, 5, 12, 1, (190, 192, 200)) + for x in range(3, 13, 2): + c.set(x, 7, (90, 92, 100)) + c.set(x, 9, (90, 92, 100)) + c.rect(6, 3, 4, 2, (100, 102, 110)) + c.rect(6, 12, 4, 2, (100, 102, 110)) + elif name == "boost_nitro": + c.rect(4, 3, 8, 12, (72, 74, 82)) + c.rect(5, 4, 6, 10, (46, 48, 56)) + c.rect(5, 8, 6, 4, (90, 200, 240)) + c.rect(6, 9, 4, 2, (180, 240, 255)) + c.rect(6, 1, 4, 2, (170, 172, 180)) + c.rect(7, 0, 2, 1, (200, 202, 210)) + elif name == "carbon_weave": + for y in range(16): + for x in range(16): + d = ((x // 2) + (y // 2)) % 2 + v = 34 if d == 0 else 52 + c.set(x, y, (v, v, v + 2)) + c.rect(0, 0, 16, 1, (90, 90, 96)) + c.rect(0, 15, 16, 1, (90, 90, 96)) + elif name == "race_guide": + c.rect(2, 2, 12, 13, (150, 60, 52)) + c.rect(2, 2, 12, 1, (190, 90, 80)) + c.rect(3, 3, 10, 11, (236, 228, 210)) + c.rect(5, 5, 6, 1, (90, 90, 96)) + c.rect(5, 7, 6, 1, (140, 140, 146)) + c.rect(5, 9, 6, 1, (140, 140, 146)) + c.rect(5, 11, 4, 1, (140, 140, 146)) + c.rect(1, 2, 2, 13, (120, 46, 40)) + return c + + +# --------------------------------------------------------------------------- +# blocks +# --------------------------------------------------------------------------- +def tex_asphalt(): + c = Canvas(16, 16) + rng = random.Random(7) + noise_rect(c, 0, 0, 16, 16, (52, 52, 56), spread=9, rng=rng, speckle=0.10, + speckle_color=(74, 74, 80)) + for _ in range(26): + x, y = rng.randint(0, 15), rng.randint(0, 15) + c.rect(x, y, rng.choice((1, 1, 2)), 1, (86, 86, 92)) + for _ in range(10): + x, y = rng.randint(0, 15), rng.randint(0, 15) + c.set(x, y, (34, 34, 38)) + return c + + +def tex_asphalt_cracked(): + c = tex_asphalt() + rng = random.Random(21) + for _ in range(3): + x, y = rng.randint(0, 12), rng.randint(0, 12) + c.line(x, y, x + rng.randint(2, 5), y + rng.randint(-3, 4), (30, 30, 34)) + return c + + +def tex_road_block(): + c = tex_asphalt() + for y in range(16): + if y % 4 < 2: + c.rect(7, y, 2, 1, (216, 210, 190)) + c.rect(7, y, 1, 1, (240, 236, 220)) + return c + + +def tex_safety_barrier(): + c = Canvas(16, 16) + for x in range(16): + color = (222, 66, 58) if (x // 4) % 2 == 0 else (238, 238, 234) + for y in range(16): + v = 0 if y % 5 else -18 + c.set(x, y, shade(color, v / 100.0)) + c.rect(0, 0, 16, 1, (250, 250, 246)) + c.rect(0, 15, 16, 1, (150, 40, 36)) + return c + + +def tex_start_gate(): + """Animated: 4 frames of a checkered gantry with a pulsing light bar.""" + frames = 4 + c = Canvas(16, 16 * frames) + for f in range(frames): + off = f * 16 + for y in range(16): + for x in range(16): + on = ((x // 2) + (y // 2)) % 2 == 0 + c.set(x, off + y, (34, 34, 38) if on else (226, 224, 216)) + pulse = (f + 0.5) / frames + bar = (int(120 + 135 * pulse), int(200 - 40 * pulse), int(60 + 40 * pulse)) + c.rect(0, off + 6, 16, 4, bar) + c.rect(0, off + 6, 16, 1, shade(bar, 0.45)) + c.rect(0, off + 9, 16, 1, shade(bar, -0.4)) + for x in range(0, 16, 4): + c.set(x + (f % 2), off + 7, (255, 255, 230)) + c.set(x + (f % 2), off + 8, (255, 255, 230)) + return c + + +def tex_checkpoint_marker(): + c = Canvas(16, 16, (0, 0, 0, 0)) + for y in range(16): + for x in range(16): + d = abs(x - 8) + a = 0 + if d <= 1: + a = 200 + elif d <= 3: + a = 120 + if a: + c.set(x, y, (86, 232, 246, a)) + rng = random.Random(5) + for _ in range(24): + x, y = rng.randint(0, 15), rng.randint(0, 15) + if c.get(x, y)[3]: + c.set(x, y, (200, 252, 255, 220)) + return c + + +def tex_pit_lane(): + c = Canvas(16, 16) + rng = random.Random(11) + noise_rect(c, 0, 0, 16, 16, (150, 148, 142), spread=7, rng=rng, speckle=0.08, + speckle_color=(120, 118, 112)) + c.rect(0, 3, 16, 1, (86, 84, 80)) + c.rect(0, 12, 16, 1, (86, 84, 80)) + c.rect(0, 0, 16, 1, (186, 184, 178)) + return c + + +def tex_fake_ice(): + c = Canvas(16, 16) + for y in range(16): + for x in range(16): + v = 190 + ((x * 7 + y * 13) % 24) + c.set(x, y, (v - 40, v - 12, 255, 190)) + for i in range(0, 16, 5): + c.line(i, 0, i + 5, 15, (232, 248, 255, 220)) + return c + + +def tex_fake_slime(): + c = Canvas(16, 16) + for y in range(16): + for x in range(16): + v = (x * 3 + y * 5) % 16 + c.set(x, y, (110 + v, 220 - v, 120 + v // 2, 170)) + c.rect(0, 0, 16, 1, (190, 255, 190, 200)) + return c + + +def tex_kerb(color): + c = Canvas(16, 16) + base = (204, 58, 52) if color == "red" else (232, 228, 218) + for y in range(16): + for x in range(16): + v = ((x + y) % 4) - 2 + c.set(x, y, shade(base, v / 24.0)) + c.rect(0, 0, 16, 2, shade(base, 0.3)) + c.rect(0, 14, 16, 2, shade(base, -0.35)) + return c + + +def tex_finish_line(): + c = Canvas(16, 16) + for y in range(16): + for x in range(16): + on = ((x // 4) + (y // 4)) % 2 == 0 + base = (240, 238, 230) if on else (38, 38, 44) + v = ((x * 3 + y * 5) % 5) - 2 + c.set(x, y, shade(base, v / 30.0)) + return c + + +def tex_station(): + c = Canvas(16, 16) + noise_rect(c, 0, 0, 16, 16, (58, 60, 70), spread=6, rng=random.Random(3)) + c.rect(1, 1, 14, 14, (32, 34, 42)) + c.rect(2, 2, 12, 12, (44, 46, 58)) + # big "P" + for y in range(3, 13): + c.set(5, y, (238, 236, 228)) + c.set(6, y, (238, 236, 228) if y > 7 else (238, 236, 228)) + for x in range(5, 10): + c.set(x, 3, (238, 236, 228)) + c.set(x, 7, (238, 236, 228)) + for y in range(3, 8): + c.set(9, y, (238, 236, 228)) + for y in range(3, 13): + c.set(11, y, (250, 210, 70) if y % 2 == 0 else (200, 160, 40)) + c.rect(5, 3, 5, 1, (255, 255, 245)) + return c + + +def tex_boost_pad(): + """Animated 4 frames of scrolling chevrons.""" + frames = 4 + c = Canvas(16, 16 * frames) + for f in range(frames): + off = f * 16 + for y in range(16): + for x in range(16): + phase = (y + f * 4) % 16 + band = phase < 8 + if band: + v = 200 - phase * 6 + c.set(x, off + y, (60, 200, 240, max(120, v))) + else: + c.set(x, off + y, (18, 20, 30, 200)) + for i in range(4): + top = off + ((f * 4 + i * 8) % 16) + for x in range(16): + for dy in range(0, 5): + y = top + dy + if y >= off + 16: + continue + d = abs(x - 8) + if abs(dy - 2) <= (2 - min(2, d // 3)): + c.set(x, y, (250, 250, 255)) + return c + + +def tex_mod_icon(): + c = Canvas(64, 64) + c.rect(0, 0, 64, 64, (26, 28, 34)) + for y in range(64): + for x in range(64): + if (x + y) % 16 < 8 and y > 40: + c.set(x, y, (40, 42, 50)) + # checkered flag top-left + for by in range(0, 24, 4): + for bx in range(0, 24, 4): + if ((bx + by) // 4) % 2 == 0: + c.rect(bx, by, 4, 4, (238, 236, 228)) + c.rect(22, 2, 2, 22, (150, 150, 156)) + # car silhouette + body = (222, 62, 52) + c.rect(14, 34, 38, 12, body) + c.rect(14, 34, 38, 3, shade(body, 0.3)) + c.rect(22, 26, 20, 9, body) + c.rect(24, 28, 16, 5, (40, 48, 62)) + c.rect(10, 30, 34, 2, shade(body, -0.3)) + c.disc(22, 48, 6, (30, 30, 34)) + c.disc(22, 48, 3, (190, 190, 198)) + c.disc(45, 48, 6, (30, 30, 34)) + c.disc(45, 48, 3, (190, 190, 198)) + c.rect(46, 36, 6, 3, (250, 240, 200)) + c.rect(12, 38, 4, 4, (250, 240, 200)) + return c + + +# --------------------------------------------------------------------------- +# main +# --------------------------------------------------------------------------- +# --------------------------------------------------------------------------- +# extras: race pass, boost flame, the secret car +# --------------------------------------------------------------------------- +def item_race_pass(): + """A race entry pass: lanyard + ticket with a chequered edge.""" + c = Canvas(16, 16, (0, 0, 0, 0)) + # lanyard + c.line(5, 1, 5, 5, (70, 74, 88, 255)) + c.line(10, 1, 10, 5, (70, 74, 88, 255)) + # ticket + c.rect(2, 5, 12, 8, (238, 238, 242)) + c.border(2, 5, 12, 8, (150, 150, 158)) + c.rect(3, 6, 10, 2, (200, 44, 40)) + for x in range(3, 13, 2): + c.set(x, 9, (40, 40, 46)) + c.set(x + 1, 9, (232, 232, 236)) + c.rect(3, 11, 10, 1, (150, 150, 158)) + return c + + +def tex_flame(): + """Boost flame strip: 16x16 of layered orange/yellow with an alpha edge.""" + c = Canvas(16, 16, (0, 0, 0, 0)) + rng = random.Random(77) + for x in range(16): + for y in range(16): + dx = x / 15.0 + dy = abs(y - 7.5) / 7.5 + if dx + dy * 1.15 > 1.15: + continue + heat = max(0.0, 1.0 - (dx * 0.75 + dy * 0.9)) + rng.uniform(-0.06, 0.06) + if heat > 0.72: + color = (255, 246, 200, 235) + elif heat > 0.45: + color = (255, 196, 82, 225) + elif heat > 0.22: + color = (240, 122, 32, 205) + else: + color = (188, 62, 22, 170) + c.set(x, y, color) + return c + + +def main(): + random.seed(404) + tex = lambda *p: os.path.join(TEXTURES, *p) + + # --- blocks ---------------------------------------------------------- + asphalt = tex_asphalt() + asphalt.save(tex("block", "asphalt.png")) + tex_asphalt_cracked().save(tex("block", "asphalt_cracked.png")) + tex_road_block().save(tex("block", "road_block.png")) + tex_safety_barrier().save(tex("block", "safety_barrier.png")) + tex_pit_lane().save(tex("block", "pit_lane.png")) + tex_fake_ice().save(tex("block", "fake_ice.png")) + tex_fake_slime().save(tex("block", "fake_slime.png")) + tex_start_gate().save( + tex("block", "start_gate.png"), + mcmeta={"animation": {"frametime": 4, "interpolate": True}}, + ) + tex_checkpoint_marker().save(tex("block", "checkpoint_marker.png")) + tex_kerb("red").save(tex("block", "kerb_red.png")) + tex_kerb("white").save(tex("block", "kerb_white.png")) + tex_finish_line().save(tex("block", "finish_line.png")) + tex_station().save(tex("block", "station.png")) + tex_boost_pad().save( + tex("block", "boost_pad.png"), + mcmeta={"animation": {"frametime": 2, "interpolate": True}}, + ) + + # --- cars (entity skins) -------------------------------------------- + for name, (primary, secondary) in CAR_COLORS.items(): + car_skin(name, primary, secondary, name).save(tex("entity", "car", "%s.png" % name)) + car_skin("wraith", (38, 36, 56), (128, 56, 200), "hyper").save( + tex("entity", "car", "wraith.png") + ) + car_skin("phantom", (208, 210, 216), (70, 70, 80), "sport").save( + tex("entity", "car", "phantom.png") + ) + car_skin("shade", (44, 66, 58), (90, 200, 160), "f1").save( + tex("entity", "car", "shade.png") + ) + # the secret car: near black with violet trim + car_skin("shadow", (26, 26, 34), (138, 62, 190), "hyper").save( + tex("entity", "car", "shadow.png") + ) + tex_flame().save(tex("entity", "car", "flame.png")) + # white "skin template" so players can hand-paint their own liveries + car_skin("template", (232, 232, 236), (170, 172, 180), "sport").save( + tex("entity", "car", "template.png") + ) + + # --- items ----------------------------------------------------------- + item_carrot_key().save(tex("item", "carrot_key.png")) + for name, (primary, secondary) in CAR_COLORS.items(): + item_car(primary, secondary, name).save(tex("item", "car_%s.png" % name)) + item_car(*CAR_COLORS["hyper"], "hyper").save(tex("item", "car_shadow.png")) + item_wrench().save(tex("item", "wrench.png")) + item_track_wand().save(tex("item", "track_wand.png")) + item_race_pass().save(tex("item", "race_pass.png")) + item_stopwatch().save(tex("item", "stopwatch.png")) + for color_name, rgb in PAINTS.items(): + item_paint_can(rgb).save(tex("item", "paint_%s.png" % color_name)) + item_trophy((238, 200, 72), (150, 110, 20)).save(tex("item", "trophy_gold.png")) + item_trophy((206, 210, 218), (110, 114, 124)).save(tex("item", "trophy_silver.png")) + item_trophy((196, 130, 78), (110, 66, 34)).save(tex("item", "trophy_bronze.png")) + for part in ( + "engine_turbo", "engine_v8", "gearbox_dct", "gearbox_sequential", + "brakes_carbon", "tires_soft", "tires_medium", "tires_hard", "tires_street", + "aero_wing", "ground_effect", "roll_cage", "chassis_plate", "boost_nitro", + "carbon_weave", "race_guide", + ): + item_generic_part(part, Canvas(16, 16)).save(tex("item", "%s.png" % part)) + + # --- mod icon -------------------------------------------------------- + icon_dir = os.path.join(ROOT, "src", "main", "resources") + tex_mod_icon().save(os.path.join(icon_dir, "icon.png")) + + # --- contact sheet for human review (4x nearest neighbour) ----------- + review = Canvas(16 * 4 * 10, 16 * 4 * 6) + tiles = [ + "item/carrot_key.png", "item/car_kart.png", "item/car_f1.png", "item/car_sport.png", + "item/car_hyper.png", "item/car_rally.png", "item/car_drift.png", "item/car_hotrod.png", + "item/car_truck.png", "item/car_shadow.png", + "item/wrench.png", "item/track_wand.png", "item/stopwatch.png", "item/paint_red.png", + "item/paint_blue.png", "item/paint_green.png", "item/trophy_gold.png", "item/trophy_silver.png", + "item/trophy_bronze.png", "item/boost_nitro.png", + "item/engine_turbo.png", "item/engine_v8.png", "item/gearbox_dct.png", + "item/gearbox_sequential.png", "item/brakes_carbon.png", "item/tires_soft.png", + "item/tires_medium.png", "item/tires_hard.png", "item/aero_wing.png", "item/roll_cage.png", + "block/asphalt.png", "block/road_block.png", "block/safety_barrier.png", + "block/pit_lane.png", "block/fake_ice.png", "block/fake_slime.png", + "block/checkpoint_marker.png", "entity/car/sport.png", "entity/car/f1.png", + "entity/car/hyper.png", + "entity/car/kart.png", "entity/car/rally.png", "entity/car/wraith.png", + "entity/car/template.png", "item/carbon_weave.png", "item/race_guide.png", + "item/tires_street.png", "item/chassis_plate.png", "item/ground_effect.png", + "item/paint_yellow.png", + ] + for i, rel in enumerate(tiles): + path = os.path.join(TEXTURES, rel) + if not os.path.exists(path): + continue + img = read_png(path) + review.blit(img.scale(4) if max(img.w, img.h) <= 16 else img.scale(1), + (i % 10) * 64, (i // 10) * 64) + review.save(os.path.join(ROOT, "tools", "preview_items.png")) + + count = sum(len(f) for _, _, f in os.walk(TEXTURES)) + print("generated %d texture files under %s" % (count, TEXTURES)) + + +def read_png(path): + """Minimal PNG reader (RGBA, filter types 0-4) used for previews.""" + with open(path, "rb") as fh: + data = fh.read() + pos = 8 + w = h = 0 + idat = b"" + while pos < len(data): + (length,) = struct.unpack(">I", data[pos:pos + 4]) + tag = data[pos + 4:pos + 8] + chunk = data[pos + 8:pos + 8 + length] + pos += 12 + length + if tag == b"IHDR": + w, h = struct.unpack(">II", chunk[:8]) + elif tag == b"IDAT": + idat += chunk + elif tag == b"IEND": + break + raw = zlib.decompress(idat) + canvas = Canvas(w, h) + stride = w * 4 + prev = bytearray(stride) + off = 0 + for y in range(h): + ftype = raw[off] + off += 1 + line = bytearray(raw[off:off + stride]) + off += stride + if ftype == 1: + for i in range(4, stride): + line[i] = (line[i] + line[i - 4]) & 255 + elif ftype == 2: + for i in range(stride): + line[i] = (line[i] + prev[i]) & 255 + elif ftype == 3: + for i in range(stride): + a = line[i - 4] if i >= 4 else 0 + line[i] = (line[i] + ((a + prev[i]) >> 1)) & 255 + elif ftype == 4: + for i in range(stride): + a = line[i - 4] if i >= 4 else 0 + b = prev[i] + c = prev[i - 4] if i >= 4 else 0 + p = a + b - c + pa, pb, pc = abs(p - a), abs(p - b), abs(p - c) + pr = a if (pa <= pb and pa <= pc) else (b if pb <= pc else c) + line[i] = (line[i] + pr) & 255 + for x in range(w): + canvas.set(x, y, tuple(line[x * 4:x * 4 + 4])) + prev = line + return canvas + + +if __name__ == "__main__": + main() diff --git a/tools/gen_tracks.py b/tools/gen_tracks.py new file mode 100644 index 0000000..e90401e --- /dev/null +++ b/tools/gen_tracks.py @@ -0,0 +1,389 @@ +#!/usr/bin/env python3 +""" +FabricRacers - stock track generator. + + python3 tools/gen_tracks.py + +Builds the five stock circuits as vanilla structure files +(src/main/resources/data/fabricracers/structure/tracks/*.nbt), together with +their definitions (data/fabricracers/tracks/*.json) and top-down preview maps +(tools/previews/*.png) used for design review. + +Design rules: + * canonical design space, track starts heading EAST (+x) from the anchor + * everything lives at x >= 0, z >= 0 so the files are vanilla-legal + * local y: 0 = foundation, 1 = road surface, 2 = car level, 3+ = scenery +""" + +import math +import os +import random +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from track_lib import ( # noqa: E402 + ASPHALT, BARRIER, CAR_Y, CHECKPOINT, CONCRETE, COARSE, Design, DIRT, FINISH, + FOUND_Y, GATE, GLASS, GRASS, IRON, LANTERN, LEAVES, LOG, NS, PIT, PLANKS, + QUARTZ, ROAD_LINE, ROAD_Y, SMOOTH, STATION, STONE, WOOL, yaw_for_offset, +) + +LAPS = {"asphalt_park": 2, "kart_circuit": 3, "speed_oval": 2, + "chaos_junction": 2, "drift_arena": 3} + + +def place_grid(design, center, arc, slots=12, spacing=3.0, start_s=3.5, lane=1.7): + """Staggered 2-lane grid on the start straight.""" + total = len(center) + for i in range(slots): + s = start_s + spacing * i + idx = 0 + for j in range(total): + if arc[j] >= s: + idx = j + break + cx, cz = center[idx] + nx, nz = center[(idx + 1) % total] + px, pz = center[(idx - 1) % total] + tx, tz = nx - px, nz - pz + tl = math.hypot(tx, tz) or 1.0 + perp = (-tz / tl, tx / tl) + off = lane if i % 2 == 0 else -lane + x = int(round(cx + perp[0] * off)) + z = int(round(cz + perp[1] * off)) + design.grid.append((x, z)) + return design.grid + + +def dist_to_grid(cx, cz, grid_cells): + return min(math.hypot(cx - x, cz - z) for (x, z) in grid_cells) + + +def build_track(key, name, short_name, waypoints, width, groups, laps=None, + cp_count=10, grid_slots=12, grandstands=(), extra=None, + pit_spec=None, tree_density=0.05, seed=7): + design = Design(key, name, short_name) + center, arc = design.stamp_road(waypoints, width=width) + total = len(center) + lap_len = arc[-1] + math.hypot(center[0][0] - center[-1][0], center[0][1] - center[-1][1]) + rng = random.Random(seed) + + # --- grid ------------------------------------------------------------ + place_grid(design, center, arc, slots=grid_slots) + design.anchor = (design.grid[0][0], CAR_Y, design.grid[0][1]) + + # --- start / finish line -------------------------------------------- + half = (width - 1) // 2 + line_s = 3.5 + 3.0 * grid_slots + 3.5 + line_idx = min(range(total), key=lambda j: abs(arc[j] - line_s)) + design.paint_line_across(center, line_idx, ASPHALT, checker=True, span=half) + cx, cz = center[line_idx] + design.start_line = (int(round(cx)), int(round(cz))) + design.gantry(int(round(cx)), int(round(cz)), span=half + 3, + beam_y=CAR_Y + 4) + + # --- checkpoints (index 0 == the start line) ------------------------- + design.checkpoint(center, arc, line_idx, None) + for k in range(1, cp_count): + s = (line_s + k * (lap_len - 1.0) / cp_count) % lap_len + idx = min(range(total), key=lambda j: abs(arc[j] - s)) + design.checkpoint(center, arc, idx, None) + + # --- pit lane -------------------------------------------------------- + if pit_spec is not None: + build_pit(design, pit_spec, center, arc, line_idx, width) + + # --- decoration ------------------------------------------------------ + for (kind, args, pos, kwargs) in groups: + if kind == "grandstand": + design.grandstand(*args, **kwargs) + elif kind == "banner": + design.sponsor_banner(*args, **kwargs) + elif kind == "box": + design.box(*args) + elif kind == "hollow": + design.hollow_box(*args) + elif kind == "set": + design.set(*args) + design.light_poles(center, every=max(30, int(lap_len / 6))) + candidates = [ + (x, z) for (x, y, z), (nm, _p) in design.blocks.items() + if y == FOUND_Y and nm in (GRASS, COARSE) + and not design.has(x, CAR_Y, z) and not design.has(x, CAR_Y + 1, z) + ] + rng.shuffle(candidates) + design.trees(candidates[:int(len(candidates) * tree_density)], rng) + + # --- JSON ------------------------------------------------------------ + count, pal = design.write() + extra_json = {"width": width, "default_laps": laps or LAPS.get(key, 2), + "lap_length": int(lap_len)} + if extra: + extra_json.update(extra) + data = design.write_json(extra_json) + path, size = design.render_preview() + print("%-16s blocks=%-6d palette=%-3d size=%s laps=%s cps=%d grid=%d bbox_px=%s" + % (key, count, pal, design.bbox()[1], extra_json["default_laps"], + len(design.checkpoints), len(design.grid), size)) + print(" json=%s preview=%s" % (os.path.relpath( + os.path.join("src", "main", "resources", "data", "fabricracers", "tracks", + "%s.json" % key)), os.path.relpath(path))) + return design + + +def build_pit(design, spec, center, arc, line_idx, width): + """A pit lane parallel to the main straight, merging with it at both ends.""" + x0, x1, z_off = spec + main_z = center[line_idx][1] + pit_z = main_z + z_off + lane_half = 2 # 4-wide lane + + # waypoints: leave the straight, run parallel, rejoin further down + wp = [ + (x0 - 10.0, main_z + 1.0), + (x0 - 4.0, main_z + 2.0), + (x0, pit_z - 0.5), + (x0 + 6.0, pit_z), + ((x0 + x1) / 2.0, pit_z), + (x1 - 6.0, pit_z), + (x1, pit_z - 0.5), + (x1 + 5.0, main_z + 2.0), + (x1 + 11.0, main_z + 1.0), + ] + design.stamp_path(wp, width=lane_half * 2, surface=PIT) + + # open the barrier wherever the pit lane touches the main straight + pit_cells = design.cells_with(ROAD_Y, PIT) + for (x, z) in pit_cells: + for dz in (-1, 0, 1): + design.clear_column(x, z + dz, (CAR_Y, CAR_Y + 1)) + design.clear_column(x + 1, z + dz, (CAR_Y, CAR_Y + 1)) + # ...and across the whole merge aprons + for x in list(range(x0 - 11, x0 + 8)) + list(range(x1 - 7, x1 + 12)): + for dz in range(0, z_off + 3): + design.clear_column(x, int(main_z) + dz, (CAR_Y, CAR_Y + 1)) + + # low pit wall on the track side of the lane, with gaps at both ends + xs = sorted(set(x for (x, _z) in pit_cells)) + for x in xs: + if x < x0 + 2 or x > x1 - 2: + continue # merge aprons stay open + if x % 9 == 0: + continue # crew access gaps + zz = min(z for (xx, z) in pit_cells if xx == x) + design.set(x, CAR_Y, int(main_z) + z_off - lane_half - 1, BARRIER) + design.set(x, CAR_Y + 1, int(main_z) + z_off - lane_half - 1, BARRIER) + + # pit building + garage doors behind the lane + build_max = max(z for (_x, z) in pit_cells) + px0, px1 = xs[0] + 1, xs[-1] - 1 + design.pit_building(px0 + 1, build_max + 4, length=max(10, px1 - px0 - 1), depth=5, + facing=-1) + + # pit box marker (the "station" block) in the middle of the lane + bx = (px0 + px1) // 2 + bz = min(z for (x, z) in pit_cells if x == bx) + design.set(bx, ROAD_Y, bz, STATION) + design.clear_column(bx, bz, (CAR_Y, CAR_Y + 1)) + design.pit = { + "entry": [int(x0), int(pit_z)], + "exit": [int(x1), int(pit_z)], + "box": [int(bx), ROAD_Y, int(bz)], + } + + +# --------------------------------------------------------------------------- +# the circuits +# --------------------------------------------------------------------------- +def asphalt_park(): + wp = [ + (16, 6), (60, 6), # main straight + (72, 12), (78, 24), # T1 + (78, 46), # back straight + (74, 58), (62, 66), # T2 + (38, 66), # second straight + (26, 62), (22, 52), # T3 + (22, 38), # infield straight (north) + (24, 28), (18, 24), # T4 left + (10, 27), (6, 20), # T5 hairpin kit + (8, 12), (14, 7), # T6 back onto the start straight + ] + groups = [ + ("grandstand", (86, 30), {}, {"length": 16, "depth": 6, "facing": 1}), + ("grandstand", (34, 74), {}, {"length": 14, "depth": 5, "facing": -1}), + ("banner", (20, 1), {}, {"y": CAR_Y + 2, "colors": ("red", "white")}), + ("banner", (58, 73), {}, {"y": CAR_Y + 2, "colors": ("blue", "white")}), + # paddock buildings behind the pit lane + ("box", (30, CAR_Y, 30, 40, CAR_Y + 4, 38, QUARTZ), {}, {}), + ("hollow", (30, CAR_Y + 1, 30, 40, CAR_Y + 2, 38, GLASS), {}, {}), + ("set", (35, CAR_Y + 5, 34, LANTERN), {}, {}), + ] + return build_track("asphalt_park", "Asphalt Park", "ASP", wp, 7, + groups, laps=2, cp_count=11, grid_slots=12, + pit_spec=(24, 60, 9)) + + +def kart_circuit(): + wp = [ + (10, 6), (30, 6), # start straight + (38, 9), (41, 16), # T1 + (39, 23), # T2 + (32, 27), (26, 24), (20, 27), # chicane + (13, 23), # T3 + (9, 16), (11, 9), # T4 + merge + ] + groups = [ + ("grandstand", (16, 1), {}, {"length": 10, "depth": 4, "facing": 1}), + ("banner", (30, 34), {}, {"y": CAR_Y + 2, "colors": ("yellow", "black")}), + ("box", (46, CAR_Y, 10, 50, CAR_Y + 3, 14, SMOOTH), {}, {}), + ("set", (48, CAR_Y + 4, 12, LANTERN), {}, {}), + ] + return build_track("kart_circuit", "Kart Circuit", "KRT", wp, 5, + groups, laps=3, cp_count=8, grid_slots=8, + tree_density=0.08, seed=11) + + +def speed_oval(): + wp = [ + (16, 8), (84, 8), # long main straight + (94, 12), (100, 22), # T1 sweep + (100, 50), # long back straight (south) + (94, 62), (84, 68), # T2 sweep + (30, 68), # second straight (west) + (16, 64), (8, 52), # T3 + (8, 20), # long straight north + (16, 8), # T4: back onto the main straight + ] + groups = [ + ("grandstand", (30, 78), {}, {"length": 24, "depth": 6, "facing": -1}), + ("grandstand", (30, 1), {}, {"length": 24, "depth": 6, "facing": 1}), + ("banner", (60, 1), {}, {"y": CAR_Y + 2, "colors": ("green", "white")}), + ("banner", (70, 78), {}, {"y": CAR_Y + 2, "colors": ("black", "yellow")}), + ("box", (40, CAR_Y, 24, 52, CAR_Y + 5, 34, QUARTZ), {}, {}), + ("hollow", (40, CAR_Y + 1, 24, 52, CAR_Y + 3, 34, GLASS), {}, {}), + ("set", (46, CAR_Y + 6, 29, LANTERN), {}, {}), + ] + return build_track("speed_oval", "Bonneville Oval", "OVL", wp, 11, + groups, laps=2, cp_count=8, grid_slots=10, + tree_density=0.03, seed=23) + + +def chaos_junction(): + wp = [ + (16, 36), (34, 36), # start straight (crossed later!) + (48, 32), (56, 22), # up to the top lobe + (50, 12), (34, 10), # top + (22, 18), (20, 28), # coming back down (left side) + (28, 38), # THE crossing + (40, 50), (52, 58), # into the bottom lobe + (54, 70), (42, 76), # bottom + (28, 74), (18, 66), # bottom left + (12, 54), (12, 44), # left side heading north + (16, 36), # merge back onto the start straight + ] + groups = [ + ("grandstand", (46, 84), {}, {"length": 16, "depth": 5, "facing": -1}), + ("banner", (40, 4), {}, {"y": CAR_Y + 2, "colors": ("orange", "white")}), + ("banner", (6, 52), {}, {"y": CAR_Y + 2, "colors": ("purple", "white")}), + ("set", (28, CAR_Y + 3, 38, IRON), {}, {}), + ("set", (28, CAR_Y + 4, 38, LANTERN), {}, {}), + ("box", (60, CAR_Y, 32, 66, CAR_Y + 3, 38, SMOOTH), {}, {}), + ("set", (63, CAR_Y + 4, 35, LANTERN), {}, {}), + ] + return build_track("chaos_junction", "Chaos Junction", "CHS", wp, 7, + groups, laps=2, cp_count=10, grid_slots=10, + tree_density=0.06, seed=31) + + +def drift_arena(): + """An open skid pad with a painted ring, slalom stripes and tyre stacks.""" + design = Design("drift_arena", "Drift Arena", "DRF") + w, d = 60, 44 + for x in range(0, w): + for z in range(0, d): + design.set(x, FOUND_Y, z, COARSE if (x + z) % 9 == 0 else GRASS, + **({"snowy": "false"} if (x + z) % 9 else {})) + for x in range(1, w - 1): + for z in range(1, d - 1): + design.road_cells.add((x, z)) + design.set(x, ROAD_Y, z, ASPHALT) + for x in range(w): + for z in (0, d - 1): + design.set(x, CAR_Y, z, BARRIER) + design.set(x, CAR_Y + 1, z, BARRIER) + for z in range(d): + for x in (0, w - 1): + design.set(x, CAR_Y, z, BARRIER) + design.set(x, CAR_Y + 1, z, BARRIER) + # opening in the north wall (entrance) + for x in range(24, 34): + for y in (CAR_Y, CAR_Y + 1): + if (x, y, 0) in design.blocks: + del design.blocks[(x, y, 0)] + + # painted donut rings + for (cx, cz, r, block) in ((22, 22, 13, FINISH), (44, 22, 8, ROAD_LINE)): + for x in range(w): + for z in range(d): + radius = math.hypot(x - cx, z - cz) + if abs(radius - r) < 0.6: + design.set(x, ROAD_Y, z, block if (x + z) % 2 else ASPHALT) + # slalom stripes + for i in range(6): + x = 30 + i * 4 + for z in range(6, d - 6): + if i % 2 == 0: + design.set(x, ROAD_Y, z, ASPHALT) + else: + design.set(x, ROAD_Y, z, PIT if z % 3 else ASPHALT) + # tyre stacks + for (tx, tz) in ((14, 12), (14, 32), (34, 12), (34, 32), (52, 16), (52, 28)): + for y in range(CAR_Y, CAR_Y + 3): + design.set(tx, y, tz, CONCRETE["black"]) + design.set(tx, CAR_Y + 3, tz, CONCRETE["white"]) + # grid along the west side, heading south->then east loop + for i in range(8): + design.grid.append((12 + (i % 2) * 3, 6 + i * 2)) + design.anchor = (design.grid[0][0], CAR_Y, design.grid[0][1]) + # a simple ring for laps: checkpoints around the donut + design.start_line = (18, 10) + for i in range(8): + angle = i * 45.0 + cx = 30 + 22 * math.cos(math.radians(angle)) + cz = 22 + 16 * math.sin(math.radians(angle)) + design.checkpoints.append((round(cx, 1), round(cz, 1), + round((90 - angle) % 360, 1))) + design.gantry(30, 0, span=6, beam_y=CAR_Y + 4) + rng = random.Random(5) + design.light_poles([(30 + 24 * math.cos(math.radians(a)), 22 + 18 * math.sin(math.radians(a))) + for a in range(0, 360, 60)], every=1, offset=0, height=5) + count, pal = design.write() + data = design.write_json({"width": 1, "default_laps": LAPS["drift_arena"], + "arena": True}) + path, size = design.render_preview() + print("%-16s blocks=%-6d palette=%-3d size=%s laps=%s cps=%d grid=%d bbox_px=%s" + % ("drift_arena", count, pal, design.bbox()[1], data["default_laps"], + len(design.checkpoints), len(design.grid), size)) + print(" json=%s preview=%s" % ( + os.path.relpath(os.path.join("src", "main", "resources", "data", + "fabricracers", "tracks", "drift_arena.json")), + os.path.relpath(path))) + return design + + +def write_index(designs): + """Writes _index.json: the list of stock circuits shipped inside the jar.""" + import json + path = os.path.join(TRACK_DIR if "TRACK_DIR" in globals() else + os.path.join("src", "main", "resources", "data", + "fabricracers", "tracks"), "_index.json") + payload = {"tracks": [design.key for design in designs]} + with open(path, "w") as fh: + json.dump(payload, fh, indent=2) + fh.write("\n") + print("index -> %s (%s)" % (path, ", ".join(payload["tracks"]))) + + +if __name__ == "__main__": + write_index([asphalt_park(), kart_circuit(), speed_oval(), chaos_junction(), + drift_arena()]) diff --git a/tools/preview_items.png b/tools/preview_items.png new file mode 100644 index 0000000000000000000000000000000000000000..3eecca83df11ca99b12bc15b832932716542da62 GIT binary patch literal 21581 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 XWSe_rjDXF^C#916E-&YDVle!FzqAj_ literal 0 HcmV?d00001 diff --git a/tools/previews/chaos_junction.png b/tools/previews/chaos_junction.png new file mode 100644 index 0000000000000000000000000000000000000000..29359d928d12729ed563d1702a2e3a3ce7f360b3 GIT binary patch literal 2346 zcmYjTdpwkB8=e`HunslFlH|}3_LpeTZ!+UJjhRoeDcUu)CX0z2YDQ=@3cDodu?~aC zBBz>aoXROm8yT!Yax7vThY#a4u|DN}XTN`*_xU}~b3e~@U-xxizc>0vds{h~12PZ@ zM2<+nIc|SjAP^`_N^*O4K6~je2t;auh_gB$+%Fi?Og(C{2lDsJ_+x)d!C@x5jbx&6 zPZ6oFt$%{aY%1PU)Tm#7p1TX3oOs@m$ktu5@8D+kzL*bi3Mw(bwXxRoy(SKAzP9JL zJ2)uuQLAdPxx7m1KB&Ieq{04;#P;3a9{BCm zWTT;X7~6VraZ#j%O~j7?VF*HeRA)r)jmBk-sdZB4Iu{iP{lBQY%|?MO#6Oo<8MWFH z%HU(}!S$VVTMRu(;0QaEQspioj;M`s@43G?nT50|J~4_Fj_L0_ZwYImDr3K#{NqmV z@MG7MoNt)67k?+X*wxjLuL!8TF2w@=noo{{-UTtT)Q0BNkXUb#DT-q?{o)4&aWbj& zvP(9$2`AFgT5dfP7?cGN!i-3og&qD=mB25$Nj4UZw7xf65Cho4b4qFlTzEqsW0vrA zr}g2Y4yA9*Aqes|JQ@WnI=P0FMXBo#Va4~^B|g$$p5r|UOk*2A9!R+g0|GSq*s~;5 z;fASlUVXQ>5~DIoO>+kW&i;69f^X$7<<&j9P@Au{-A?mWR(`A7k{xq;{aq;xKW^24$m0x@M~^vSv4wf~5dQPD57XD*=BoLROY|u$<}$`2XJUp7R@{i-(wc#dIAYg3S)jn^ zIlVx^9joUW(%;pTt_Xlb#Ko-qitd4kd^mr?;pGMe=SoRa!p(dsMw`>@Id*eUKnPLe zNbL4Cdd>y=_B_)i-LBm2JF`$q?!IL;`+*?|l*-ilxV}O$y8g&kG~Ut05A_q>d#lp;o%_KI_cD1Au_iHjeD~Yy z%QIEMd%6&lReU5CV*dsLpps4!%H|$)f`$Im&U>Z5`8b2>Ys`GVRS>YTgiWh1 zC{Mj=Yce#z_^&?rx-GBTq$+^vy^`2{=h4r`H34K5#XQme`FJRh0#jp3CE0MgvFTji zatxW@9CZ!~(upTHU+PNe*EvZG-PmW-6p;x5cNPfQF2Qjg&nZDP{ zvp+fQt357MV^6WQ{J|zXnuu|*Y_f?%wbmDg7ppvbuyqU+b(s4qXWrLiG`n_O5(Dtc zgcE0O1fCgQ#ORRh5%)`R{vpohpd3Hbn7m(N96wiPn7=p@=Cz5rkbAG;U$+V2Tveju zEC#viqB#Xy{OqN^U7%|otYlR2`{`-aYXNLt1jSPx<^7!zfWwmNPOIjUk6Y-natl%5 zA}7CBU=X5=Pl-jPCD_fQR6)N(KY$OOi^u3$M7NVTVSVFJXMiwbI4+dAo69v@%_F-T z$bs$dj@-N6QWPZ!9Hdb7vnS9Dw=39Qm3BRG!53pEv9j2qlYWw2^zUB^-}IJgo6(}x z0lJm6R8n$qpSn>Y*!qXA4cWKqrF$%!;wxE~V*&FWi*{p%c3n>vmTO-lTNrkFH zmYU(Ob{n0^)z1I!NHcwCA4!EqLl+MWk7xPiB!e2Mo;!rHl@j`_I_b2Z8_i*nfJP@; zBe5P$vO(9vR7mo}j15V!e)o{CZwQKN7FP4D%$JKMe+Y~G^uShBNYCCqMJEe=RQ=j0p7<$-x$EtJ<~zGu;{~ZZ zUL}pBgy}V=-wY>(D5WV~>6j__6)y*kYbYz3{SE+~c6dr96ist|cbbgKF>QWYMQKHg z?QF}L=M;38VlX7R}_UB3SO4i{6 z#F^nt+4~-1n;#*&EI??IlHx{dkqSE-9LS}(WC|++@AY|{-UV{TRB~ub?RQG_775ti z)oH%>fnrG`TMMzLggw53V4g@+ric6CIsr5lOBk>5`t$`*w?|@QVWg*hbF%Tg<`6D=ZO>Mi0ywM2=SCXj%`g2{~vlC;4c6G literal 0 HcmV?d00001 diff --git a/tools/previews/drift_arena.png b/tools/previews/drift_arena.png new file mode 100644 index 0000000000000000000000000000000000000000..457d6a6b112ab9089c7ea1e768c1f303e36528a1 GIT binary patch literal 825 zcmeAS@N?(olHy`uVBq!ia0vp^yMVZhgAGW^mITBwFfcQCx;TbZ+}s9pR; zwZQZ@I^P1%SuU1;oU|gbZQiEY*=`Us?w)(GztG(!zfurvV#$TWy83(zJNFr9ofIm$ z0Cb}l^In#NQ|4aOcySi!AD|Trn&bV!~PMfdv)bV_QS{?VZe*gAuduDvS*9UXV8#cFRKX!O`74NHq zy1_o-?{c*r&lgD6o(eXB+4iC*Fa63n4X7X3Z%O9r&X<0%fE(y$^To3-o&`q+->zqO zcFt)^^Lz1*NdX+fEEjZVa+YwGXujy-`}_Xd z=_Q3J^O&=sF(mtCW?-MK+|%E%;OEG%moi$ReCOi>`$LSQ4` zE(iA51>O*N+G+YC_FWFsFF8-�I1{ePOnF{HIlM(hXZ+mSFI7^>bP0l+XkKzEpfC literal 0 HcmV?d00001 diff --git a/tools/previews/kart_circuit.png b/tools/previews/kart_circuit.png new file mode 100644 index 0000000000000000000000000000000000000000..5f04e7a5fdf439f2da3a7a2123b5e0e61c1d0692 GIT binary patch literal 1015 zcmeAS@N?(olHy`uVBq!ia0vp^Gl962gAGW=-g0(kU|`EaktaqI0}#YrNDB5Vog zHZt`(Ce#A!756l^wi$!aa9QKBr!H+0=Hn-@bKKvptu` z-@UuHPE2xUxRs-V@Cg?nW-$^3;)#YQ?$uUl_crnvolt1b$xLBr;E+t2z?l+zp!WZV zxPKFuFJB&=HGij&9Z;HQlkWsqy#?1I?cc?pTYr51c9*{O6?v6=T0*Vo9o@wRQ*zZ{ z>haTk)fGQxc&!!QZ&O!mIKTe?&ZXbxZY@@Rdvf0FzcrkzZR8H#;+9XD;K(MK((aqM z@-ov9P1ZaDSr`hlyHj+!%DfbHW}?mqKuyTa+F-TUizs5a?L`eYrwPkpw|8l8PN z4}kPeaJ(ja^YeD|?Nh$`{w()SS-f1jYVPgTYSO%XAP@0udUtw#_STToJ@vT?;qGR; z)UsEWyEiw3e0y6aa_vRcrYNPaF>AM3Wcx;_O#GViG`)`{{Ix$wtI>&F`uqD7wDp(m zIl-}S?)k61xpHbPxc zt7HGq{j+9(Wu1HnQ-f zH6%vQ-uj{Ze~x82_CF1!}u3{GX+SoSGz zuX%NKo~R)tDz?e&yC}_de$PQeNRZuDivC+_YMsyoioIqZ7NbTV1z`|g`CLEh`LVdq To&4W{8JfY<)z4*}Q$iB}Koida literal 0 HcmV?d00001 diff --git a/tools/previews/speed_oval.png b/tools/previews/speed_oval.png new file mode 100644 index 0000000000000000000000000000000000000000..b64c4b54b353e1e624e490d120e6350bfa5eb20b GIT binary patch literal 2855 zcmY*bdpwiv8+U3FGMyYsCWKxuZ9wo)rC& z@d3GtZ?bl4dJ$;LG*`umRh$aKv0*K;M=5Ql3iwjfZpL*+;grBzK z1Z$(2Gii}066&I98QF(_);z?0xNMK@4gT0i=+Kwe;{ zMBue9)a96ef={)Z0@F*|?n>-aAt5X-ww9F_8|&ubF>xYej!?>xPwlRo8RK6p-`58} z0R+d(h=U=;jmivIVdhdrmfL69s9}Xj=Sym2v%DVz%~a}zq{Rv!TUYaD@37X7YgRHs ziXD`=xRw-@heOETJ$RQF7DVlAdLun?wAMw7W^M?e0(n`+2ZcCz*z2AhLyOSHe3+ zQCZ4CqPkZo2;q-e5+Zgo3a~@zgbo%d_fjvVd?>K=e0{$X2xgfrpiQsvF6+Q^R_1&JCI>HtT(FMU=;H zjCTCjtom2Hs+HcW2(iFcr=VC{-P$r{R^Mrh(8zv}h4HsLhC} zkO$pqXTv$kW!T^&KVO;86bSd`=f~Ps-l)p%+t5{gDG+@@kbPCh; z3#vEWjD|PaQoWihJff3yB%1@Hmb^bRq63Z>tVb>wx~iLnY8+#D?FI5m)ahz+g7$LC z$*ZUHWuA@3ny4g$Rl$|LiB-HoR;}jqM^&s$caGFr2AC9(DGb8DXn(dQy!Vug@t9&% z1uIj6SPyC%UnTsz2~vD3Ru9hg%(ndfXdMJJhutK7Xktudv#wvs&IbtHy32E#Bz>^A zU>%%kmGaYWX1cvWmc}dPVX*aa=;}}$HyjlR41$Cb(|C)^9f2;!4{S{Xf>4aDc9Mb= z7$H=8x+)45d8Gldk?V<@U4hkoUzWk%4Q2fz?-C}Thoy<=m*HQ5y0S<|wJn-IKeDFM z0XSkG0$3Obqfl6+0$sq>z#eZPrP}8PeyV*(MguVU%Gwf`au^jTDZd}f5LwKWFoTGe z7em#9;wnQQ3R6ellhM@xoXBl8L}f{Wx~*IeE9TCB@pE0s`7BWaphP6B0$9Q>r!7v! zMQVvC10fN=J-gwF*M?)>Kelg2YtNt30?8EpK`_$jK$JGHc9~3!%I!y}G`d##u!`gx z5y1#%P+CAWiAdCg(o|`xs9=WO3t35djyCx0Hg-e{NgpnrOwnTZp(3IiTaHBNEfbC= zuY#00o%E+C)w+b}mM~$0n_MB2?O91H_}}FVvAR0gPNX~(o}Hkyd5Q(7&F zeku*fL||UiZazs;8F2u$4YvFF&V=n2hA7LyH|^KUOp>n<;e!oU;3$ik#t!#b!DY(IChpyLWnNpx z2~H4BnvVo5v+&0*(*vrng9?S}+QLg7tj!s|#|N~QtrhGzAZiLc;zG7sYS+mpdoG%A z9`i;D`nE>()xzFxUg#;0#Z~lIL%10%La+%_Zsi!;LUPOuy~=L6A3HlW_vr%h6vp9| zt`wg>y)s-NfV7D~TRPt4Ebra$(HZ$@LMIImb!RJ`Lp0*unNg3%H zjOKZ#U-Wwnwo7!cd7yDIDl58XAvy6P-RDRnZ z+z7fgp$B{d%*gNsMz{ROePpVW&WDVT#`({4@0@Z8AMXBK%E<_PhFz>ttosLp2ySXp z%+5CRdy8|SP~1Y}*xa1zj820h`>CMMd&$6dNw%QTePnly?n;yx5{%N@B zF6-XM^5sg-+rkX|K+9c)dDf5b(3h!}_v=Z}$X@(1Mlk;x475Rwy8$hp;Vq!mp#w7I z%t5ghI~YS10X^%*qrqFK3~=Q{Q{*tn>bxZ>QrZ^HTV(%2-~ud#bar solid foundation (dirt/stone) under the road + local y=1 -> the road surface itself (asphalt, kerbs, lines) + local y=2 -> car level: checkpoint light strips, barriers, pit wall + local y=3+ -> everything above: barrier tops, gantries, buildings, trees + +The structure is placed with its local y=2 layer on top of the road surface, +i.e. world y = stationBlock.y for car level. +""" + +import gzip +import json +import math +import os +import struct +import zlib + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +DATA = os.path.join(ROOT, "src", "main", "resources", "data", "fabricracers") +STRUCT_DIR = os.path.join(DATA, "structure", "tracks") +TRACK_DIR = os.path.join(DATA, "tracks") +PREVIEW_DIR = os.path.join(ROOT, "tools", "previews") + +# --- block name shortcuts -------------------------------------------------- +NS = "fabricracers:" +ASPHALT = NS + "asphalt" +ASPHALT_WORN = NS + "asphalt_cracked" +ROAD_LINE = NS + "road_block" +FINISH = NS + "finish_line" +KERB = NS + "kerb" +BARRIER = NS + "safety_barrier" +GATE = NS + "start_gate" +CHECKPOINT = NS + "checkpoint_marker" +PIT = NS + "pit_lane" +STATION = NS + "station" +DIRT = "minecraft:dirt" +GRASS = "minecraft:grass_block" +COARSE = "minecraft:coarse_dirt" +STONE = "minecraft:stone" +SMOOTH = "minecraft:smooth_stone" +QUARTZ = "minecraft:quartz_block" +CONCRETE = { + "white": "minecraft:white_concrete", + "black": "minecraft:black_concrete", + "red": "minecraft:red_concrete", + "yellow": "minecraft:yellow_concrete", + "blue": "minecraft:blue_concrete", + "green": "minecraft:lime_concrete", + "gray": "minecraft:gray_concrete", + "orange": "minecraft:orange_concrete", + "purple": "minecraft:purple_concrete", + "pink": "minecraft:pink_concrete", + "cyan": "minecraft:cyan_concrete", + "lime": "minecraft:lime_concrete", + "light_blue": "minecraft:light_blue_concrete", + "brown": "minecraft:brown_concrete", +} +GLASS = "minecraft:glass" +LANTERN = "minecraft:sea_lantern" +IRON = "minecraft:iron_block" +LOG = "minecraft:oak_log" +LEAVES = "minecraft:oak_leaves" +PLANKS = "minecraft:oak_planks" +WOOL = "minecraft:%s_wool" +TERRACOTTA = "minecraft:%s_terracotta" + +ROAD_Y = 1 # local y of the road surface blocks +CAR_Y = 2 # local y of car level (checkpoint strips, barriers base) +FOUND_Y = 0 # local y of the foundation layer + + +# --------------------------------------------------------------------------- +# NBT writer +# --------------------------------------------------------------------------- +TAG_END, TAG_BYTE, TAG_SHORT, TAG_INT, TAG_LONG = 0, 1, 2, 3, 4 +TAG_FLOAT, TAG_DOUBLE, TAG_BYTE_ARRAY, TAG_STRING = 5, 6, 7, 8 +TAG_LIST, TAG_COMPOUND, TAG_INT_ARRAY = 9, 10, 11 + + +def _nbt_string(name, value): + raw = value.encode("utf-8") + return struct.pack(">H", len(raw)) + raw + + +def _tag_payload(tag_id, value): + if tag_id == TAG_BYTE: + return struct.pack(">b", value) + if tag_id == TAG_SHORT: + return struct.pack(">h", value) + if tag_id == TAG_INT: + return struct.pack(">i", value) + if tag_id == TAG_LONG: + return struct.pack(">q", value) + if tag_id == TAG_FLOAT: + return struct.pack(">f", value) + if tag_id == TAG_DOUBLE: + return struct.pack(">d", value) + if tag_id == TAG_STRING: + return _nbt_string("", value) + if tag_id == TAG_LIST: + item_type, items = value + out = struct.pack(">bi", item_type, len(items)) + for item in items: + out += _tag_payload(item_type, item) + return out + if tag_id == TAG_COMPOUND: + out = b"" + for key, (sub_id, sub_val) in value.items(): + out += struct.pack(">b", sub_id) + _nbt_string("", key) + _tag_payload(sub_id, sub_val) + return out + struct.pack(">b", TAG_END) + if tag_id == TAG_INT_ARRAY: + return struct.pack(">i", len(value)) + b"".join(struct.pack(">i", v) for v in value) + raise ValueError("unsupported tag %d" % tag_id) + + +def _write_file(path, root_compound, gzipped=True): + data = struct.pack(">b", TAG_COMPOUND) + _nbt_string("", "") + _tag_payload(TAG_COMPOUND, root_compound) + os.makedirs(os.path.dirname(path), exist_ok=True) + if gzipped: + with gzip.open(path, "wb", compresslevel=9) as fh: + fh.write(data) + else: + with open(path, "wb") as fh: + fh.write(data) + + +def write_structure(path, blocks, size, anchor, author="FabricRacers"): + """blocks: {(x, y, z): (name, props_dict or None)}""" + palette = [] + palette_index = {} + entries = [] + for (x, y, z) in sorted(blocks, key=lambda p: (p[1], p[2], p[0])): + name, props = blocks[(x, y, z)] + key = (name, tuple(sorted((props or {}).items()))) + if key not in palette_index: + palette_index[key] = len(palette) + palette.append(key) + entries.append((palette_index[key], x, y, z)) + + pal_list = [] + for name, props in palette: + comp = {"Name": (TAG_STRING, name)} + if props: + comp["Properties"] = (TAG_COMPOUND, {k: (TAG_STRING, v) for k, v in props}) + pal_list.append(comp) + + block_list = [ + { + "state": (TAG_INT, state), + "pos": (TAG_LIST, (TAG_INT, [x, y, z])), + } + for state, x, y, z in entries + ] + + root = { + "DataVersion": (TAG_INT, 3953), # 1.21.1 + "size": (TAG_LIST, (TAG_INT, list(size))), + "palette": (TAG_LIST, (TAG_COMPOUND, pal_list)), + "blocks": (TAG_LIST, (TAG_COMPOUND, block_list)), + "entities": (TAG_LIST, (TAG_COMPOUND, [])), + # custom tags (ignored by vanilla, read by TrackStructureBuilder) + "Author": (TAG_STRING, author), + "AnchorPos": (TAG_LIST, (TAG_INT, list(anchor))), + } + _write_file(path, root) + return len(entries), len(palette) + + +# --------------------------------------------------------------------------- +# spline + design helpers +# --------------------------------------------------------------------------- +def catmull_rom_closed(points, samples_per_segment=60): + """Returns a dense list of (x, z) samples around a closed loop.""" + out = [] + n = len(points) + for i in range(n): + p0 = points[(i - 1) % n] + p1 = points[i] + p2 = points[(i + 1) % n] + p3 = points[(i + 2) % n] + for s in range(samples_per_segment): + t = s / float(samples_per_segment) + t2, t3 = t * t, t * t * t + x = 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t + + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3) + z = 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t + + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3) + out.append((x, z)) + return out + + +def catmull_rom_open(points, samples_per_segment=40): + """Smooth open polyline through the given points.""" + if len(points) < 2: + return list(points) + pts = [points[0]] + list(points) + [points[-1]] + out = [] + for i in range(1, len(pts) - 2): + p0, p1, p2, p3 = pts[i - 1], pts[i], pts[i + 1], pts[i + 2] + for s in range(samples_per_segment): + t = s / float(samples_per_segment) + t2, t3 = t * t, t * t * t + out.append(( + 0.5 * ((2 * p1[0]) + (-p0[0] + p2[0]) * t + + (2 * p0[0] - 5 * p1[0] + 4 * p2[0] - p3[0]) * t2 + + (-p0[0] + 3 * p1[0] - 3 * p2[0] + p3[0]) * t3), + 0.5 * ((2 * p1[1]) + (-p0[1] + p2[1]) * t + + (2 * p0[1] - 5 * p1[1] + 4 * p2[1] - p3[1]) * t2 + + (-p0[1] + 3 * p1[1] - 3 * p2[1] + p3[1]) * t3), + )) + out.append(points[-1]) + return out + + +def resample(points, spacing=0.25): + """Resamples a polyline to roughly constant spacing; returns (pts, arc_len).""" + out = [points[0]] + arc = [0.0] + acc = 0.0 + for i in range(1, len(points)): + x0, z0 = out[-1] + x1, z1 = points[i] + d = math.hypot(x1 - x0, z1 - z0) + if d < 1e-9: + continue + while acc + d >= spacing: + t = (spacing - acc) / d + x0, z0 = x0 + (x1 - x0) * t, z0 + (z1 - z0) * t + out.append((x0, z0)) + arc.append(arc[-1] + spacing) + d = math.hypot(x1 - x0, z1 - z0) + acc = 0.0 + acc += d + return out, arc + + +class Design: + """Accumulates blocks on an integer grid; later writes NBT + previews.""" + + def __init__(self, key, name, short_name): + self.key = key + self.name = name + self.short_name = short_name + self.blocks = {} + self.checkpoints = [] # (x, z, yaw) + self.grid = [] # (x, z) + self.road_cells = set() + self.kerb_cells = {} + self.anchor = (0, CAR_Y, 0) + self.start_line = (0, 0) + self.pit = {} + + # -- primitives ------------------------------------------------------ + def set(self, x, y, z, name, **props): + self.blocks[(int(x), int(y), int(z))] = (name, props or None) + + def box(self, x0, y0, z0, x1, y1, z1, name, **props): + for x in range(min(x0, x1), max(x0, x1) + 1): + for y in range(min(y0, y1), max(y0, y1) + 1): + for z in range(min(z0, z1), max(z0, z1) + 1): + self.set(x, y, z, name, **props) + + def hollow_box(self, x0, y0, z0, x1, y1, z1, name, **props): + for x in range(min(x0, x1), max(x0, x1) + 1): + for y in range(min(y0, y1), max(y0, y1) + 1): + for z in range(min(z0, z1), max(z0, z1) + 1): + if x in (x0, x1) or z in (z0, z1): + self.set(x, y, z, name, **props) + + def has(self, x, y, z): + return (int(x), int(y), int(z)) in self.blocks + + # -- road stamping --------------------------------------------------- + def stamp_road(self, waypoints, width=7, worn=True, kerb=True, wall=True, + verge=2, surface=ASPHALT, seed=1): + half = (width - 1) // 2 + center, arc = resample(catmull_rom_closed(waypoints), 0.25) + n = len(center) + for idx, (cx, cz) in enumerate(center): + nx, nz = center[(idx + 1) % n] + px, pz = center[(idx - 1) % n] + tx, tz = nx - px, nz - pz + tl = math.hypot(tx, tz) or 1.0 + tx, tz = tx / tl, tz / tl + perp = (-tz, tx) + s = arc[idx] + for i in range(-half, half + 1): + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + self.road_cells.add((x, z)) + worn_here = worn and ((x * 31 + z * 17 + idx) % 23 == 0) + self.set(x, ROAD_Y, z, ASPHALT_WORN if worn_here else surface) + self.set(x, FOUND_Y, z, DIRT) + if kerb: + for side in (-1, 1): + i = side * (half + 1) + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + color = "red" if int(s / 1.6) % 2 == 0 else "white" + self.kerb_cells[(x, z)] = color + self.set(x, ROAD_Y, z, KERB, color=color) + self.set(x, FOUND_Y, z, DIRT) + if wall: + for side in (-1, 1): + i = side * (half + 2) + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + self.set(x, CAR_Y, z, BARRIER) + self.set(x, CAR_Y + 1, z, BARRIER) + self.set(x, FOUND_Y, z, DIRT) + for side in (-1, 1): + for extra in range(3, 3 + verge): + i = side * (half + extra) + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + if (x, z) in self.road_cells or (x, z) in self.kerb_cells: + continue + if self.has(x, FOUND_Y, z): + continue + self.set(x, FOUND_Y, z, COARSE if ((x + z) % 7 == 0) else GRASS, + **({"snowy": "false"} if (x + z) % 7 else {})) + return center, arc + + def stamp_path(self, waypoints, width, surface=PIT, track=False, kerb=False, + wall=False, verge=0): + """Stamps an open, non-looping road segment (pit lanes, access roads).""" + half = (width - 1) // 2 + pts = resample(catmull_rom_open(waypoints, 12), 0.25)[0] + n = len(pts) + for idx, (cx, cz) in enumerate(pts): + ax, az = pts[min(idx + 1, n - 1)] + bx, bz = pts[max(idx - 1, 0)] + tx, tz = ax - bx, az - bz + tl = math.hypot(tx, tz) or 1.0 + perp = (-tz / tl, tx / tl) + for i in range(-half, half + 1): + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + if track: + self.road_cells.add((x, z)) + self.set(x, ROAD_Y, z, surface) + self.set(x, FOUND_Y, z, DIRT) + if kerb: + for side in (-1, 1): + i = side * (half + 1) + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + self.kerb_cells[(x, z)] = "red" + self.set(x, ROAD_Y, z, KERB, color="red") + if wall: + for side in (-1, 1): + i = side * (half + 2) + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + self.set(x, CAR_Y, z, BARRIER) + self.set(x, CAR_Y + 1, z, BARRIER) + for side in (-1, 1): + for extra in range(3, 3 + verge): + i = side * (half + extra) + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + if (x, z) in self.road_cells or (x, z) in self.kerb_cells: + continue + if not self.has(x, FOUND_Y, z): + self.set(x, FOUND_Y, z, GRASS, snowy="false") + return pts + + def clear_column(self, x, z, ys): + for y in ys: + self.blocks.pop((int(x), int(y), int(z)), None) + + def cells_with(self, y, name): + return set((x, z) for (x, yy, z), (nm, _p) in self.blocks.items() + if yy == y and nm == name) + + def paint_line_across(self, center, idx, name, checker=True, span=None, **props): + """Paints a stripe across the road at sample index `idx`.""" + n = len(center) + nx, nz = center[(idx + 1) % n] + px, pz = center[(idx - 1) % n] + tx, tz = nx - px, nz - pz + tl = math.hypot(tx, tz) or 1.0 + perp = (-tz / tl, tx / tl) + cx, cz = center[idx] + for i in range(-(span or 3), (span or 3) + 1): + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + if (x, z) not in self.road_cells: + continue + if checker and (i + idx) % 2 == 0: + self.set(x, ROAD_Y, z, FINISH) + else: + self.set(x, ROAD_Y, z, name, **props) + + def checkpoint(self, center, arc, idx, yaw_from_dir): + cx, cz = center[idx] + n = len(center) + nx, nz = center[(idx + 1) % n] + px, pz = center[(idx - 1) % n] + tx, tz = nx - px, nz - pz + tl = math.hypot(tx, tz) or 1.0 + perp = (-tz / tl, tx / tl) + for i in range(-4, 5): + x = int(round(cx + perp[0] * i)) + z = int(round(cz + perp[1] * i)) + if (x, z) not in self.road_cells: + continue + self.set(x, CAR_Y, z, CHECKPOINT, lit="false") + yaw = math.degrees(math.atan2(-tx, tz)) % 360.0 + self.checkpoints.append((round(cx, 1), round(cz, 1), round(yaw, 1))) + + # -- decoration ------------------------------------------------------ + def gantry(self, x, z, width=15, beam_y=CAR_Y + 4, span=9): + """Start/finish gantry across the road, centred on (x, z).""" + for side in (-1, 1): + px = x + side * span + self.box(px, CAR_Y, z, px, CAR_Y + 4, z, IRON) + self.box(x - span, beam_y, z, x + span, beam_y, z, CONCRETE["white"]) + self.box(x - span, beam_y + 1, z, x + span, beam_y + 1, z, CONCRETE["black"]) + for i in range(-4, 5): + if i in (-4, -2, 0, 2, 4): + continue + self.set(x + i, beam_y, z, GATE, lit="false") + for i in (-6, -3, 3, 6): + self.set(x + i, beam_y - 1, z, CONCRETE["red"]) + self.set(x, beam_y + 2, z, LANTERN) + + def grandstand(self, x, z, length=18, depth=7, facing=1): + """Simple grandstand along the main straight.""" + for i in range(length): + for d in range(depth): + seat = i % 3 + color = ("red", "white", "blue")[seat] + self.set(x + i, CAR_Y + 1 + d, z + facing * d, WOOL % color) + self.set(x + i, CAR_Y + 9, z + facing * (depth - 1), CONCRETE["gray"]) + self.box(x, CAR_Y + 2, z + facing * (depth - 1), x + length - 1, CAR_Y + 8, + z + facing * (depth - 1), GLASS) + for i in range(0, length, 4): + self.set(x + i, CAR_Y + 9, z, CONCRETE["gray"]) + self.box(x, CAR_Y + 10, z, x + length - 1, CAR_Y + 10, z + facing * (depth - 1), + CONCRETE["white"]) + self.set(x + length // 2, CAR_Y + 11, z + facing * (depth // 2), LANTERN) + + def pit_building(self, x, z, length=22, depth=6, facing=-1): + for i in range(length): + for d in range(depth): + self.set(x + i, FOUND_Y, z + facing * d, SMOOTH) + self.set(x + i, ROAD_Y, z + facing * d, PIT if d == 0 else SMOOTH) + self.box(x, CAR_Y, z, x + length - 1, CAR_Y + 3, z + facing * (depth - 1), QUARTZ) + for i in range(1, length - 1): + if i % 3 == 0: + continue + self.box(x + i, CAR_Y + 1, z + facing * (depth - 1), + x + i, CAR_Y + 2, z + facing * (depth - 1), GLASS) + self.box(x, CAR_Y + 4, z - (0 if facing > 0 else depth - 1), x + length - 1, + CAR_Y + 4, z + facing * (depth - 1), CONCRETE["gray"]) + for i in range(0, length, 4): + self.set(x + i, CAR_Y + 3, z + facing * (depth - 1), LANTERN) + for i in range(1, length, 3): # pit wall between track and pits + self.set(x + i, CAR_Y, z + facing * -1, BARRIER) + self.set(x + i, CAR_Y + 1, z + facing * -1, BARRIER) + + def light_poles(self, center, every=44, offset=6, height=5): + for i in range(0, len(center), every): + n = len(center) + nx, nz = center[(i + 1) % n] + px, pz = center[(i - 1) % n] + tx, tz = nx - px, nz - pz + tl = math.hypot(tx, tz) or 1.0 + perp = (-tz / tl, tx / tl) + for side in (-1,): + x = int(round(center[i][0] + perp[0] * offset * side)) + z = int(round(center[i][1] + perp[1] * offset * side)) + if self.has(x, CAR_Y, z) and not self.has(x, FOUND_Y, z): + continue + for h in range(CAR_Y, CAR_Y + height): + self.set(x, h, z, IRON) + self.set(x, CAR_Y + height, z, LANTERN) + + def trees(self, cells, rng): + for (x, z) in cells: + if self.has(x, CAR_Y, z) or self.has(x, CAR_Y + 1, z): + continue + if not self.has(x, FOUND_Y, z): + continue + h = 4 + rng.randint(0, 2) + for y in range(CAR_Y, CAR_Y + h): + self.set(x, y, z, LOG, axis="y") + for dx in range(-2, 3): + for dz in range(-2, 3): + for dy in range(-1, 2): + if abs(dx) == 2 and abs(dz) == 2 and dy == -1: + continue + if abs(dx) + abs(dz) + abs(dy) > 4: + continue + self.set(x + dx, CAR_Y + h + dy, z + dz, LEAVES, + persistent="true", distance="1", waterlogged="false") + + def sponsor_banner(self, x, z, y=3, colors=("red", "white"), text_pattern=None): + pattern = text_pattern or [ + "1111110", "1000000", "1111100", "1000000", + "1000000", "1000000", "1111110", + ] + for row, line in enumerate(pattern): + for col, bit in enumerate(line): + if bit == "1": + self.set(x + col, y + (len(pattern) - 1 - row), z, CONCRETE[colors[0]]) + elif bit == "0" and (row + col) % 2 == 0: + self.set(x + col, y + (len(pattern) - 1 - row), z, CONCRETE[colors[1]]) + + # -- output ---------------------------------------------------------- + def bbox(self): + xs = [p[0] for p in self.blocks] + ys = [p[1] for p in self.blocks] + zs = [p[2] for p in self.blocks] + return (min(xs), min(ys), min(zs)), (max(xs) + 1, max(ys) + 1, max(zs) + 1) + + def normalize(self): + """Vanilla structure files cannot contain negative coordinates.""" + (mn, mx) = self.bbox() + dx, dz = max(0, -mn[0]), max(0, -mn[2]) + if not (dx or dz): + return + self.blocks = {(x + dx, y, z + dz): v for (x, y, z), v in self.blocks.items()} + self.checkpoints = [(x + dx, z + dz, yaw) for (x, z, yaw) in self.checkpoints] + self.grid = [(x + dx, z + dz) for (x, z) in self.grid] + self.road_cells = {(x + dx, z + dz) for (x, z) in self.road_cells} + self.kerb_cells = {(x + dx, z + dz): v for (x, z), v in self.kerb_cells.items()} + self.anchor = (self.anchor[0] + dx, self.anchor[1], self.anchor[2] + dz) + self.start_line = (self.start_line[0] + dx, self.start_line[1] + dz) + shifted = {} + for key, value in self.pit.items(): + if isinstance(value, (list, tuple)) and len(value) == 2: + shifted[key] = [value[0] + dx, value[1] + dz] + elif isinstance(value, (list, tuple)) and len(value) == 3: + shifted[key] = [value[0] + dx, value[1], value[2] + dz] + else: + shifted[key] = value + self.pit = shifted + + def write(self): + self.normalize() + (mn, mx) = self.bbox() + count, pal = write_structure( + os.path.join(STRUCT_DIR, "%s.nbt" % self.key), self.blocks, mx, self.anchor + ) + return count, pal + + def write_json(self, extra=None): + data = { + "name": self.name, + "short_name": self.short_name, + "structure": "fabricracers:tracks/%s" % self.key, + "anchor": list(self.anchor), + "start_line": [self.start_line[0], CAR_Y, self.start_line[1]], + "pit": self.pit, + "grid": [[x, CAR_Y, z] for (x, z) in self.grid], + "checkpoints": [[x, CAR_Y, z, yaw] for (x, z, yaw) in self.checkpoints], + } + if extra: + data.update(extra) + os.makedirs(TRACK_DIR, exist_ok=True) + with open(os.path.join(TRACK_DIR, "%s.json" % self.key), "w") as fh: + json.dump(data, fh, indent=2) + fh.write("\n") + return data + + # -- preview --------------------------------------------------------- + def render_preview(self, scale=3): + (mn, mx) = self.bbox() + w, h = mx[0] - mn[0] + 2, mx[2] - mn[2] + 2 + img = [[(26, 26, 30) for _ in range(w)] for _ in range(h)] + + def put(x, z, color): + px, pz = x - mn[0] + 1, z - mn[2] + 1 + if 0 <= px < w and 0 <= pz < h: + img[pz][px] = color + + ROAD_COLORS = { + ASPHALT: (64, 64, 70), + ASPHALT_WORN: (58, 58, 64), + ROAD_LINE: (150, 148, 140), + FINISH: (238, 236, 228), + KERB: (226, 220, 210), + PIT: (128, 126, 120), + NS + "fake_ice": (170, 210, 240), + NS + "fake_slime": (120, 190, 120), + } + grass = set() + for (x, y, z), (name, props) in self.blocks.items(): + if y == FOUND_Y and name in (GRASS, COARSE, DIRT, STONE, SMOOTH, QUARTZ, PLANKS): + grass.add((x, z)) + for (x, z) in grass: + put(x, z, (48, 78, 44)) + for (x, z) in self.road_cells: + put(x, z, (64, 64, 70)) + for (x, y, z), (name, props) in self.blocks.items(): + if y != ROAD_Y: + continue + if name == KERB: + put(x, z, (226, 220, 210) if props.get("color") == "white" else (204, 58, 52)) + elif name == FINISH: + put(x, z, (240, 238, 230) if (x + z) % 2 else (40, 40, 46)) + elif name in ROAD_COLORS: + put(x, z, ROAD_COLORS[name]) + for (x, z, _yaw) in self.checkpoints: + put(int(x), int(z), (86, 232, 246)) + for (x, y, z), (name, props) in self.blocks.items(): + if y != CAR_Y: + continue + if name == CHECKPOINT: + put(x, z, (86, 232, 246)) + elif name == BARRIER: + put(x, z, (188, 60, 52)) + elif name == GATE: + put(x, z, (250, 210, 70)) + elif name == STATION: + put(x, z, (255, 120, 240)) + for (x, z) in self.grid: + for dx in (0, 1): + for dz in (0, 1): + put(x + dx, z + dz, (90, 220, 120)) + if self.start_line != (0, 0): + for dz in range(-4, 5): + for dx in range(-1, 2): + if (dx + dz) % 2 == 0: + put(self.start_line[0] + dx, self.start_line[1] + dz, (250, 250, 250)) + put(self.anchor[0], self.anchor[2], (255, 160, 40)) + + canvas = Png(w * scale, h * scale) + for y in range(h * scale): + for x in range(w * scale): + canvas.set(x, y, img[y // scale][x // scale]) + os.makedirs(PREVIEW_DIR, exist_ok=True) + path = os.path.join(PREVIEW_DIR, "%s.png" % self.key) + canvas.save(path) + return path, (w, h) + + +class Png: + """Minimal RGBA PNG writer.""" + + def __init__(self, w, h): + self.w, self.h = w, h + self.px = bytearray(w * h * 4) + + def set(self, x, y, color): + if not (0 <= x < self.w and 0 <= y < self.h): + return + i = (y * self.w + x) * 4 + self.px[i:i + 4] = bytes(color[:3]) + b"\xff" + + def save(self, path): + raw = b"" + for y in range(self.h): + raw += b"\x00" + bytes(self.px[y * self.w * 4:(y + 1) * self.w * 4]) + png = b"\x89PNG\r\n\x1a\n" + png += _png_chunk(b"IHDR", struct.pack(">IIBBBBB", self.w, self.h, 8, 6, 0, 0, 0)) + png += _png_chunk(b"IDAT", zlib.compress(raw, 9)) + png += _png_chunk(b"IEND", b"") + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "wb") as fh: + fh.write(png) + + +def _png_chunk(tag, data): + return struct.pack(">I", len(data)) + tag + data + struct.pack( + ">I", zlib.crc32(tag + data) & 0xFFFFFFFF + ) + + +def yaw_for_offset(dx, dz): + """Minecraft yaw for a direction vector (0 = south/+z, 90 = west, 180 = north, 270 = east).""" + return math.degrees(math.atan2(-dx, dz)) % 360.0 diff --git a/tools/validate_assets.py b/tools/validate_assets.py new file mode 100644 index 0000000..a78d16b --- /dev/null +++ b/tools/validate_assets.py @@ -0,0 +1,463 @@ +#!/usr/bin/env python3 +""" +FabricRacers asset validator. + +Run by `./gradlew check` (see build.gradle) and by CI. It is deliberately +dependency free: it parses every JSON file, walks the model/blockstate graph, +reads the generated structure files back and cross checks them against the +Java registries so a missing texture or a stale track file fails the build +instead of failing in game. + + python3 tools/validate_assets.py +""" + +import gzip +import json +import os +import re +import struct +import sys + +ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +RES = os.path.join(ROOT, "src", "main", "resources") +ASSETS = os.path.join(RES, "assets", "fabricracers") +DATA = os.path.join(RES, "data", "fabricracers") +MAIN_JAVA = os.path.join(ROOT, "src", "main", "java") +CLIENT_JAVA = os.path.join(ROOT, "src", "client", "java") + +PROBLEMS = [] +NOTES = [] + + +def fail(message): + PROBLEMS.append(message) + + +def read_json(path): + try: + with open(path, "r", encoding="utf-8") as handle: + return json.load(handle) + except json.JSONDecodeError as exc: + fail(f"{rel(path)}: invalid JSON ({exc})") + except OSError as exc: + fail(f"{rel(path)}: cannot read ({exc})") + return None + + +def rel(path): + return os.path.relpath(path, ROOT) + + +def exists(path, what): + if not os.path.exists(path): + fail(f"missing {what}: {rel(path)}") + return False + return True + + +# --------------------------------------------------------------------------- +# Java side: pull the registries out of the source so this stays in sync +# --------------------------------------------------------------------------- +def java_source(relative): + path = os.path.join(ROOT, relative) + if not os.path.exists(path): + return "" + with open(path, "r", encoding="utf-8") as handle: + return handle.read() + + +def registered_blocks(): + source = java_source("src/main/java/dev/qynl/racers/registry/ModBlocks.java") + return re.findall(r'register\("([a-z_]+)"', source) + + +def registered_items(): + source = java_source("src/main/java/dev/qynl/racers/registry/ModItems.java") + names = [name for name in re.findall(r'register\("([a-z_0-9]+)"', source) + if not name.endswith("_")] + # car items are built with a loop helper: car(CarType.KART) -> car_ + car_ids = re.findall(r"car\(CarType\.([A-Z_]+)\)", source) + for name in car_ids: + names.append("car_" + name.lower()) + # tuning parts are registered from a switch on Tune + part_block = source.split("private static Item part(Tune tune)")[-1].split("\n }")[0] + names.extend(re.findall(r'->\s*"([a-z_0-9]+)"', part_block)) + # paint cans are registered by name + names.extend("paint_" + colour for colour in + re.findall(r'paint\("([a-z_]+)"', source)) + return sorted(set(names)) + + +def entity_types(): + source = java_source("src/main/java/dev/qynl/racers/car/CarType.java") + return [name.lower() for name in re.findall(r"^\s{4}([A-Z][A-Z0-9_]*)\(", source, re.M)] + + +def translation_keys_used(): + keys = set() + pattern = re.compile(r'translatable\(\s*"([A-Za-z0-9_.\-]+)"') + for base, _dirs, files in os.walk(os.path.join(ROOT, "src")): + for name in files: + if name.endswith(".java"): + with open(os.path.join(base, name), "r", encoding="utf-8") as handle: + keys.update(pattern.findall(handle.read())) + return sorted(keys) + + +# --------------------------------------------------------------------------- +# model graph +# --------------------------------------------------------------------------- +def texture_path(identifier): + namespace, _, path = identifier.partition(":") + if not path: + namespace, path = "minecraft", namespace + return os.path.join(RES, "assets", namespace, "textures", path + ".png") + + +def check_model(identifier, seen): + """Resolve a model id and verify every concrete texture it uses exists.""" + namespace, _, path = identifier.partition(":") + if not path: + namespace, path = "minecraft", namespace + model_path = os.path.join(RES, "assets", namespace, "models", path + ".json") + if namespace != "fabricracers": + return # vanilla assets are not ours to validate + if not os.path.exists(model_path): + fail(f"missing model {identifier} ({rel(model_path)})") + return + if model_path in seen: + return + seen.add(model_path) + model = read_json(model_path) + if model is None: + return + for value in model.get("textures", {}).values(): + if isinstance(value, str) and not value.startswith("#"): + if not exists(texture_path(value), f"texture for model {identifier}"): + pass + parent = model.get("parent") + if isinstance(parent, str): + check_model(parent, seen) + + +def check_block_assets(blocks): + for block in blocks: + state_path = os.path.join(ASSETS, "blockstates", block + ".json") + if not exists(state_path, f"blockstate for {block}"): + continue + state = read_json(state_path) + if state is None: + continue + variants = state.get("variants", {}) + if not variants: + fail(f"blockstate {block}.json has no variants") + for variant in variants.values(): + entries = variant if isinstance(variant, list) else [variant] + for entry in entries: + model = entry.get("model") if isinstance(entry, dict) else None + if not model: + fail(f"blockstate {block}.json has a variant without a model") + continue + check_model(model, set()) + # every block has an item model because they are all sold as items + item_model = os.path.join(ASSETS, "models", "item", block + ".json") + if exists(item_model, f"item model for block {block}"): + check_model(f"fabricracers:{block}" if False else "minecraft:item/generated", set()) + # loot table + loot = os.path.join(DATA, "loot_table", "blocks", block + ".json") + if exists(loot, f"loot table for {block}"): + read_json(loot) + + +def check_item_assets(items, blocks): + for item in items: + if item in blocks: + continue # covered by the block check + model = os.path.join(ASSETS, "models", "item", item + ".json") + if exists(model, f"item model for {item}"): + check_model("fabricracers:item/" + item, set()) + + +# --------------------------------------------------------------------------- +# structure files +# --------------------------------------------------------------------------- +class NbtReader: + """Minimal NBT reader, just enough for structure files.""" + + def __init__(self, data): + self.data = data + self.pos = 0 + + def read(self): + tag = self.u8() + if tag != 10: + raise ValueError("root tag is not a compound") + self.skip_name() + return self.payload(10) + + def u8(self): + value = self.data[self.pos] + self.pos += 1 + return value + + def i16(self): + value = struct.unpack_from(">h", self.data, self.pos)[0] + self.pos += 2 + return value + + def i32(self): + value = struct.unpack_from(">i", self.data, self.pos)[0] + self.pos += 4 + return value + + def i64(self): + value = struct.unpack_from(">q", self.data, self.pos)[0] + self.pos += 8 + return value + + def f32(self): + value = struct.unpack_from(">f", self.data, self.pos)[0] + self.pos += 4 + return value + + def f64(self): + value = struct.unpack_from(">d", self.data, self.pos)[0] + self.pos += 8 + return value + + def string(self): + length = struct.unpack_from(">H", self.data, self.pos)[0] + self.pos += 2 + value = self.data[self.pos:self.pos + length].decode("utf-8", "replace") + self.pos += length + return value + + def skip_name(self): + self.string() + + def payload(self, tag): + if tag == 1: + return self.u8() + if tag == 2: + return self.i16() + if tag == 3: + return self.i32() + if tag == 4: + return self.i64() + if tag == 5: + return self.f32() + if tag == 6: + return self.f64() + if tag == 7: + length = self.i32() + value = self.data[self.pos:self.pos + length] + self.pos += length + return value + if tag == 8: + return self.string() + if tag == 9: + kind = self.u8() + length = self.i32() + return [self.payload(kind) for _ in range(length)] + if tag == 10: + value = {} + while True: + child = self.u8() + if child == 0: + return value + name = self.string() + value[name] = self.payload(child) + if tag == 11: + length = self.i32() + values = list(struct.unpack_from(">%di" % length, self.data, self.pos)) + self.pos += 4 * length + return values + if tag == 12: + length = self.i32() + values = list(struct.unpack_from(">%dq" % length, self.data, self.pos)) + self.pos += 8 * length + return values + raise ValueError(f"unsupported NBT tag {tag}") + + +def read_nbt(path): + with gzip.open(path, "rb") as handle: + return NbtReader(handle.read()).read() + + +VANILLA_TRACK_BLOCKS = { + "minecraft:air", "minecraft:cave_air", "minecraft:dirt", "minecraft:grass_block", + "minecraft:coarse_dirt", "minecraft:stone", "minecraft:cobblestone", "minecraft:gravel", + "minecraft:sand", "minecraft:oak_planks", "minecraft:spruce_planks", "minecraft:oak_log", + "minecraft:spruce_log", "minecraft:oak_leaves", "minecraft:spruce_leaves", + "minecraft:short_grass", "minecraft:fern", "minecraft:iron_bars", "minecraft:stone_bricks", + "minecraft:chiseled_stone_bricks", "minecraft:polished_andesite", "minecraft:andesite", + "minecraft:glass", "minecraft:tinted_glass", "minecraft:sea_lantern", "minecraft:lantern", + "minecraft:redstone_lamp", "minecraft:redstone_torch", "minecraft:torch", "minecraft:water", + "minecraft:red_concrete", "minecraft:white_concrete", "minecraft:black_concrete", + "minecraft:gray_concrete", "minecraft:light_gray_concrete", "minecraft:blue_concrete", + "minecraft:yellow_concrete", "minecraft:lime_concrete", "minecraft:cyan_concrete", + "minecraft:orange_concrete", "minecraft:purple_concrete", "minecraft:pink_concrete", + "minecraft:brown_concrete", "minecraft:green_concrete", "minecraft:magenta_concrete", + "minecraft:light_blue_concrete", "minecraft:smooth_stone", "minecraft:smooth_stone_slab", + "minecraft:stone_slab", "minecraft:barrier", "minecraft:light", +} +MOD_BLOCK_NAMES = {f"fabricracers:{name}" for name in registered_blocks()} + + +def check_tracks(): + index_path = os.path.join(DATA, "tracks", "_index.json") + if not exists(index_path, "stock track index"): + return + index = read_json(index_path) + if not isinstance(index, dict): + fail("_index.json should be an object") + return + entries = index.get("tracks", index if isinstance(index, list) else []) + ids = [] + if isinstance(entries, list): + for entry in entries: + if isinstance(entry, str): + ids.append(entry) + elif isinstance(entry, dict) and "id" in entry: + ids.append(entry["id"]) + if not ids: + fail("_index.json does not list any tracks") + for track_id in ids: + json_path = os.path.join(DATA, "tracks", track_id + ".json") + if not exists(json_path, f"track definition {track_id}"): + continue + definition = read_json(json_path) + if not isinstance(definition, dict): + continue + for key in ("name", "width", "structure", "grid", "checkpoints"): + if key not in definition: + fail(f"{track_id}.json: missing '{key}'") + if definition.get("id", track_id) != track_id: + fail(f"{track_id}.json: id field is '{definition.get('id')}'") + nbt_path = os.path.join(DATA, "structure", "tracks", track_id + ".nbt") + if not exists(nbt_path, f"structure for {track_id}"): + continue + try: + nbt = read_nbt(nbt_path) + except Exception as exc: # noqa: BLE001 - report whatever went wrong + fail(f"{rel(nbt_path)}: cannot parse ({exc})") + continue + version = nbt.get("DataVersion") + if version != 3953: + fail(f"{rel(nbt_path)}: DataVersion {version} (expected 3953)") + size = nbt.get("size") + if not isinstance(size, list) or len(size) != 3: + fail(f"{rel(nbt_path)}: bad size tag") + else: + declared = definition.get("size") + if isinstance(declared, list) and declared[:3] != size[:3]: + fail(f"{track_id}: json size {declared} != structure size {size}") + anchor = definition.get("anchor") + if isinstance(anchor, list) and isinstance(size, list) and len(anchor) == 3: + for axis, value in enumerate(anchor): + if not 0 <= value <= size[axis]: + fail(f"{track_id}: anchor {anchor} outside the structure {size}") + palette = nbt.get("palette") + if not isinstance(palette, list) or not palette: + fail(f"{rel(nbt_path)}: missing palette") + else: + for entry in palette: + name = entry.get("Name") if isinstance(entry, dict) else None + if name is None: + fail(f"{rel(nbt_path)}: palette entry without Name") + continue + if name in MOD_BLOCK_NAMES: + continue + if name in VANILLA_TRACK_BLOCKS: + continue + if name.startswith("fabricracers:"): + fail(f"{rel(nbt_path)}: references unknown mod block {name}") + else: + NOTES.append(f"{rel(nbt_path)}: uses vanilla block {name}") + # sanity: grid and checkpoints inside the footprint + if isinstance(size, list) and len(size) == 3: + sx, sy, sz = size + for entry in definition.get("grid", []): + if not (0 <= entry[0] <= sx and 0 <= entry[2] <= sz): + fail(f"{track_id}: grid point {entry} outside the structure") + for entry in definition.get("checkpoints", []): + if not (0 <= entry[0] <= sx and 0 <= entry[2] <= sz): + fail(f"{track_id}: checkpoint {entry} outside the structure") + return ids + + +def check_single_files(): + # fabric.mod.json + mod_path = os.path.join(RES, "fabric.mod.json") + if exists(mod_path, "fabric.mod.json"): + mod = read_json(mod_path) + if isinstance(mod, dict): + icon = mod.get("icon") + if icon and not exists(os.path.join(RES, icon), "mod icon"): + pass + entrypoints = mod.get("entrypoints", {}) + for key, class_path in (("main", "src/main/java"), ("client", "src/client/java")): + for name in entrypoints.get(key, []): + path = os.path.join(ROOT, class_path, name.replace(".", "/") + ".java") + if not exists(path, f"{key} entrypoint class"): + pass + # every JSON file parses + for base, _dirs, files in os.walk(RES): + for name in files: + if name.endswith(".json") or name.endswith(".mcmeta"): + read_json(os.path.join(base, name)) + + +def check_entity_textures(): + directory = os.path.join(ASSETS, "textures", "entity", "car") + for car in entity_types(): + exists(os.path.join(directory, car + ".png"), f"car texture for {car}") + + +def main(): + blocks = registered_blocks() + items = registered_items() + if not blocks or not items: + fail("could not read the block/item registries from the Java sources") + check_single_files() + check_block_assets(blocks) + check_item_assets(items, blocks) + check_entity_textures() + lang_path = os.path.join(ASSETS, "lang", "en_us.json") + if exists(lang_path, "en_us.json"): + lang = read_json(lang_path) or {} + for item in items: + if item in blocks: + continue + if f"item.fabricracers.{item}" not in lang: + fail(f"lang is missing item.fabricracers.{item}") + for block in blocks: + if f"block.fabricracers.{block}" not in lang: + fail(f"lang is missing block.fabricracers.{block}") + vanilla = {"gui.done", "gui.cancel", "gui.back", "gui.toTitle"} + for key in translation_keys_used(): + if key in vanilla or key.endswith(".") or key.endswith("_") \ + or key.startswith("item.fabricracers.") and key.count(".") < 3: + continue + if key.startswith("fabricracers.") or key.startswith("itemGroup."): + continue + if key not in lang: + fail(f"lang is missing {key} (used by code)") + check_tracks() + + for note in NOTES[:10]: + print("note:", note) + if len(NOTES) > 10: + print(f"note: ... and {len(NOTES) - 10} more vanilla-block notes") + if PROBLEMS: + print(f"FabricRacers asset validation FAILED ({len(PROBLEMS)} problems):") + for problem in PROBLEMS: + print(" -", problem) + return 1 + print(f"FabricRacers assets OK ({len(blocks)} blocks, {len(items)} items).") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) From 9a614c2c7602d53f5e76a480db8e1822f798550c Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:42:03 +0000 Subject: [PATCH 03/24] CI: surface Gradle errors as annotations when the build fails Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d3793f5..4545730 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -24,9 +24,29 @@ jobs: run: python3 tools/validate_assets.py - name: Build - run: ./gradlew build --no-daemon --stacktrace + id: build + continue-on-error: true + run: | + set -o pipefail + ./gradlew build --no-daemon --stacktrace --console=plain > gradle.log 2>&1 || true + tail -n 3 gradle.log + + # Compiler errors are surfaced as annotations so a build failure is + # readable straight from the run summary. + - name: Report build result + if: steps.build.outcome == 'failure' + run: | + echo "=== gradle.log (tail) ===" + tail -n 80 gradle.log + echo "=== annotations ===" + grep -aE "error:|Could not resolve|Could not find|Could not download|What went wrong|Caused by:|FAILURE:|A problem occurred" gradle.log \ + | head -40 | while IFS= read -r line; do + echo "::error title=Gradle::${line}" + done + exit 1 - name: Upload Mod JAR + if: steps.build.outcome == 'success' uses: actions/upload-artifact@v4 with: name: fabricracers From 9e33e2f00d506b302d5ab3acc22b368409ac8170 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:43:10 +0000 Subject: [PATCH 04/24] CI: stream gradle log to an artifact on failure Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 30 +++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4545730..5482f54 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,30 +23,34 @@ jobs: - name: Validate assets run: python3 tools/validate_assets.py + # tee keeps the full compiler output; GitHub's default shell is + # `bash -eo pipefail`, so a broken build still fails this step. - name: Build - id: build - continue-on-error: true - run: | - set -o pipefail - ./gradlew build --no-daemon --stacktrace --console=plain > gradle.log 2>&1 || true - tail -n 3 gradle.log + run: ./gradlew build --no-daemon --stacktrace --console=plain 2>&1 | tee gradle.log # Compiler errors are surfaced as annotations so a build failure is # readable straight from the run summary. - - name: Report build result - if: steps.build.outcome == 'failure' + - name: Report build failure + if: failure() run: | echo "=== gradle.log (tail) ===" - tail -n 80 gradle.log + tail -n 100 gradle.log echo "=== annotations ===" - grep -aE "error:|Could not resolve|Could not find|Could not download|What went wrong|Caused by:|FAILURE:|A problem occurred" gradle.log \ - | head -40 | while IFS= read -r line; do + grep -aE "error:|Could not resolve|Could not find|Could not download|Could not get resource|What went wrong|Caused by:|FAILURE:" gradle.log \ + | head -50 | while IFS= read -r line; do echo "::error title=Gradle::${line}" done - exit 1 + + - name: Upload Gradle log + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gradle-log + path: gradle.log + if-no-files-found: warn - name: Upload Mod JAR - if: steps.build.outcome == 'success' + if: success() uses: actions/upload-artifact@v4 with: name: fabricracers From 8aa479aed1e284da3d90416928b5846c5b75ec90 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:45:09 +0000 Subject: [PATCH 05/24] CI: fail the build step on Gradle errors (pipefail) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 41 +++++++++++++++++++++++++++---------- 1 file changed, 30 insertions(+), 11 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5482f54..efa9b4f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -23,23 +23,42 @@ jobs: - name: Validate assets run: python3 tools/validate_assets.py - # tee keeps the full compiler output; GitHub's default shell is - # `bash -eo pipefail`, so a broken build still fails this step. + # `set -o pipefail` matters: without it `| tee` would swallow Gradle's + # exit code and a broken build would look green. - name: Build - run: ./gradlew build --no-daemon --stacktrace --console=plain 2>&1 | tee gradle.log + run: | + set -o pipefail + ./gradlew build --no-daemon --stacktrace --console=plain 2>&1 | tee gradle.log - # Compiler errors are surfaced as annotations so a build failure is - # readable straight from the run summary. + # Compiler errors are packed into a single escaped annotation (plus the + # log tail) so a failure is readable straight through the checks API. - name: Report build failure if: failure() run: | echo "=== gradle.log (tail) ===" - tail -n 100 gradle.log - echo "=== annotations ===" - grep -aE "error:|Could not resolve|Could not find|Could not download|Could not get resource|What went wrong|Caused by:|FAILURE:" gradle.log \ - | head -50 | while IFS= read -r line; do - echo "::error title=Gradle::${line}" - done + tail -n 120 gradle.log + echo "=== jars produced ===" + find build -name '*.jar' 2>/dev/null | head -20 || true + python3 - <<'PY' + import re, pathlib + log = pathlib.Path('gradle.log').read_text(errors='replace') + pat = re.compile( + r"(error:|Could not resolve|Could not find|Could not download|Could not get resource" + r"|What went wrong|Caused by:|FAILURE:|A problem occurred|BUILD FAILED)" + ) + seen, out = set(), [] + for line in log.splitlines(): + if not pat.search(line): + continue + line = line.rstrip() + if line in seen: + continue + seen.add(line) + out.append(line) + msg = "\n".join(out[:200]) or log[-3000:] + msg = msg.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') + print(f"::error title=Gradle build errors::{msg}") + PY - name: Upload Gradle log if: failure() From cdf1633d1f6ac7accf598e113ccfbc4b5ecb04b0 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:49:12 +0000 Subject: [PATCH 06/24] Fix the first wave of 1.21.1 compile errors (network codecs, item use, settings, trades) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- job.html | 1264 +++++++++++++++++ .../dev/qynl/racers/block/RacerBlocks.java | 1 + .../java/dev/qynl/racers/car/CarData.java | 4 - .../java/dev/qynl/racers/item/CarItem.java | 19 +- .../java/dev/qynl/racers/item/ToolItems.java | 67 +- .../dev/qynl/racers/net/ModNetworking.java | 110 +- .../dev/qynl/racers/net/ServerActions.java | 11 +- .../dev/qynl/racers/registry/ModItems.java | 10 +- .../java/dev/qynl/racers/shop/PartShop.java | 19 +- 9 files changed, 1411 insertions(+), 94 deletions(-) create mode 100644 job.html diff --git a/job.html b/job.html new file mode 100644 index 0000000..483d463 --- /dev/null +++ b/job.html @@ -0,0 +1,1264 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + CI: stream gradle log to an artifact on failure ยท Qynl/MyFirstMod@9e33e2f ยท GitHub + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +

+
+ + +
+ Skip to content + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + +
+
+ + + + + + + + + + + + + + + + + + + +
+
+ + + + +
+ +
+ + + + + + + + +
+ + + + + + + +
+ + + + + + + + + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + +
+ + +
+
+
+ + + + +
+
+ + +
+ +
+ +
+ + +
+ +
+ +
+
+

+ + CI: stream gradle log to an artifact on failure + + #252 + +

+
+ + + +
+ +
+
+
+
+ + + +
+
+
+

+ CI: stream gradle log to an artifact on failure +

+

+ +
+ +
+ + + CI: stream gradle log to an artifact on failure #252 + +

+ +
+
+ +
+
+ +
+ +
+ + + +
+
+ +
+
+
+ + +
+
+
+ + + + + + + + + + + + +
+
+ + + + +
+
+
+ + +
+
+ +
+
+
+
+ + +
+
+

Annotations

+
1 error, 2 warnings, and 1 notice
+
+
+
+
+ +
+
+
+
+ +
+ + +
+ +
+ +
+
+ +
+
+
+

+ + build + +

+
+
+
+
+ + +
+ + failed + Oct 11, 2026 in 59s + + +
+
+ + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+ +
+
+ + + +
+
+
+
+ + +
+ + +
+ +
+ + + +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+
+
+ + + diff --git a/src/main/java/dev/qynl/racers/block/RacerBlocks.java b/src/main/java/dev/qynl/racers/block/RacerBlocks.java index 8fc44ef..89fcfa8 100644 --- a/src/main/java/dev/qynl/racers/block/RacerBlocks.java +++ b/src/main/java/dev/qynl/racers/block/RacerBlocks.java @@ -2,6 +2,7 @@ import java.util.Locale; +import net.minecraft.block.AbstractBlock.Settings; import net.minecraft.block.Block; import net.minecraft.block.BlockState; import net.minecraft.block.enums.NoteBlockInstrument; diff --git a/src/main/java/dev/qynl/racers/car/CarData.java b/src/main/java/dev/qynl/racers/car/CarData.java index aee3814..08c2fb3 100644 --- a/src/main/java/dev/qynl/racers/car/CarData.java +++ b/src/main/java/dev/qynl/racers/car/CarData.java @@ -2,8 +2,6 @@ import java.util.UUID; -import net.minecraft.component.DataComponentTypes; -import net.minecraft.component.type.CustomModelDataComponent; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NbtCompound; @@ -158,7 +156,5 @@ public static ItemStack createStack(CarType type, PlayerEntity owner) { public static void apply(ItemStack stack, CarData data) { ModComponents.setCarData(stack, data.toNbt()); - stack.set(DataComponentTypes.CUSTOM_MODEL_DATA, new CustomModelDataComponent( - java.util.List.of(), java.util.List.of(), java.util.List.of(), java.util.List.of())); } } diff --git a/src/main/java/dev/qynl/racers/item/CarItem.java b/src/main/java/dev/qynl/racers/item/CarItem.java index dae074d..8d6275b 100644 --- a/src/main/java/dev/qynl/racers/item/CarItem.java +++ b/src/main/java/dev/qynl/racers/item/CarItem.java @@ -10,6 +10,7 @@ import net.minecraft.util.ActionResult; import net.minecraft.util.Formatting; import net.minecraft.util.Hand; +import net.minecraft.util.TypedActionResult; import net.minecraft.util.math.BlockPos; import net.minecraft.util.math.Direction; import net.minecraft.util.math.Vec3d; @@ -57,20 +58,28 @@ public ActionResult useOnBlock(ItemUsageContext context) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); if (world.isClient) { - return ActionResult.SUCCESS; + return TypedActionResult.success(stack); } - ItemStack stack = user.getStackInHand(hand); // Drop it roughly a car length in front of the player Vec3d look = Vec3d.fromPolar(0.0F, user.getYaw()).multiply(2.6); Vec3d spawn = user.getPos().add(look); double ground = Cars.groundHeight(world, spawn); if (Double.isNaN(ground)) { user.sendMessage(Text.translatable("message.fabricracers.no_room").formatted(Formatting.RED), true); - return ActionResult.FAIL; + return TypedActionResult.fail(stack); + } + ActionResult result = spawnCar(stack, user, world, new Vec3d(spawn.x, ground, spawn.z), + user.getYaw()); + if (result == ActionResult.FAIL) { + return TypedActionResult.fail(stack); + } + if (result == ActionResult.PASS) { + return TypedActionResult.pass(stack); } - return spawnCar(stack, user, world, new Vec3d(spawn.x, ground, spawn.z), user.getYaw()); + return TypedActionResult.success(stack); } private ActionResult spawnCar(ItemStack stack, PlayerEntity player, World world, Vec3d spawn, float yaw) { diff --git a/src/main/java/dev/qynl/racers/item/ToolItems.java b/src/main/java/dev/qynl/racers/item/ToolItems.java index 238be70..ddf8e98 100644 --- a/src/main/java/dev/qynl/racers/item/ToolItems.java +++ b/src/main/java/dev/qynl/racers/item/ToolItems.java @@ -12,6 +12,7 @@ import net.minecraft.util.ActionResult; import net.minecraft.util.Formatting; import net.minecraft.util.Hand; +import net.minecraft.util.TypedActionResult; import net.minecraft.util.math.BlockPos; import net.minecraft.world.World; @@ -39,6 +40,17 @@ private static CarEntity carAt(World world, PlayerEntity player) { return near; } + /** Turns a plain action result from a helper into an item-use result. */ + static TypedActionResult wrap(ActionResult result, ItemStack stack) { + if (result == ActionResult.FAIL) { + return TypedActionResult.fail(stack); + } + if (result == ActionResult.PASS) { + return TypedActionResult.pass(stack); + } + return TypedActionResult.success(stack); + } + /** Repairs a car, picks it back up, or opens the tuning sheet. */ public static class Wrench extends Item { public Wrench(Settings settings) { @@ -58,17 +70,18 @@ public ActionResult useOnBlock(ItemUsageContext context) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); if (world.isClient) { - return ActionResult.SUCCESS; + return TypedActionResult.success(stack); } CarEntity car = carAt(world, user); if (car == null) { user.sendMessage(Text.translatable("message.fabricracers.wrench_none") .formatted(Formatting.GRAY), true); - return ActionResult.PASS; + return TypedActionResult.pass(stack); } - return interact(car, user, user.getStackInHand(hand), user.isSneaking()); + return wrap(interact(car, user, stack, user.isSneaking()), stack); } private ActionResult interact(CarEntity car, PlayerEntity player, ItemStack wrench, boolean sneaking) { @@ -122,25 +135,26 @@ public CarKey(Settings settings) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); if (world.isClient) { - return ActionResult.SUCCESS; + return TypedActionResult.success(stack); } CarEntity car = carAt(world, user); if (car == null) { user.sendMessage(Text.translatable("message.fabricracers.wrench_none") .formatted(Formatting.GRAY), true); - return ActionResult.PASS; + return TypedActionResult.pass(stack); } if (car.ownerName().isEmpty() || car.owner() == null) { car.setOwner(user.getUuid(), user.getName().getString()); user.sendMessage(Text.translatable("message.fabricracers.claimed", car.carType().displayName).formatted(Formatting.GREEN), true); - return ActionResult.CONSUME; + return TypedActionResult.success(stack); } user.sendMessage(Text.translatable("message.fabricracers.key_owner", Text.literal(car.ownerName())).formatted(Formatting.YELLOW), true); - return ActionResult.CONSUME; + return TypedActionResult.success(stack); } @Override @@ -168,11 +182,12 @@ public ActionResult useOnBlock(ItemUsageContext context) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); if (world.isClient) { - return ActionResult.SUCCESS; + return TypedActionResult.success(stack); } - return paint(carAt(world, user), user, user.getStackInHand(hand)); + return wrap(paint(carAt(world, user), user, stack), stack); } private ActionResult paint(CarEntity car, PlayerEntity player, ItemStack stack) { @@ -212,9 +227,10 @@ public ActionResult useOnBlock(ItemUsageContext context) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); BlockPos pos = user == null ? BlockPos.ORIGIN : user.getBlockPos(); - return use(world, user, hand, pos); + return wrap(use(world, user, hand, pos), stack); } private ActionResult use(World world, PlayerEntity user, Hand hand, BlockPos pos) { @@ -253,12 +269,13 @@ public Stopwatch(Settings settings) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); if (world.isClient) { - Stopwatch.items().toggle(); - return ActionResult.SUCCESS; + dev.qynl.racers.util.Stopwatch.items().toggle(); + return TypedActionResult.success(stack); } - return ActionResult.CONSUME; + return TypedActionResult.success(stack); } @Override @@ -277,14 +294,15 @@ public Trophy(String place, Settings settings) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); if (world.isClient) { - return ActionResult.SUCCESS; + return TypedActionResult.success(stack); } if (user instanceof ServerPlayerEntity player) { StatsManager.get(player.server).sendRecord(player); } - return ActionResult.CONSUME; + return TypedActionResult.success(stack); } @Override @@ -323,9 +341,10 @@ public RacePass(Settings settings) { } @Override - public ActionResult use(World world, PlayerEntity user, Hand hand) { + public TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); if (world.isClient) { - return ActionResult.SUCCESS; + return TypedActionResult.success(stack); } if (user instanceof ServerPlayerEntity player) { var session = dev.qynl.racers.race.RaceManager.get(player.server).sessionFor(player); @@ -337,7 +356,7 @@ public ActionResult use(World world, PlayerEntity user, Hand hand) { false); } } - return ActionResult.CONSUME; + return TypedActionResult.success(stack); } @Override diff --git a/src/main/java/dev/qynl/racers/net/ModNetworking.java b/src/main/java/dev/qynl/racers/net/ModNetworking.java index b511020..8886828 100644 --- a/src/main/java/dev/qynl/racers/net/ModNetworking.java +++ b/src/main/java/dev/qynl/racers/net/ModNetworking.java @@ -2,13 +2,12 @@ import java.util.List; -import com.mojang.serialization.Codec; -import com.mojang.serialization.codecs.RecordCodecBuilder; - import net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry; import net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking; import net.minecraft.nbt.NbtCompound; import net.minecraft.nbt.NbtList; +import net.minecraft.network.RegistryByteBuf; +import net.minecraft.network.codec.PacketCodec; import net.minecraft.server.MinecraftServer; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.util.math.BlockPos; @@ -65,10 +64,12 @@ private ModNetworking() { /** Driver input, sent every tick while a player is behind the wheel. */ public record CarControl(int entityId, int mask) implements net.minecraft.network.packet.CustomPayload { public static final Id ID = new Id<>(FabricRacers.id("car_control")); - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Codec.INT.fieldOf("entity").forGetter(CarControl::entityId), - Codec.INT.fieldOf("mask").forGetter(CarControl::mask) - ).apply(instance, CarControl::new)); + public static final PacketCodec CODEC = PacketCodec.of( + (value, buf) -> { + buf.writeVarInt(value.entityId); + buf.writeVarInt(value.mask); + }, + buf -> new CarControl(buf.readVarInt(), buf.readVarInt())); @Override public Id getId() { @@ -80,13 +81,16 @@ public Id getId() { public record ClientAction(int action, int entityId, int a, int b, String text) implements net.minecraft.network.packet.CustomPayload { public static final Id ID = new Id<>(FabricRacers.id("client_action")); - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Codec.INT.fieldOf("action").forGetter(ClientAction::action), - Codec.INT.fieldOf("entity").forGetter(ClientAction::entityId), - Codec.INT.fieldOf("a").forGetter(ClientAction::a), - Codec.INT.fieldOf("b").forGetter(ClientAction::b), - Codec.STRING.fieldOf("text").forGetter(ClientAction::text) - ).apply(instance, ClientAction::new)); + public static final PacketCodec CODEC = PacketCodec.of( + (value, buf) -> { + buf.writeVarInt(value.action); + buf.writeVarInt(value.entityId); + buf.writeInt(value.a); + buf.writeInt(value.b); + buf.writeString(value.text); + }, + buf -> new ClientAction(buf.readVarInt(), buf.readVarInt(), buf.readInt(), buf.readInt(), + buf.readString())); @Override public Id getId() { @@ -103,15 +107,27 @@ public record ServerAction(int action, int entityId, int a, int b, float f, Stri NbtCompound data) implements net.minecraft.network.packet.CustomPayload { public static final Id ID = new Id<>(FabricRacers.id("server_action")); - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Codec.INT.fieldOf("action").forGetter(ServerAction::action), - Codec.INT.fieldOf("entity").forGetter(ServerAction::entityId), - Codec.INT.fieldOf("a").forGetter(ServerAction::a), - Codec.INT.fieldOf("b").forGetter(ServerAction::b), - Codec.FLOAT.fieldOf("f").forGetter(ServerAction::f), - Codec.STRING.fieldOf("text").forGetter(ServerAction::text), - NbtCompound.CODEC.fieldOf("data").forGetter(ServerAction::data) - ).apply(instance, ServerAction::new)); + public static final PacketCodec CODEC = PacketCodec.of( + (value, buf) -> { + buf.writeVarInt(value.action); + buf.writeVarInt(value.entityId); + buf.writeInt(value.a); + buf.writeInt(value.b); + buf.writeFloat(value.f); + buf.writeString(value.text); + buf.writeNbt(value.data); + }, + buf -> { + int action = buf.readVarInt(); + int entityId = buf.readVarInt(); + int a = buf.readInt(); + int b = buf.readInt(); + float f = buf.readFloat(); + String text = buf.readString(); + NbtCompound data = buf.readNbt(); + return new ServerAction(action, entityId, a, b, f, text, + data == null ? new NbtCompound() : data); + }); @Override public Id getId() { @@ -130,27 +146,33 @@ public record Hud(int state, int countdown, int lap, int laps, int position, int String trackName, String message, String standings, String carName) implements net.minecraft.network.packet.CustomPayload { public static final Id ID = new Id<>(FabricRacers.id("hud")); - public static final Codec CODEC = RecordCodecBuilder.create(instance -> instance.group( - Codec.INT.fieldOf("state").forGetter(Hud::state), - Codec.INT.fieldOf("countdown").forGetter(Hud::countdown), - Codec.INT.fieldOf("lap").forGetter(Hud::lap), - Codec.INT.fieldOf("laps").forGetter(Hud::laps), - Codec.INT.fieldOf("position").forGetter(Hud::position), - Codec.INT.fieldOf("total").forGetter(Hud::total), - Codec.INT.fieldOf("timeMs").forGetter(Hud::timeMs), - Codec.INT.fieldOf("lapTimeMs").forGetter(Hud::lapTimeMs), - Codec.INT.fieldOf("bestLapMs").forGetter(Hud::bestLapMs), - Codec.INT.fieldOf("speed").forGetter(Hud::speedKmh), - Codec.DOUBLE.fieldOf("boost").forGetter(Hud::boost), - Codec.INT.fieldOf("drift").forGetter(Hud::driftScore), - Codec.INT.fieldOf("gapAhead").forGetter(Hud::gapAheadMs), - Codec.INT.fieldOf("gapBehind").forGetter(Hud::gapBehindMs), - Codec.INT.fieldOf("flags").forGetter(Hud::flags), - Codec.STRING.fieldOf("track").forGetter(Hud::trackName), - Codec.STRING.fieldOf("message").forGetter(Hud::message), - Codec.STRING.fieldOf("standings").forGetter(Hud::standings), - Codec.STRING.fieldOf("car").forGetter(Hud::carName) - ).apply(instance, Hud::new)); + public static final PacketCodec CODEC = PacketCodec.of( + (value, buf) -> { + buf.writeVarInt(value.state); + buf.writeVarInt(value.countdown); + buf.writeVarInt(value.lap); + buf.writeVarInt(value.laps); + buf.writeVarInt(value.position); + buf.writeVarInt(value.total); + buf.writeVarInt(value.timeMs); + buf.writeVarInt(value.lapTimeMs); + buf.writeVarInt(value.bestLapMs); + buf.writeVarInt(value.speedKmh); + buf.writeDouble(value.boost); + buf.writeVarInt(value.driftScore); + buf.writeInt(value.gapAheadMs); + buf.writeInt(value.gapBehindMs); + buf.writeVarInt(value.flags); + buf.writeString(value.trackName); + buf.writeString(value.message); + buf.writeString(value.standings); + buf.writeString(value.carName); + }, + buf -> new Hud(buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), + buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), buf.readVarInt(), + buf.readVarInt(), buf.readVarInt(), buf.readDouble(), buf.readVarInt(), + buf.readInt(), buf.readInt(), buf.readVarInt(), buf.readString(), + buf.readString(), buf.readString(), buf.readString())); @Override public Id getId() { diff --git a/src/main/java/dev/qynl/racers/net/ServerActions.java b/src/main/java/dev/qynl/racers/net/ServerActions.java index cbf16da..a19f051 100644 --- a/src/main/java/dev/qynl/racers/net/ServerActions.java +++ b/src/main/java/dev/qynl/racers/net/ServerActions.java @@ -20,6 +20,7 @@ import dev.qynl.racers.track.Track; import dev.qynl.racers.track.TrackManager; import dev.qynl.racers.track.TrackPlacement; +import dev.qynl.racers.track.TrackRecorder; import dev.qynl.racers.util.Cars; /** Handles every button press and menu action coming from a client. */ @@ -67,7 +68,7 @@ private static void openGarage(ServerPlayerEntity player, int entityId) { for (Tune tune : Tune.values()) { extra.putInt(tune.id, PartShop.costFor(tune, car.data().level(tune) + 1)); extra.putInt(tune.id + "_owned", countParts(player, - dev.qynl.racers.registry.ModItems.part(tune))); + dev.qynl.racers.registry.ModItems.partItem(tune))); } ModNetworking.sendGarage(player, car, extra); } @@ -88,9 +89,9 @@ private static void tune(ServerPlayerEntity player, int entityId, int tuneIndex) return; } int cost = PartShop.costFor(tune, level + 1); - if (player.isCreative() || countParts(player, dev.qynl.racers.registry.ModItems.part(tune)) >= cost) { + if (player.isCreative() || countParts(player, dev.qynl.racers.registry.ModItems.partItem(tune)) >= cost) { if (!player.isCreative()) { - consumeParts(player, dev.qynl.racers.registry.ModItems.part(tune), cost); + consumeParts(player, dev.qynl.racers.registry.ModItems.partItem(tune), cost); } int[] levels = car.levels(); levels[tune.ordinal()] = level + 1; @@ -102,7 +103,7 @@ private static void tune(ServerPlayerEntity player, int entityId, int tuneIndex) } else { ModNetworking.notify(player, "You need " + cost + "x " + tune.displayName + " part (you have " + countParts(player, - dev.qynl.racers.registry.ModItems.part(tune)) + ")."); + dev.qynl.racers.registry.ModItems.partItem(tune)) + ")."); } } @@ -151,7 +152,7 @@ private static void placeTrack(ServerPlayerEntity player, String trackId, int ro if (mode == 1) { anchor = anchor.offset(player.getHorizontalFacing()); } - manager.placeTrack(player, track, anchor, rotation); + manager.placeTrack(player, track, anchor, rotation); } private static void removeTrack(ServerPlayerEntity player, int x, int y, int z, String text) { diff --git a/src/main/java/dev/qynl/racers/registry/ModItems.java b/src/main/java/dev/qynl/racers/registry/ModItems.java index b306213..233d5bc 100644 --- a/src/main/java/dev/qynl/racers/registry/ModItems.java +++ b/src/main/java/dev/qynl/racers/registry/ModItems.java @@ -178,7 +178,8 @@ public static Item carItem(CarType type) { return CAR_ITEMS.get(type); } - public static Item part(Tune tune) { + /** The upgrade part item for a tuning system. */ + public static Item partItem(Tune tune) { return TUNE_PARTS.get(tune); } @@ -198,8 +199,11 @@ public static ItemStack carStack(CarType type, PlayerEntity owner) { } public static void init() { - // the group is registered at class load; also mirror the tools into vanilla tabs - ItemGroupEvents.modifyEntriesEvent(net.minecraft.item.ItemGroups.TRANSPORTATION) + // The mod has its own creative tab; also mirror the headline items into + // the vanilla tools tab (ItemGroups' keys are private in this version). + ItemGroupEvents.modifyEntriesEvent(net.minecraft.registry.RegistryKey.of( + net.minecraft.registry.RegistryKeys.ITEM_GROUP, + net.minecraft.util.Identifier.ofVanilla("tools"))) .register(entries -> { entries.add(CAR_SPORT); entries.add(WRENCH); diff --git a/src/main/java/dev/qynl/racers/shop/PartShop.java b/src/main/java/dev/qynl/racers/shop/PartShop.java index 1ae676a..a633dfc 100644 --- a/src/main/java/dev/qynl/racers/shop/PartShop.java +++ b/src/main/java/dev/qynl/racers/shop/PartShop.java @@ -10,6 +10,7 @@ import net.minecraft.util.math.random.Random; import net.minecraft.village.TradeOffer; import net.minecraft.village.TradeOffers; +import net.minecraft.village.TradedItem; import net.minecraft.village.VillagerProfession; import dev.qynl.racers.car.CarType; @@ -28,8 +29,8 @@ private PartShop() { public static void init() { // --- tier 1: starter parts and building blocks ------------------- TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 1, factories -> { - factories.add(buy(ModItems.part(Tune.TIRES), 2, 9, 8)); - factories.add(buy(ModItems.part(Tune.BRAKES), 1, 8, 8)); + factories.add(buy(ModItems.partItem(Tune.TIRES), 2, 9, 8)); + factories.add(buy(ModItems.partItem(Tune.BRAKES), 1, 8, 8)); }); TradeOfferHelper.registerVillagerOffers(VillagerProfession.MASON, 1, factories -> { factories.add(buy(ModItems.BLOCK_ASPHALT_ITEM, 16, 7, 6)); @@ -38,15 +39,15 @@ public static void init() { // --- tier 2: real tuning ---------------------------------------- TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 2, factories -> { - factories.add(buy(ModItems.part(Tune.ENGINE), 1, 18, 6)); - factories.add(buy(ModItems.part(Tune.GEARBOX), 1, 16, 6)); - factories.add(buy(ModItems.part(Tune.CHASSIS), 1, 14, 6)); + factories.add(buy(ModItems.partItem(Tune.ENGINE), 1, 18, 6)); + factories.add(buy(ModItems.partItem(Tune.GEARBOX), 1, 16, 6)); + factories.add(buy(ModItems.partItem(Tune.CHASSIS), 1, 14, 6)); }); // --- tier 3: aero, boost and the cars themselves ----------------- TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 3, factories -> { - factories.add(buy(ModItems.part(Tune.AERO), 1, 26, 5)); - factories.add(buy(ModItems.part(Tune.BOOST), 1, 24, 5)); + factories.add(buy(ModItems.partItem(Tune.AERO), 1, 26, 5)); + factories.add(buy(ModItems.partItem(Tune.BOOST), 1, 24, 5)); factories.add(PartShop::carOffer); }); TradeOfferHelper.registerVillagerOffers(VillagerProfession.WEAPONSMITH, 3, @@ -69,7 +70,7 @@ public static void init() { /** A plain "give emeralds, get item" trade. */ private static TradeOffers.Factory buy(Item item, int amount, int price, int maxUses) { - return (entity, random) -> new TradeOffer(new ItemStack(Items.EMERALD, price), + return (entity, random) -> new TradeOffer(new TradedItem(Items.EMERALD, price), new ItemStack(item, amount), maxUses, 5, 0.05F); } @@ -77,7 +78,7 @@ private static TradeOffers.Factory buy(Item item, int amount, int price, int max private static TradeOffer carOffer(net.minecraft.entity.Entity entity, Random random) { List sellable = new ArrayList<>(CarType.stock()); CarType type = sellable.get(random.nextInt(sellable.size())); - return new TradeOffer(new ItemStack(Items.EMERALD, Math.max(4, type.price() * 3)), + return new TradeOffer(new TradedItem(Items.EMERALD, Math.max(4, type.price() * 3)), ModItems.carStack(type, null), 2, 12, 0.1F); } From ce4261dc52fde7e95c4dd3f73663992ca6968196 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:50:54 +0000 Subject: [PATCH 07/24] Fix ServerActions/removeTrack arity, BlockSoundGroup.METAL, richer CI diagnostics Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 39 ++++++++++++------- .../dev/qynl/racers/block/RacerBlocks.java | 2 +- .../dev/qynl/racers/net/ServerActions.java | 14 +++++-- 3 files changed, 36 insertions(+), 19 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index efa9b4f..fc9d513 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -42,22 +42,31 @@ jobs: python3 - <<'PY' import re, pathlib log = pathlib.Path('gradle.log').read_text(errors='replace') - pat = re.compile( - r"(error:|Could not resolve|Could not find|Could not download|Could not get resource" - r"|What went wrong|Caused by:|FAILURE:|A problem occurred|BUILD FAILED)" - ) - seen, out = set(), [] - for line in log.splitlines(): - if not pat.search(line): + lines = log.splitlines() + pat = re.compile(r"^(\S+\.java):(\d+): (error|warning): (.*)$") + blocks, i = [], 0 + while i < len(lines): + if not pat.match(lines[i]): + i += 1 continue - line = line.rstrip() - if line in seen: - continue - seen.add(line) - out.append(line) - msg = "\n".join(out[:200]) or log[-3000:] - msg = msg.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') - print(f"::error title=Gradle build errors::{msg}") + block = [lines[i]] + j = i + 1 + while j < len(lines) and len(block) < 8 and lines[j].startswith(' '): + block.append(lines[j]) + j += 1 + blocks.append("\n".join(block)) + i = j + seen, uniq = set(), [] + for b in blocks: + if b not in seen: + seen.add(b) + uniq.append(b) + text = "\n".join(uniq) + chunks = [text[k:k + 3500] for k in range(0, len(text), 3500)][:8] + for n, chunk in enumerate(chunks, 1): + esc = chunk.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') + print(f"::error title=Gradle errors {n}/{len(chunks)}::{esc}") + print(f"::warning title=Gradle summary::{len(uniq)} diagnostics in {len(lines)} log lines") PY - name: Upload Gradle log diff --git a/src/main/java/dev/qynl/racers/block/RacerBlocks.java b/src/main/java/dev/qynl/racers/block/RacerBlocks.java index 89fcfa8..d61b4bf 100644 --- a/src/main/java/dev/qynl/racers/block/RacerBlocks.java +++ b/src/main/java/dev/qynl/racers/block/RacerBlocks.java @@ -189,7 +189,7 @@ public static Settings tarmac() { public static Settings barrier() { return Block.Settings.create().strength(2.5F, 12.0F).requiresTool() - .sounds(BlockSoundGroup.IRON); + .sounds(BlockSoundGroup.METAL); } public static Settings decor() { diff --git a/src/main/java/dev/qynl/racers/net/ServerActions.java b/src/main/java/dev/qynl/racers/net/ServerActions.java index a19f051..2f87540 100644 --- a/src/main/java/dev/qynl/racers/net/ServerActions.java +++ b/src/main/java/dev/qynl/racers/net/ServerActions.java @@ -155,10 +155,18 @@ private static void placeTrack(ServerPlayerEntity player, String trackId, int ro manager.placeTrack(player, track, anchor, rotation); } - private static void removeTrack(ServerPlayerEntity player, int x, int y, int z, String text) { + private static void removeTrack(ServerPlayerEntity player, int x, int z, String text) { TrackManager manager = TrackManager.get(player.server); - BlockPos pos = new BlockPos(x, y, z); - TrackPlacement placement = manager.placementAt(pos); + TrackPlacement placement = null; + for (TrackPlacement candidate : manager.placements()) { + if (candidate.anchor.getX() == x && candidate.anchor.getZ() == z) { + placement = candidate; + break; + } + } + if (placement == null) { + placement = manager.placementAt(new BlockPos(x, player.getBlockPos().getY(), z)); + } if (placement == null) { placement = manager.placementNear(player.getPos(), 200.0); } From 22799e1f2e39357466e103bed79416beeda1389c Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:52:27 +0000 Subject: [PATCH 08/24] Fix client build: wheel vertex normals and CarData field access Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../java/dev/qynl/racers/client/render/CarMesh.java | 8 ++++---- .../java/dev/qynl/racers/client/screen/GarageScreen.java | 4 ++-- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/src/client/java/dev/qynl/racers/client/render/CarMesh.java b/src/client/java/dev/qynl/racers/client/render/CarMesh.java index 5004f54..0f93a37 100644 --- a/src/client/java/dev/qynl/racers/client/render/CarMesh.java +++ b/src/client/java/dev/qynl/racers/client/render/CarMesh.java @@ -141,10 +141,10 @@ public static void wheel(VertexConsumer vc, Matrix4f matrix, float x, float y, f float ny = (sy[i] + sy[j]) / (2.0F * radius); float nz = (sz[i] + sz[j]) / (2.0F * radius); // tread band - vertex(vc, matrix, x - half, y + sy[i], z + sz[i], uA, tv1, 1, 1, 1, 1, light, ny, nz); - vertex(vc, matrix, x + half, y + sy[i], z + sz[i], uA, tv0, 1, 1, 1, 1, light, ny, nz); - vertex(vc, matrix, x + half, y + sy[j], z + sz[j], uB, tv0, 1, 1, 1, 1, light, ny, nz); - vertex(vc, matrix, x - half, y + sy[j], z + sz[j], uB, tv1, 1, 1, 1, 1, light, ny, nz); + vertex(vc, matrix, x - half, y + sy[i], z + sz[i], uA, tv1, 1, 1, 1, 1, light, 0.0F, ny, nz); + vertex(vc, matrix, x + half, y + sy[i], z + sz[i], uA, tv0, 1, 1, 1, 1, light, 0.0F, ny, nz); + vertex(vc, matrix, x + half, y + sy[j], z + sz[j], uB, tv0, 1, 1, 1, 1, light, 0.0F, ny, nz); + vertex(vc, matrix, x - half, y + sy[j], z + sz[j], uB, tv1, 1, 1, 1, 1, light, 0.0F, ny, nz); float sideU = 0.5F, sideV = 0.5F; float edgeU = 0.5F + 0.5F * (sz[i] / radius); diff --git a/src/client/java/dev/qynl/racers/client/screen/GarageScreen.java b/src/client/java/dev/qynl/racers/client/screen/GarageScreen.java index ad78803..3bf0bb3 100644 --- a/src/client/java/dev/qynl/racers/client/screen/GarageScreen.java +++ b/src/client/java/dev/qynl/racers/client/screen/GarageScreen.java @@ -53,11 +53,11 @@ protected void init() { addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.paint_primary"), button -> ClientRaceState.send(ModNetworking.REQ_PAINT, - ClientRaceState.garageEntityId, 0, PaintColors.next(data.primaryColor(), 1), "")) + ClientRaceState.garageEntityId, 0, PaintColors.next(data.primaryColor, 1), "")) .dimensions(x, y + 168, 116, 20).build()); addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.paint_secondary"), button -> ClientRaceState.send(ModNetworking.REQ_PAINT, - ClientRaceState.garageEntityId, 1, PaintColors.next(data.secondaryColor(), 1), "")) + ClientRaceState.garageEntityId, 1, PaintColors.next(data.secondaryColor, 1), "")) .dimensions(x + 124, y + 168, 116, 20).build()); addDrawableChild(ButtonWidget.builder(Text.translatable("screen.fabricracers.repair"), button -> ClientRaceState.send(ModNetworking.REQ_REPAIR, From 05ac654dd6e698c729a669994abb2aa058cc60b4 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:53:56 +0000 Subject: [PATCH 09/24] CI: boot a dedicated server with the mod to smoke test registries and data Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index fc9d513..f49bfea 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -84,3 +84,38 @@ jobs: name: fabricracers path: build/libs/*.jar if-no-files-found: error + + # Actually boots a dedicated server with the mod installed: this loads the + # datapack (recipes, tags, structures), the registries and the networking + # wiring, which no amount of static checking can prove. + - name: Server smoke test + run: | + mkdir -p run + echo "eula=true" > run/eula.txt + cat > run/server.properties <<'PROPS' + level-type=minecraft\:flat + online-mode=false + view-distance=4 + simulation-distance=4 + spawn-protection=0 + max-players=2 + motd=FabricRacers smoke test + PROPS + (sleep 150; echo "stop") | timeout 480 ./gradlew runServer --no-daemon --console=plain > server.log 2>&1 || true + echo "=== server.log tail ===" + tail -n 60 server.log + python3 - <<'PY' + import re, pathlib + log = pathlib.Path('server.log').read_text(errors='replace') + ok = re.search(r'Done \([0-9.]+s\)! For help', log) is not None + bad = [l for l in re.findall(r'^.*(?:ERROR|SEVERE|Exception|Caused by:|Failed to|Could not|Unknown recipe).*$', + log, re.M) if 'Ignoring' not in l][:60] + head = "server reached Done" if ok else "SERVER NEVER REACHED 'Done'" + body = (head + "\n" + "\n".join(bad)) + esc = body.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') + if ok: + print(f"::notice title=Server smoke test::{esc}") + else: + print(f"::error title=Server smoke test::{esc}") + PY + grep -aqE "Done \([0-9.]+s\)! For help" server.log From 73cc4d6c53f38e88008ca596dd680707aae8df58 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 07:58:30 +0000 Subject: [PATCH 10/24] CI: exercise blocks, a car entity and the circuit list during the smoke test Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 47 +++++++++++++++++++++++++++---------- 1 file changed, 34 insertions(+), 13 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index f49bfea..622d688 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -87,13 +87,14 @@ jobs: # Actually boots a dedicated server with the mod installed: this loads the # datapack (recipes, tags, structures), the registries and the networking - # wiring, which no amount of static checking can prove. + # wiring, which no amount of static checking can prove. Then it pokes the + # mod through the console: list the stock circuits (reads the generated + # .nbt structures), place modded blocks, spawn and inspect a car entity. - name: Server smoke test run: | mkdir -p run echo "eula=true" > run/eula.txt cat > run/server.properties <<'PROPS' - level-type=minecraft\:flat online-mode=false view-distance=4 simulation-distance=4 @@ -101,21 +102,41 @@ jobs: max-players=2 motd=FabricRacers smoke test PROPS - (sleep 150; echo "stop") | timeout 480 ./gradlew runServer --no-daemon --console=plain > server.log 2>&1 || true + ( + sleep 90 + echo "race tracks" + sleep 2 + echo "setblock 0 5 0 fabricracers:asphalt" + sleep 1 + echo "setblock 2 5 0 fabricracers:boost_pad" + sleep 1 + echo "setblock 4 5 0 fabricracers:checkpoint_marker" + sleep 1 + echo "summon fabricracers:player_car 6 6 0" + sleep 2 + echo "data get entity @e[type=fabricracers:player_car,limit=1]" + sleep 2 + echo "kill @e[type=fabricracers:player_car]" + sleep 2 + echo "stop" + ) | timeout 600 ./gradlew runServer --no-daemon --console=plain > server.log 2>&1 || true echo "=== server.log tail ===" - tail -n 60 server.log + tail -n 80 server.log python3 - <<'PY' import re, pathlib log = pathlib.Path('server.log').read_text(errors='replace') ok = re.search(r'Done \([0-9.]+s\)! For help', log) is not None - bad = [l for l in re.findall(r'^.*(?:ERROR|SEVERE|Exception|Caused by:|Failed to|Could not|Unknown recipe).*$', - log, re.M) if 'Ignoring' not in l][:60] - head = "server reached Done" if ok else "SERVER NEVER REACHED 'Done'" - body = (head + "\n" + "\n".join(bad)) - esc = body.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') - if ok: - print(f"::notice title=Server smoke test::{esc}") - else: - print(f"::error title=Server smoke test::{esc}") + poked = 'fabricracers:asphalt' in log + problems = [l for l in re.findall( + r'^.*(?:ERROR|SEVERE|Exception|Caused by:|Failed to|Unknown or incomplete' + r'|An unexpected error occurred).*$', log, re.M) if 'Ignoring' not in l][:60] + head = ("server reached Done" if ok else "SERVER NEVER REACHED 'Done'") \ + + ("; console commands reached the mod" if poked else "; console commands did NOT run") + esc = (head + "\n" + "\n".join(problems)).replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') + print(f"::{'notice' if ok else 'error'} title=Server smoke test::{esc}") + if not ok: + raise SystemExit(1) PY grep -aqE "Done \([0-9.]+s\)! For help" server.log + ! grep -aq "Unknown or incomplete command" server.log + ! grep -aq "An unexpected error occurred" server.log From f2760278e38d1aae164b4e29dc8be19225c074a7 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:02:03 +0000 Subject: [PATCH 11/24] CI: drive the smoke test server over RCON and assert the mod reacts Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 129 +++++++++++++++++++++++++----------- 1 file changed, 90 insertions(+), 39 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 622d688..ccd81b4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -85,11 +85,10 @@ jobs: path: build/libs/*.jar if-no-files-found: error - # Actually boots a dedicated server with the mod installed: this loads the - # datapack (recipes, tags, structures), the registries and the networking - # wiring, which no amount of static checking can prove. Then it pokes the - # mod through the console: list the stock circuits (reads the generated - # .nbt structures), place modded blocks, spawn and inspect a car entity. + # Actually boots a dedicated server with the mod installed, then drives it + # over RCON: this loads the datapack (recipes, tags, structures) and the + # registries, and proves the blocks, the car entity and the track catalogue + # all work at runtime - none of which static checks can show. - name: Server smoke test run: | mkdir -p run @@ -100,43 +99,95 @@ jobs: simulation-distance=4 spawn-protection=0 max-players=2 + enable-rcon=true + rcon.password=fabricracers + rcon.port=25575 + broadcast-rcon-to-ops=false motd=FabricRacers smoke test PROPS - ( - sleep 90 - echo "race tracks" - sleep 2 - echo "setblock 0 5 0 fabricracers:asphalt" - sleep 1 - echo "setblock 2 5 0 fabricracers:boost_pad" - sleep 1 - echo "setblock 4 5 0 fabricracers:checkpoint_marker" - sleep 1 - echo "summon fabricracers:player_car 6 6 0" - sleep 2 - echo "data get entity @e[type=fabricracers:player_car,limit=1]" - sleep 2 - echo "kill @e[type=fabricracers:player_car]" - sleep 2 - echo "stop" - ) | timeout 600 ./gradlew runServer --no-daemon --console=plain > server.log 2>&1 || true + timeout 480 ./gradlew runServer --no-daemon --console=plain > server.log 2>&1 & + GRADLE_PID=$! + python3 - <<'PY' > rcon_out.txt 2>&1 || true + import socket, struct, time + + def packet(request_id, kind, body): + data = struct.pack(' {command}') + print(reply[1] if reply else '') + sock.sendall(packet(99, 2, 'stop')) + read_packet(sock) + sock.close() + PY + wait $GRADLE_PID || true + echo "=== rcon transcript ===" + cat rcon_out.txt || true echo "=== server.log tail ===" - tail -n 80 server.log + tail -n 40 server.log python3 - <<'PY' - import re, pathlib - log = pathlib.Path('server.log').read_text(errors='replace') - ok = re.search(r'Done \([0-9.]+s\)! For help', log) is not None - poked = 'fabricracers:asphalt' in log - problems = [l for l in re.findall( - r'^.*(?:ERROR|SEVERE|Exception|Caused by:|Failed to|Unknown or incomplete' - r'|An unexpected error occurred).*$', log, re.M) if 'Ignoring' not in l][:60] - head = ("server reached Done" if ok else "SERVER NEVER REACHED 'Done'") \ - + ("; console commands reached the mod" if poked else "; console commands did NOT run") - esc = (head + "\n" + "\n".join(problems)).replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') - print(f"::{'notice' if ok else 'error'} title=Server smoke test::{esc}") - if not ok: + import pathlib + transcript = pathlib.Path('rcon_out.txt').read_text(errors='replace') \ + if pathlib.Path('rcon_out.txt').exists() else '' + log = pathlib.Path('server.log').read_text(errors='replace') if pathlib.Path('server.log').exists() else '' + started = 'Done (' in log + checks = { + 'track catalogue': 'Circuits (' in transcript, + 'block placement': 'Changed the block at' in transcript, + 'car entity spawn': 'Summoned new' in transcript, + 'entity data read': 'has the following entity data' in transcript, + } + failed = [name for name, good in checks.items() if not good] + head = 'server started' if started else "SERVER NEVER REACHED 'Done'" + lines = [f"{'ok ' if good else 'FAIL'} {name}" for name, good in checks.items()] + body = head + '\n' + '\n'.join(lines) + '\n--- transcript ---\n' + transcript[-2500:] + esc = body.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') + print(f"::{'notice' if started and not failed else 'error'} title=Server smoke test::{esc}") + if not started or failed: raise SystemExit(1) PY - grep -aqE "Done \([0-9.]+s\)! For help" server.log - ! grep -aq "Unknown or incomplete command" server.log - ! grep -aq "An unexpected error occurred" server.log From 02184cb0a49db764301c5edab7dd5783fbcd48b6 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:02:35 +0000 Subject: [PATCH 12/24] RaceManager: keep AI grid slots contiguous; README status section Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 21 +++++++++++++++++-- .../dev/qynl/racers/race/RaceManager.java | 2 +- 2 files changed, 20 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 12f5a01..4214aa2 100644 --- a/README.md +++ b/README.md @@ -138,8 +138,10 @@ as many copies of it as you like. ## Building from source ```bash -./gradlew build # also runs the asset validator -python3 tools/gen_textures.py # regenerate every PNG (no art files in git) +./gradlew build # compiles the mod and runs the asset validator +./gradlew runClient # launch the game with the mod +./gradlew runServer # launch a test server +python3 tools/gen_textures.py # regenerate every PNG (all 71 are committed) python3 tools/gen_tracks.py # regenerate the five stock circuits python3 tools/validate_assets.py ``` @@ -159,6 +161,21 @@ The client car model is hand-rolled geometry (`client/render/CarMesh.java`) mapped onto a 64ร—64 atlas per car, so all nine cars share one renderer and one mesh - see the layout table at the top of `tools/gen_textures.py`. +## Status + +Every push is checked by CI: + +1. `tools/validate_assets.py` - JSON, models, textures, structures, translations. +2. `./gradlew build` - compiles both source sets and produces the jar. +3. a **dedicated server is booted with the mod** and then driven over RCON: + the circuit catalogue is listed (which reads the generated `.nbt` + structures), modded blocks are placed, a car entity is summoned, and its + synched entity data is read back. + +So the build, the data pack and the runtime registries are all verified. Live +gameplay balance (lap times, AI difficulty spread, prize economy) is tuned by +simulation and will still benefit from real play-testing. + ## Layout ``` diff --git a/src/main/java/dev/qynl/racers/race/RaceManager.java b/src/main/java/dev/qynl/racers/race/RaceManager.java index a034a5a..d2faba0 100644 --- a/src/main/java/dev/qynl/racers/race/RaceManager.java +++ b/src/main/java/dev/qynl/racers/race/RaceManager.java @@ -123,7 +123,7 @@ public RaceSession start(ServerPlayerEntity host, TrackPlacement placement, Race break; } float yaw = placement.startYaw(); - Vec3d pos = placement.gridPos(cars.size() + spawned); + Vec3d pos = placement.gridPos(cars.size()); ai.teleportCleanly(pos.x, pos.y, pos.z, yaw); cars.add(ai); spawned++; From 80bfc14632d6f233b28a3ac793db70d0c83e7748 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:04:08 +0000 Subject: [PATCH 13/24] CI: load chunks, summon a car from NBT and assert its synched data Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index ccd81b4..7b8d4ae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -149,11 +149,12 @@ jobs: commands = [ 'race tracks', + 'forceload add -16 -16 16 16', 'setblock 0 5 0 fabricracers:asphalt', 'setblock 2 5 0 fabricracers:boost_pad', 'setblock 4 5 0 fabricracers:checkpoint_marker', - 'summon fabricracers:player_car 6 6 0', - 'data get entity @e[type=fabricracers:player_car,limit=1]', + 'summon fabricracers:player_car 6 6 0 {CarData:{type:"f1",levels:[1,1,0,0,0,0,0]}}', + 'data get entity @e[type=fabricracers:player_car,limit=1] CarData', 'kill @e[type=fabricracers:player_car]', ] for index, command in enumerate(commands, start=10): @@ -180,7 +181,8 @@ jobs: 'track catalogue': 'Circuits (' in transcript, 'block placement': 'Changed the block at' in transcript, 'car entity spawn': 'Summoned new' in transcript, - 'entity data read': 'has the following entity data' in transcript, + 'car NBT -> type': 'Circuit Formula' in transcript, + 'entity data read': 'CarData' in transcript and 'f1' in transcript, } failed = [name for name, good in checks.items() if not good] head = 'server started' if started else "SERVER NEVER REACHED 'Done'" From bae6a6cc7214b352fa374bb97e015f8b8f94a23e Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:06:04 +0000 Subject: [PATCH 14/24] CarData: accept list or array tuning levels; smoke test asserts the NBT round trip Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 8 +++--- .../java/dev/qynl/racers/car/CarData.java | 26 ++++++++++++++----- 2 files changed, 25 insertions(+), 9 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7b8d4ae..738292e 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -153,7 +153,7 @@ jobs: 'setblock 0 5 0 fabricracers:asphalt', 'setblock 2 5 0 fabricracers:boost_pad', 'setblock 4 5 0 fabricracers:checkpoint_marker', - 'summon fabricracers:player_car 6 6 0 {CarData:{type:"f1",levels:[1,1,0,0,0,0,0]}}', + 'summon fabricracers:player_car 6 6 0 {CarData:{type:"f1",levels:[I;1,1,0,0,0,0,0]}}', 'data get entity @e[type=fabricracers:player_car,limit=1] CarData', 'kill @e[type=fabricracers:player_car]', ] @@ -177,12 +177,14 @@ jobs: if pathlib.Path('rcon_out.txt').exists() else '' log = pathlib.Path('server.log').read_text(errors='replace') if pathlib.Path('server.log').exists() else '' started = 'Done (' in log + summon_line = next((l for l in transcript.splitlines() if l.startswith('Summoned new')), '') checks = { 'track catalogue': 'Circuits (' in transcript, 'block placement': 'Changed the block at' in transcript, 'car entity spawn': 'Summoned new' in transcript, - 'car NBT -> type': 'Circuit Formula' in transcript, - 'entity data read': 'CarData' in transcript and 'f1' in transcript, + 'car NBT applied on spawn': bool(summon_line) and 'Sport' not in summon_line, + 'entity data read': 'CarData' in transcript and 'type: "f1"' in transcript, + 'tuning levels round-trip': 'levels: [I; 1, 1' in transcript, } failed = [name for name, good in checks.items() if not good] head = 'server started' if started else "SERVER NEVER REACHED 'Done'" diff --git a/src/main/java/dev/qynl/racers/car/CarData.java b/src/main/java/dev/qynl/racers/car/CarData.java index 08c2fb3..a83a9e1 100644 --- a/src/main/java/dev/qynl/racers/car/CarData.java +++ b/src/main/java/dev/qynl/racers/car/CarData.java @@ -5,6 +5,8 @@ import net.minecraft.entity.player.PlayerEntity; import net.minecraft.item.ItemStack; import net.minecraft.nbt.NbtCompound; +import net.minecraft.nbt.NbtElement; +import net.minecraft.nbt.NbtList; import net.minecraft.text.Text; import net.minecraft.util.Formatting; @@ -101,14 +103,26 @@ public NbtCompound toNbt() { return nbt; } + /** + * Tuning levels are written as an int array, but hand written NBT (or SNBT + * from a command) usually uses a plain int list - accept both. + */ + private static int[] readLevels(NbtCompound nbt) { + int[] levels = new int[Tune.values().length]; + int[] packed = nbt.getIntArray("levels"); + for (int i = 0; i < Math.min(packed.length, levels.length); i++) { + levels[i] = packed[i]; + } + NbtList list = nbt.getList("levels", NbtElement.INT_TYPE); + for (int i = 0; i < Math.min(list.size(), levels.length); i++) { + levels[i] = list.getInt(i); + } + return levels; + } + public static CarData fromNbt(NbtCompound nbt) { CarType type = CarType.byId(nbt.getString("type")); - int[] levels = nbt.getIntArray("levels"); - if (levels.length != Tune.values().length) { - int[] fixed = new int[Tune.values().length]; - System.arraycopy(levels, 0, fixed, 0, Math.min(levels.length, fixed.length)); - levels = fixed; - } + int[] levels = readLevels(nbt); UUID owner = null; String raw = nbt.getString("owner"); if (!raw.isEmpty()) { From 6c4eba19523d533205b3676ca63e40c78db10645 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:08:30 +0000 Subject: [PATCH 15/24] CI: check villager and trader offers include the mod's parts Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 738292e..4506cab 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -156,6 +156,12 @@ jobs: 'summon fabricracers:player_car 6 6 0 {CarData:{type:"f1",levels:[I;1,1,0,0,0,0,0]}}', 'data get entity @e[type=fabricracers:player_car,limit=1] CarData', 'kill @e[type=fabricracers:player_car]', + 'summon minecraft:villager 8 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:3}}', + 'data get entity @e[type=minecraft:villager,limit=1] Offers', + 'kill @e[type=minecraft:villager]', + 'summon minecraft:wandering_trader 10 5 0', + 'data get entity @e[type=minecraft:wandering_trader,limit=1] Offers', + 'kill @e[type=minecraft:wandering_trader]', ] for index, command in enumerate(commands, start=10): sock.sendall(packet(index, 2, command)) @@ -185,6 +191,7 @@ jobs: 'car NBT applied on spawn': bool(summon_line) and 'Sport' not in summon_line, 'entity data read': 'CarData' in transcript and 'type: "f1"' in transcript, 'tuning levels round-trip': 'levels: [I; 1, 1' in transcript, + 'toolsmith sells parts': 'fabricracers:' in transcript and 'Offers' in transcript, } failed = [name for name, good in checks.items() if not good] head = 'server started' if started else "SERVER NEVER REACHED 'Done'" From 32fd87215d0e17a1384a5fdec3e4dc7594ec940e Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:11:24 +0000 Subject: [PATCH 16/24] Smoke test: dump villager offers for toolsmith levels 1 and 3 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 7 +++++-- src/main/java/dev/qynl/racers/shop/PartShop.java | 3 +++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 4506cab..65cd532 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -156,6 +156,9 @@ jobs: 'summon fabricracers:player_car 6 6 0 {CarData:{type:"f1",levels:[I;1,1,0,0,0,0,0]}}', 'data get entity @e[type=fabricracers:player_car,limit=1] CarData', 'kill @e[type=fabricracers:player_car]', + 'summon minecraft:villager 8 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:1}}', + 'data get entity @e[type=minecraft:villager,limit=1] Offers', + 'kill @e[type=minecraft:villager]', 'summon minecraft:villager 8 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:3}}', 'data get entity @e[type=minecraft:villager,limit=1] Offers', 'kill @e[type=minecraft:villager]', @@ -191,12 +194,12 @@ jobs: 'car NBT applied on spawn': bool(summon_line) and 'Sport' not in summon_line, 'entity data read': 'CarData' in transcript and 'type: "f1"' in transcript, 'tuning levels round-trip': 'levels: [I; 1, 1' in transcript, - 'toolsmith sells parts': 'fabricracers:' in transcript and 'Offers' in transcript, + 'toolsmith sells parts': 'fabricracers:engine_turbo' in transcript, } failed = [name for name, good in checks.items() if not good] head = 'server started' if started else "SERVER NEVER REACHED 'Done'" lines = [f"{'ok ' if good else 'FAIL'} {name}" for name, good in checks.items()] - body = head + '\n' + '\n'.join(lines) + '\n--- transcript ---\n' + transcript[-2500:] + body = head + '\n' + '\n'.join(lines) + '\n--- transcript ---\n' + transcript[-7000:] esc = body.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') print(f"::{'notice' if started and not failed else 'error'} title=Server smoke test::{esc}") if not started or failed: diff --git a/src/main/java/dev/qynl/racers/shop/PartShop.java b/src/main/java/dev/qynl/racers/shop/PartShop.java index a633dfc..7cd6b6b 100644 --- a/src/main/java/dev/qynl/racers/shop/PartShop.java +++ b/src/main/java/dev/qynl/racers/shop/PartShop.java @@ -27,6 +27,7 @@ private PartShop() { } public static void init() { + dev.qynl.racers.FabricRacers.LOGGER.info("Registering racing villager trades"); // --- tier 1: starter parts and building blocks ------------------- TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 1, factories -> { factories.add(buy(ModItems.partItem(Tune.TIRES), 2, 9, 8)); @@ -66,6 +67,8 @@ public static void init() { factories.add(buy(ModItems.BLOCK_BOOST_PAD_ITEM, 4, 22, 5)); factories.add(PartShop::carOffer); }); + dev.qynl.racers.FabricRacers.LOGGER.info("Racing trades hooked: {} part systems, {} stock cars", + Tune.values().length, CarType.stock().size()); } /** A plain "give emeralds, get item" trade. */ From 58bfbbed4e7de785d23785034d8ba06ba787bb73 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:11:57 +0000 Subject: [PATCH 17/24] Smoke test: roll trades across six villagers before asserting Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 20 +++++++++++++------- 1 file changed, 13 insertions(+), 7 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 65cd532..7779c71 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -157,13 +157,15 @@ jobs: 'data get entity @e[type=fabricracers:player_car,limit=1] CarData', 'kill @e[type=fabricracers:player_car]', 'summon minecraft:villager 8 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:1}}', - 'data get entity @e[type=minecraft:villager,limit=1] Offers', + 'summon minecraft:villager 9 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:2}}', + 'summon minecraft:villager 10 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:3}}', + 'summon minecraft:villager 11 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:weaponsmith",level:3}}', + 'summon minecraft:villager 12 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:mason",level:1}}', + 'summon minecraft:villager 13 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:cleric",level:2}}', + 'execute as @e[type=minecraft:villager] run data get entity @s Offers', 'kill @e[type=minecraft:villager]', - 'summon minecraft:villager 8 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:3}}', - 'data get entity @e[type=minecraft:villager,limit=1] Offers', - 'kill @e[type=minecraft:villager]', - 'summon minecraft:wandering_trader 10 5 0', - 'data get entity @e[type=minecraft:wandering_trader,limit=1] Offers', + 'summon minecraft:wandering_trader 14 5 0', + 'execute as @e[type=minecraft:wandering_trader] run data get entity @s Offers', 'kill @e[type=minecraft:wandering_trader]', ] for index, command in enumerate(commands, start=10): @@ -194,7 +196,11 @@ jobs: 'car NBT applied on spawn': bool(summon_line) and 'Sport' not in summon_line, 'entity data read': 'CarData' in transcript and 'type: "f1"' in transcript, 'tuning levels round-trip': 'levels: [I; 1, 1' in transcript, - 'toolsmith sells parts': 'fabricracers:engine_turbo' in transcript, + # villagers roll 2 trades per level out of a mixed pool, so check + # that any of the six rolled at least one of the mod's trades + 'villagers sell mod parts': 'fabricracers:' in transcript.split('Offers')[-1] + or 'fabricracers:tires_street' in transcript + or 'fabricracers:aero_wing' in transcript, } failed = [name for name, good in checks.items() if not good] head = 'server started' if started else "SERVER NEVER REACHED 'Done'" From 0b55b41855ff609c20101131678c6d3d4cd25cc1 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:41:45 +0000 Subject: [PATCH 18/24] Polish: drop dead event callbacks, guard tab completion, keep parked cars on a full inventory Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- job.html | 1264 ----------------- .../dev/qynl/racers/command/RaceCommands.java | 24 +- .../qynl/racers/event/CarServerEvents.java | 21 - .../java/dev/qynl/racers/item/ToolItems.java | 5 +- .../assets/fabricracers/lang/en_us.json | 2 +- 5 files changed, 20 insertions(+), 1296 deletions(-) delete mode 100644 job.html diff --git a/job.html b/job.html deleted file mode 100644 index 483d463..0000000 --- a/job.html +++ /dev/null @@ -1,1264 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - CI: stream gradle log to an artifact on failure ยท Qynl/MyFirstMod@9e33e2f ยท GitHub - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- - -
- Skip to content - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - -
-
- - - - - - - - - - - - - - - - - - - -
-
- - - - -
- -
- - - - - - - - -
- - - - - - - -
- - - - - - - - - -
-
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - - - - -
- - -
-
-
- - - - -
-
- - -
- -
- -
- - -
- -
- -
-
-

- - CI: stream gradle log to an artifact on failure - - #252 - -

-
- - - -
- -
-
-
-
- - - -
-
-
-

- CI: stream gradle log to an artifact on failure -

-

- -
- -
- - - CI: stream gradle log to an artifact on failure #252 - -

- -
-
- -
-
- -
- -
- - - -
-
- -
-
-
- - -
-
-
- - - - - - - - - - - - -
-
- - - - -
-
-
- - -
-
- -
-
-
-
- - -
-
-

Annotations

-
1 error, 2 warnings, and 1 notice
-
-
-
-
- -
-
-
-
- -
- - -
- -
- -
-
- -
-
-
-

- - build - -

-
-
-
-
- - -
- - failed - Oct 11, 2026 in 59s - - -
-
- - - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
-
- -
-
- - - -
-
-
-
- - -
- - -
- -
- - - -
- - -
-
- -
- - - - - - - - - - - - - - - - - - - -
-
-
- - - diff --git a/src/main/java/dev/qynl/racers/command/RaceCommands.java b/src/main/java/dev/qynl/racers/command/RaceCommands.java index 125a3ea..bb3e373 100644 --- a/src/main/java/dev/qynl/racers/command/RaceCommands.java +++ b/src/main/java/dev/qynl/racers/command/RaceCommands.java @@ -10,6 +10,8 @@ import net.minecraft.command.CommandSource; import net.minecraft.command.argument.EntityArgumentType; import net.minecraft.server.command.CommandManager; +import net.minecraft.item.ItemStack; +import net.minecraft.server.MinecraftServer; import net.minecraft.server.command.ServerCommandSource; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.text.Text; @@ -38,10 +40,15 @@ public final class RaceCommands { private RaceCommands() { } - private static final SuggestionProvider TRACKS = (context, builder) -> - CommandSource.suggestMatching( - TrackManager.get(context.getSource().getServer()).all().stream() - .map(track -> track.id).toList(), builder); + private static final SuggestionProvider TRACKS = (context, builder) -> { + MinecraftServer server = context.getSource().getServer(); + if (server == null) { + // tab completion can run without a server behind the source + return builder.buildFuture(); + } + return CommandSource.suggestMatching( + TrackManager.get(server).all().stream().map(track -> track.id).toList(), builder); + }; private static final SuggestionProvider CAR_TYPES = (context, builder) -> CommandSource.suggestMatching( @@ -221,13 +228,16 @@ private static int park(ServerCommandSource source) { return 0; } player.stopRiding(); - player.getInventory().insertStack(parkStack(car)); + ItemStack parked = parkStack(car); + if (!player.getInventory().insertStack(parked)) { + player.dropItem(parked, false); + } car.discard(); return 1; } - private static net.minecraft.item.ItemStack parkStack(CarEntity car) { - var stack = new net.minecraft.item.ItemStack(dev.qynl.racers.registry.ModItems.carItem(car.carType())); + private static ItemStack parkStack(CarEntity car) { + ItemStack stack = new ItemStack(dev.qynl.racers.registry.ModItems.carItem(car.carType())); CarData.apply(stack, car.data().withHealth(car.engineHealth(), car.tyreHealth())); return stack; } diff --git a/src/main/java/dev/qynl/racers/event/CarServerEvents.java b/src/main/java/dev/qynl/racers/event/CarServerEvents.java index 63d3e8e..84047df 100644 --- a/src/main/java/dev/qynl/racers/event/CarServerEvents.java +++ b/src/main/java/dev/qynl/racers/event/CarServerEvents.java @@ -2,12 +2,9 @@ import net.fabricmc.fabric.api.entity.event.v1.ServerEntityWorldChangeEvents; import net.fabricmc.fabric.api.event.lifecycle.v1.ServerTickEvents; -import net.fabricmc.fabric.api.event.player.AttackEntityCallback; -import net.fabricmc.fabric.api.event.player.UseEntityCallback; import net.minecraft.entity.player.PlayerEntity; import net.minecraft.server.network.ServerPlayerEntity; import net.minecraft.text.Text; -import net.minecraft.util.ActionResult; import net.minecraft.util.Formatting; import dev.qynl.racers.car.CarEntity; @@ -24,24 +21,6 @@ private CarServerEvents() { } public static void init() { - // Prevent friendly fire on cars: a punch nudges the car instead of hurting the driver - AttackEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { - if (entity instanceof CarEntity) { - return ActionResult.PASS; - } - return ActionResult.PASS; - }); - - UseEntityCallback.EVENT.register((player, world, hand, entity, hitResult) -> { - if (world.isClient || !(entity instanceof CarEntity car)) { - return ActionResult.PASS; - } - if (player.isSneaking()) { - return ActionResult.PASS; - } - return ActionResult.PASS; - }); - ServerEntityWorldChangeEvents.AFTER_PLAYER_CHANGE_WORLD.register((player, origin, destination) -> { var session = RaceManager.get(player.server).sessionFor(player); if (session != null) { diff --git a/src/main/java/dev/qynl/racers/item/ToolItems.java b/src/main/java/dev/qynl/racers/item/ToolItems.java index ddf8e98..10ce44c 100644 --- a/src/main/java/dev/qynl/racers/item/ToolItems.java +++ b/src/main/java/dev/qynl/racers/item/ToolItems.java @@ -241,9 +241,8 @@ private ActionResult use(World world, PlayerEntity user, Hand hand, BlockPos pos return ActionResult.PASS; } TrackManager manager = TrackManager.get(player.server); - if (!player.isCreativeLevelTwoOp() && !manager.placements().isEmpty() - && player.getVehicle() == null) { - // anyone may build in creative, otherwise it is an op tool + if (!player.isCreative() && !player.isCreativeLevelTwoOp()) { + // anyone may build tracks in creative, otherwise it is an op tool player.sendMessage(Text.translatable("message.fabricracers.wand_needs_op") .formatted(Formatting.RED), true); return ActionResult.FAIL; diff --git a/src/main/resources/assets/fabricracers/lang/en_us.json b/src/main/resources/assets/fabricracers/lang/en_us.json index 2ea40f9..f9a974d 100644 --- a/src/main/resources/assets/fabricracers/lang/en_us.json +++ b/src/main/resources/assets/fabricracers/lang/en_us.json @@ -94,7 +94,7 @@ "message.fabricracers.no_room": "Your inventory is full.", "message.fabricracers.repaired": "Car repaired.", "message.fabricracers.recovered": "That could have been a long walk - car recovered.", - "message.fabricracers.wand_needs_op": "Only operators may place circuits here.", + "message.fabricracers.wand_needs_op": "Only operators or creative players may place circuits here.", "message.fabricracers.track_placed": "Built %s (%s blocks, footprint %s).", "message.fabricracers.track_placed_tip": "Sit in a car on the start line and run /race start, or press T.", "message.fabricracers.track_removed": "Removed %s (%s blocks cleared).", From 47dd22f2cb6d2033b7d23387bf126d17076fa8d9 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:44:20 +0000 Subject: [PATCH 19/24] Smoke test: accept the mod in any trade roll instead of two fixed ids Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 25 +++++++++++++++---------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7779c71..396373f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -159,12 +159,16 @@ jobs: 'summon minecraft:villager 8 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:1}}', 'summon minecraft:villager 9 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:2}}', 'summon minecraft:villager 10 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:3}}', - 'summon minecraft:villager 11 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:weaponsmith",level:3}}', - 'summon minecraft:villager 12 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:mason",level:1}}', - 'summon minecraft:villager 13 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:cleric",level:2}}', + 'summon minecraft:villager 11 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:1}}', + 'summon minecraft:villager 12 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:2}}', + 'summon minecraft:villager 13 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:3}}', + 'summon minecraft:villager 14 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:weaponsmith",level:3}}', + 'summon minecraft:villager 15 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:weaponsmith",level:2}}', + 'summon minecraft:villager 16 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:mason",level:1}}', + 'summon minecraft:villager 17 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:cleric",level:2}}', 'execute as @e[type=minecraft:villager] run data get entity @s Offers', 'kill @e[type=minecraft:villager]', - 'summon minecraft:wandering_trader 14 5 0', + 'summon minecraft:wandering_trader 18 5 0', 'execute as @e[type=minecraft:wandering_trader] run data get entity @s Offers', 'kill @e[type=minecraft:wandering_trader]', ] @@ -183,11 +187,12 @@ jobs: echo "=== server.log tail ===" tail -n 40 server.log python3 - <<'PY' - import pathlib + import pathlib, re transcript = pathlib.Path('rcon_out.txt').read_text(errors='replace') \ if pathlib.Path('rcon_out.txt').exists() else '' log = pathlib.Path('server.log').read_text(errors='replace') if pathlib.Path('server.log').exists() else '' started = 'Done (' in log + trade = re.search(r'sell: \{[^}]*"id": "(fabricracers:[a-z_]+)"', transcript) summon_line = next((l for l in transcript.splitlines() if l.startswith('Summoned new')), '') checks = { 'track catalogue': 'Circuits (' in transcript, @@ -196,15 +201,15 @@ jobs: 'car NBT applied on spawn': bool(summon_line) and 'Sport' not in summon_line, 'entity data read': 'CarData' in transcript and 'type: "f1"' in transcript, 'tuning levels round-trip': 'levels: [I; 1, 1' in transcript, - # villagers roll 2 trades per level out of a mixed pool, so check - # that any of the six rolled at least one of the mod's trades - 'villagers sell mod parts': 'fabricracers:' in transcript.split('Offers')[-1] - or 'fabricracers:tires_street' in transcript - or 'fabricracers:aero_wing' in transcript, + # trades are rolled randomly from a mixed pool, so accept the mod + # turning up in any sell slot anywhere in the transcript + 'villagers sell mod parts': trade is not None, } failed = [name for name, good in checks.items() if not good] head = 'server started' if started else "SERVER NEVER REACHED 'Done'" lines = [f"{'ok ' if good else 'FAIL'} {name}" for name, good in checks.items()] + if trade: + lines.append(' rolled trade: ' + trade.group(1)) body = head + '\n' + '\n'.join(lines) + '\n--- transcript ---\n' + transcript[-7000:] esc = body.replace('%', '%25').replace('\r', '%0D').replace('\n', '%0A') print(f"::{'notice' if started and not failed else 'error'} title=Server smoke test::{esc}") From 1dc152828c23cd55233ad70aec6ef65bfa2414e8 Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:46:46 +0000 Subject: [PATCH 20/24] Smoke test: build a floor under the test rig and let the car settle Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 396373f..7edb3e1 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -150,10 +150,13 @@ jobs: commands = [ 'race tracks', 'forceload add -16 -16 16 16', + # the world seed changes every run, so build a known floor first + 'fill -8 4 -8 20 4 8 minecraft:stone', + 'fill -8 5 -8 20 5 8 minecraft:air', 'setblock 0 5 0 fabricracers:asphalt', 'setblock 2 5 0 fabricracers:boost_pad', 'setblock 4 5 0 fabricracers:checkpoint_marker', - 'summon fabricracers:player_car 6 6 0 {CarData:{type:"f1",levels:[I;1,1,0,0,0,0,0]}}', + 'summon fabricracers:player_car 6 5 0 {CarData:{type:"f1",levels:[I;1,1,0,0,0,0,0]}}', 'data get entity @e[type=fabricracers:player_car,limit=1] CarData', 'kill @e[type=fabricracers:player_car]', 'summon minecraft:villager 8 5 0 {VillagerData:{type:"minecraft:plains",profession:"minecraft:toolsmith",level:1}}', @@ -173,6 +176,8 @@ jobs: 'kill @e[type=minecraft:wandering_trader]', ] for index, command in enumerate(commands, start=10): + if 'data get' in command: + time.sleep(1.0) # let the entity settle before reading it back sock.sendall(packet(index, 2, command)) reply = read_packet(sock) print(f'> {command}') From 8fa349b2d8d80bda15d7fcd4aa51f926e5be923c Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:49:42 +0000 Subject: [PATCH 21/24] Smoke test: match mod trades with a quote-free regex Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/build.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 7edb3e1..4b30c44 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -197,7 +197,7 @@ jobs: if pathlib.Path('rcon_out.txt').exists() else '' log = pathlib.Path('server.log').read_text(errors='replace') if pathlib.Path('server.log').exists() else '' started = 'Done (' in log - trade = re.search(r'sell: \{[^}]*"id": "(fabricracers:[a-z_]+)"', transcript) + trade = re.search('sell.{0,40}fabricracers:([a-z_]+)', transcript) summon_line = next((l for l in transcript.splitlines() if l.startswith('Summoned new')), '') checks = { 'track catalogue': 'Circuits (' in transcript, From a17ef96ee94541691c2695187f9be0511ba85f0b Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:52:10 +0000 Subject: [PATCH 22/24] Docs: show the five stock circuits in the README and note the trade smoke test Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 4214aa2..701819b 100644 --- a/README.md +++ b/README.md @@ -44,9 +44,17 @@ you watch - handy for testing a layout. * **Five stock circuits** ship with the mod, generated by a real track builder (`tools/gen_tracks.py`): Asphalt Park, Kart Circuit, Speed Oval, Chaos Junction and a Drift Arena. -* The **Track Wand** (`/race wand`) opens the catalogue, lets you rotate the - layout by 90ยฐ, and builds it where you are standing - complete with kerbs, - barriers, grandstands, an animated start gate, checkpoints and a pit lane. +* The **Track Wand** (`/race wand`, creative mode or operator rights) opens the + catalogue, lets you rotate the layout by 90ยฐ, and builds it where you are + standing - complete with kerbs, barriers, grandstands, an animated start + gate, checkpoints and a pit lane. + +| | | | +| --- | --- | --- | +| ![Asphalt Park](tools/previews/asphalt_park.png) | ![Kart Circuit](tools/previews/kart_circuit.png) | ![Bonneville Oval](tools/previews/speed_oval.png) | +| Asphalt Park | Kart Circuit | Bonneville Oval | +| ![Chaos Junction](tools/previews/chaos_junction.png) | ![Drift Arena](tools/previews/drift_arena.png) | | +| Chaos Junction | Drift Arena | | * **Record your own**: `/race record`, drive a lap, then `/race record` again. The mod bakes the lap you drove into a real circuit - kerbs, barriers, grid and checkpoints included - and adds it to the catalogue. @@ -169,8 +177,9 @@ Every push is checked by CI: 2. `./gradlew build` - compiles both source sets and produces the jar. 3. a **dedicated server is booted with the mod** and then driven over RCON: the circuit catalogue is listed (which reads the generated `.nbt` - structures), modded blocks are placed, a car entity is summoned, and its - synched entity data is read back. + structures), modded blocks are placed, a car entity is summoned, its synched + entity data is read back, and a crowd of villagers is checked to make sure + the mod's parts and cars really do appear in their trade offers. So the build, the data pack and the runtime registries are all verified. Live gameplay balance (lap times, AI difficulty spread, prize economy) is tuned by From 4055fbda4796822b72da351a2b58d6e125b7fd3d Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:52:56 +0000 Subject: [PATCH 23/24] Tools: deterministic structure files, ignore bytecode, higher-res circuit previews Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .gitignore | 4 ++++ .../structure/tracks/asphalt_park.nbt | Bin 37054 -> 37054 bytes .../structure/tracks/chaos_junction.nbt | Bin 29458 -> 29458 bytes .../structure/tracks/drift_arena.nbt | Bin 15702 -> 15702 bytes .../structure/tracks/kart_circuit.nbt | Bin 12930 -> 12930 bytes .../structure/tracks/speed_oval.nbt | Bin 42178 -> 42178 bytes tools/__pycache__/track_lib.cpython-311.pyc | Bin 52420 -> 0 bytes tools/gen_tracks.py | 2 +- tools/previews/asphalt_park.png | Bin 2681 -> 5049 bytes tools/previews/chaos_junction.png | Bin 2346 -> 4099 bytes tools/previews/drift_arena.png | Bin 825 -> 1525 bytes tools/previews/kart_circuit.png | Bin 1015 -> 1545 bytes tools/previews/speed_oval.png | Bin 2855 -> 5349 bytes tools/track_lib.py | 5 +++-- 14 files changed, 8 insertions(+), 3 deletions(-) delete mode 100644 tools/__pycache__/track_lib.cpython-311.pyc diff --git a/.gitignore b/.gitignore index d9f1a9f..256f281 100644 --- a/.gitignore +++ b/.gitignore @@ -22,6 +22,10 @@ bin/ .DS_Store Thumbs.db +# Python bytecode +__pycache__/ +*.pyc + # Local scratch /tmp/ *.log diff --git a/src/main/resources/data/fabricracers/structure/tracks/asphalt_park.nbt b/src/main/resources/data/fabricracers/structure/tracks/asphalt_park.nbt index 22ef589dad9c96dc6fc7dd0333885c2f162d9271..0df0e73179c07052134ac9a0363750ddcb1ea095 100644 GIT binary patch delta 19 ZcmdnDkZIpSCJy;-4h{w&*vPSM0suTw1%vr?WP4a1{dpObQ1Y diff --git a/src/main/resources/data/fabricracers/structure/tracks/drift_arena.nbt b/src/main/resources/data/fabricracers/structure/tracks/drift_arena.nbt index 706bac58a5cbaed677f408b4d6ff024648e5978c..094da8e86cfaf62a5ef325a102b12fe716316e65 100644 GIT binary patch delta 17 Xcmcasb*+j+zMF%C0SGp7_}KygH9rKW delta 17 Ycmcasb*+j+zMF&Nkj3e&jU0Zq07I(=)&Kwi diff --git a/src/main/resources/data/fabricracers/structure/tracks/kart_circuit.nbt b/src/main/resources/data/fabricracers/structure/tracks/kart_circuit.nbt index 3e5f19c0272e558906b3ee989792021211180159..94d00a8628f8ddb965bc4c69698344ea57ef37a6 100644 GIT binary patch delta 17 WcmZolZA#^k@8;lO0D_GiRYm|PmIL+x delta 17 YcmZolZA#^k@8;k*XmL7gBS)1H05?PN1@r&_ delta 19 bcmX?flIhS%CJy;-4vs?>r?WP4>{l#9 diff --git a/tools/__pycache__/track_lib.cpython-311.pyc b/tools/__pycache__/track_lib.cpython-311.pyc deleted file mode 100644 index 8c5a49ed7101814f82a906a22f585f5279b0cf3d..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 52420 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 Z@t}2&FAdcmiqm`-!l0N7dW&QNdjfKLuMGeI diff --git a/tools/gen_tracks.py b/tools/gen_tracks.py index e90401e..da4fb54 100644 --- a/tools/gen_tracks.py +++ b/tools/gen_tracks.py @@ -377,7 +377,7 @@ def write_index(designs): path = os.path.join(TRACK_DIR if "TRACK_DIR" in globals() else os.path.join("src", "main", "resources", "data", "fabricracers", "tracks"), "_index.json") - payload = {"tracks": [design.key for design in designs]} + payload = {"tracks": sorted(design.key for design in designs)} with open(path, "w") as fh: json.dump(payload, fh, indent=2) fh.write("\n") diff --git a/tools/previews/asphalt_park.png b/tools/previews/asphalt_park.png index 7d8be931d4e519e47d751714e6b4c95cd692f8e3..d70601c404a786336822545b7321613f493e226f 100644 GIT binary patch literal 5049 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
    UgC( zF0XG0TIp2^a3PYV}`J>TC~4)eAvQ1MH`S`mo^ zu!6Cm#VB)_OC0GyvdBA|Z{X(9Y-ZYKziIGOrQ4OkwyoCHL0Kyf+M!~^j~tl&W^im_ zVML($So1*pkxWWV@3LmwwXb^goNMY^OxE$aKsz+BR}i*$|#lbLUwM-~vd8e$#HD2UrjI(PoxGb=p|c(JqsLf~xO! z>ZJJGkIPf+cI|ZD=pOZh&&LHXKB3}&Vt~j{W(?6BXB2f54N)f|TC-h_nFt6(F|(qn zJ!)~l&0kLVP{Ay)zs#@%Zx7H-0@59pAVdTdH1*&|Oc|}?@$@J=Gu=LnkAuaAI0eG! z^TcDo5(eTArAcftK(yFhfS4LhzG0$eO!Hk~5H~mLB7f<=wgWN`WO^Cy2AB`DgCH1~ ztgvQ~8z|d{J6d~Lokl-%MOhIQA*nE9fD3^|dd{v61Rq0vSrl)XnA-GcAUbXE zookdrDeLfzHVXPioermB#f8i#1&9b-2(auDNR&`a)qS4)2kY7Mm)VDGdsEWMA0kt) zm#=kl_z#q$b0u)Wo-S2@lXb27s^aDQUAxo9%goX;JnFT1TXNQhr|+4+bo~r!JRyLZ zj)?)6{tQBgK<`ND{rn)cYm4sYhomPrsY@oRj0W(`Z{axRO)jcyTw}V11VGZ~x;ii~s-t literal 2681 zcmZ8jc|25m8>f3PGnP&fO0I(}_m;P$#f`yCvP6vDHp|7U$xMW)c{TQQn7qA~B4SF+ zC%tL8mdesH^U>TE$}-%{*wW~#ree(CQi%JWGsN)EoO9;)Jm2U0e4pi{j)ZY=4p{~t>kfI%HVg@D1qa32vgqvGI| z$Rifsp~Gt+{uDGiT)X{`5hpBa0H#8yC6PA5kxEOqiv~sBf7}1;_@i%YSoBH^vY!pAXpZgk7T1QR$w%^!WCUgsF+f5+PskW&PXr zr}*#dDM^lHr#86|u(2I)V?xs@N@+bE`VE{WS>4wtN{9DW67P9cKjl%i?+gY6-aJ2@8JsB;vRJq z&u+L|_omL^O^cqO>A*C{EWz$ui;eIQIbL%C8uj7zgU9e3HpSob`LfL90&6uF<=H*<8A_c|Z~cyGOMuKwL(RYqEAv%sM?bz}cPenpoiuiGeU3ArAVPMf-Ll{?XGRSp_5 zEXuswUwx@f$%upE_(q)l;$L0L)t7kf1Pfy+ZV#eOPoySB)Cm6Ddn29PUU!@0!Y!@- z{5$|x8;3>)&%LB!ZX3+3D*LEk`IYafau1TJLB~+?Vg-y15FUIAfQEKLm1vs zOeo(uEQ-cwwoS$t#6Ewml}UW}S24G`C}K;QY**;}MN&b)WpFSyIc~fw%x*BF&H%@t zw#Qzo_WhT{5m(Y@V>H*SAPhr5sj+RCmi{lFx#G;KUCwqo%E#0Gc5(udR!8DvVK5*m z-|cyg{?Yw56<2&ZRshD9D5+e8Q{!th6G?L4&tOFUX*PgJPD0_D0n?tK&0y+vFJKJUnf2gg>svP2VSgBZ8RymESn z8jzsI^HX;Jn`UFoj)UX6K>ekG+zEMO%!wOA2N>~B!en{+V{3)yPnSqA5C7<256zZm zRw=JX@#M6wVvD4stKgajL>vXRkr4^I==1w_9M3S6`5t~WDg-1cY%4?oq;9Av$kbJB zuQ=1z)Ftu+!`VAYOA+!3mEz38v%wMDI(D48sjN5QYd`F<)TrLr_Hz@Dg_N3>Ev2`A z(I7OxKB>z}5k+`uDlDMLk?UFAIMU_eT`eO9Ths6Kksanp%&H+%3uRV^QC%w2A5(ZQ zE{L1ZD$jQr7IN7XRwfoT2a-eK69vl@4r5{g*S7_|T0J3@Ruji$ER^@A*r z0qg0SXO{8io%F|Piv@__ho?6M#1-wM_-D8v9dhm@wb>K#pWT1R7;K^3v2xKOC9|0- zC>>p0besAes(JP?*Ulv_R2Vut2eW#znMYc#s`G(x(!vf!FsdpG+paUit84OJni?St zzo$jObf1IAZM55&(v^42QRc0eW&!JshNjB3s$bt;Bt!Yt4m@d3j z{qA3&z-GC4b)DR);O(Q4=TA)k1yP%kF@)J$e~=qivGv~I_Xg^BL7@z@hPcYhvCc^m z2dG-85Z~JrxuuOx1e3G0yY|veE|uy`;5%jtngeXI9dGo>xuCXaMz(K zVyll0#%U3#(}$0)Uxk)_0grdOo=p+Cuk2E7qh~wcN{l2=J!`3+$*|){i$W9)(ZCn% z+)YN=KJ?;2i}OdK20jr=#q`E=(rrD>F(GTeB6=*~5-?sx%R`$o{LYc;=p{u@Yv@0| zP+D|Or0oRnQM^HpSW0Q1$SFq}TVa;c9D?J;>d^v?q1`sGgejXIt1WhxL14H#_l(u8OcwIt@n_z2)RBbgD=UuS(}s61=OCMRkp@q_)-r6DB~cO% z8h4JqbL^CloPtfiOf8jfe{1Hcg-kFcq)vC;GjfxzneQ5T7uWCG;wt#zW0Qp-b28SI z%P7Y#N{41&ZYb+j-rPw*HYox<*H%tZ1zY7K4x?+9K$0=@A>@dJt`y59-*lv$Z66)4 XWSe_rjDXF^C#916E-&YDVle!FzqAj_ diff --git a/tools/previews/chaos_junction.png b/tools/previews/chaos_junction.png index 29359d928d12729ed563d1702a2e3a3ce7f360b3..001f51de76bf465e726dd5cbc62972768119ee27 100644 GIT binary patch literal 4099 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 literal 2346 zcmYjTdpwkB8=e`HunslFlH|}3_LpeTZ!+UJjhRoeDcUu)CX0z2YDQ=@3cDodu?~aC zBBz>aoXROm8yT!Yax7vThY#a4u|DN}XTN`*_xU}~b3e~@U-xxizc>0vds{h~12PZ@ zM2<+nIc|SjAP^`_N^*O4K6~je2t;auh_gB$+%Fi?Og(C{2lDsJ_+x)d!C@x5jbx&6 zPZ6oFt$%{aY%1PU)Tm#7p1TX3oOs@m$ktu5@8D+kzL*bi3Mw(bwXxRoy(SKAzP9JL zJ2)uuQLAdPxx7m1KB&Ieq{04;#P;3a9{BCm zWTT;X7~6VraZ#j%O~j7?VF*HeRA)r)jmBk-sdZB4Iu{iP{lBQY%|?MO#6Oo<8MWFH z%HU(}!S$VVTMRu(;0QaEQspioj;M`s@43G?nT50|J~4_Fj_L0_ZwYImDr3K#{NqmV z@MG7MoNt)67k?+X*wxjLuL!8TF2w@=noo{{-UTtT)Q0BNkXUb#DT-q?{o)4&aWbj& zvP(9$2`AFgT5dfP7?cGN!i-3og&qD=mB25$Nj4UZw7xf65Cho4b4qFlTzEqsW0vrA zr}g2Y4yA9*Aqes|JQ@WnI=P0FMXBo#Va4~^B|g$$p5r|UOk*2A9!R+g0|GSq*s~;5 z;fASlUVXQ>5~DIoO>+kW&i;69f^X$7<<&j9P@Au{-A?mWR(`A7k{xq;{aq;xKW^24$m0x@M~^vSv4wf~5dQPD57XD*=BoLROY|u$<}$`2XJUp7R@{i-(wc#dIAYg3S)jn^ zIlVx^9joUW(%;pTt_Xlb#Ko-qitd4kd^mr?;pGMe=SoRa!p(dsMw`>@Id*eUKnPLe zNbL4Cdd>y=_B_)i-LBm2JF`$q?!IL;`+*?|l*-ilxV}O$y8g&kG~Ut05A_q>d#lp;o%_KI_cD1Au_iHjeD~Yy z%QIEMd%6&lReU5CV*dsLpps4!%H|$)f`$Im&U>Z5`8b2>Ys`GVRS>YTgiWh1 zC{Mj=Yce#z_^&?rx-GBTq$+^vy^`2{=h4r`H34K5#XQme`FJRh0#jp3CE0MgvFTji zatxW@9CZ!~(upTHU+PNe*EvZG-PmW-6p;x5cNPfQF2Qjg&nZDP{ zvp+fQt357MV^6WQ{J|zXnuu|*Y_f?%wbmDg7ppvbuyqU+b(s4qXWrLiG`n_O5(Dtc zgcE0O1fCgQ#ORRh5%)`R{vpohpd3Hbn7m(N96wiPn7=p@=Cz5rkbAG;U$+V2Tveju zEC#viqB#Xy{OqN^U7%|otYlR2`{`-aYXNLt1jSPx<^7!zfWwmNPOIjUk6Y-natl%5 zA}7CBU=X5=Pl-jPCD_fQR6)N(KY$OOi^u3$M7NVTVSVFJXMiwbI4+dAo69v@%_F-T z$bs$dj@-N6QWPZ!9Hdb7vnS9Dw=39Qm3BRG!53pEv9j2qlYWw2^zUB^-}IJgo6(}x z0lJm6R8n$qpSn>Y*!qXA4cWKqrF$%!;wxE~V*&FWi*{p%c3n>vmTO-lTNrkFH zmYU(Ob{n0^)z1I!NHcwCA4!EqLl+MWk7xPiB!e2Mo;!rHl@j`_I_b2Z8_i*nfJP@; zBe5P$vO(9vR7mo}j15V!e)o{CZwQKN7FP4D%$JKMe+Y~G^uShBNYCCqMJEe=RQ=j0p7<$-x$EtJ<~zGu;{~ZZ zUL}pBgy}V=-wY>(D5WV~>6j__6)y*kYbYz3{SE+~c6dr96ist|cbbgKF>QWYMQKHg z?QF}L=M;38VlX7R}_UB3SO4i{6 z#F^nt+4~-1n;#*&EI??IlHx{dkqSE-9LS}(WC|++@AY|{-UV{TRB~ub?RQG_775ti z)oH%>fnrG`TMMzLggw53V4g@+ric6CIsr5lOBk>5`t$`*w?|@QVWg*hbF%Tg<`6D=ZO>Mi0ywM2=SCXj%`g2{~vlC;4c6G diff --git a/tools/previews/drift_arena.png b/tools/previews/drift_arena.png index 457d6a6b112ab9089c7ea1e768c1f303e36528a1..93ab6c7d9912a24c8ea0f1304dbb88067c291ba4 100644 GIT binary patch literal 1525 zcmeAS@N?(olHy`uVBq!ia0y~yU^D}=pK-7O$@s4;jTjhM_jtNEhE&{odso*l+fkw+ z@mgS`n}UFci>OfKL9bOD+zvt}+=-emmUS)A$~wiACgf){`RC7b%5m4L-ybh{wlm3S zdHC$JUtK3Dm8J@+H2O|Dpyrt{dC9L0wYGP(3(EbsD{B3$v}G@3ntg!HYy)Rbg76I% z5X~fgRGnDIG{&SCK)xUe{ zSO1lte_kB)r1YMwzo*%1sO8MkAln;xLDmC>8gow+bzE)B)lAAhyl%<$>9v!7K03n= zQXrE6RC*xI4x~20SRSNU`f^P|v}%_CbNf$_R^}X#5{T-YXUww?q$SVd&(UsM`y}Mc z_vaI){NB9PULB-+GRSVA58+nN)(-t2^2u(ajGFPhuD+I}C&9a<6%t_Z=f!EzyOvgXaK314RX$g#!kba|5s&7K|q9UYXn@6PjS|7um| zBiw`JG@uJW4)A4~{cH`3@Mna*pb!E}-Mj*{KJ3FkeT{Fw_inYg+FkT&$CoQ&aQ^{Q z1w3>>tV4&dJ?eHk@XTu}IHV8PfXrb82gx&t6FxVdovO7{EHxHr^UVX-SV75Z4L8_} zz#!S&cn}mKxzB4b;#~nB2<=RN$Mixh_Ha-7gW5 z3wM}5Idj%=M%n{IIqK$&Yy4pAfQbSaq9E%GS+`9Tt17oq?Jbc~TfS(bpKI}Npg6=3 zi08mje3k*mFfivlYn=An zA>p&b>0*Tqo_?-#yPCj8LF}Id@(Vb9Eio(Da$}A0A+ulN_ZyH@2!VV9vSIdX(@7BK zWMDS%1jdR=BX3#Mzv}G5R}n>C7ME6Lyn8P37b$AM;RcRGU}yndza(>=j_%QOHPBQI zN{EOIrM7m*(ivZV&Cw18OM`MXNYWRS_@2$Ws#PZ+xG(cAFgAgFNOVFIGAI~9irB0} zqtYR%TG|2}WT4=E$YKUgRltOPZSUQWQ&?R8bAGA0c1;W=lAa|(k_ISRnP;DU|GePU zix80<2L`A~!2AJ>5MYkx%z0oSeUwIfyMlIgnzopr E0C8=AT>t<8 literal 825 zcmeAS@N?(olHy`uVBq!ia0vp^yMVZhgAGW^mITBwFfcQCx;TbZ+}s9pR; zwZQZ@I^P1%SuU1;oU|gbZQiEY*=`Us?w)(GztG(!zfurvV#$TWy83(zJNFr9ofIm$ z0Cb}l^In#NQ|4aOcySi!AD|Trn&bV!~PMfdv)bV_QS{?VZe*gAuduDvS*9UXV8#cFRKX!O`74NHq zy1_o-?{c*r&lgD6o(eXB+4iC*Fa63n4X7X3Z%O9r&X<0%fE(y$^To3-o&`q+->zqO zcFt)^^Lz1*NdX+fEEjZVa+YwGXujy-`}_Xd z=_Q3J^O&=sF(mtCW?-MK+|%E%;OEG%moi$ReCOi>`$LSQ4` zE(iA51>O*N+G+YC_FWFsFF8-�I1{ePOnF{HIlM(hXZ+mSFI7^>bP0l+XkKzEpfC diff --git a/tools/previews/kart_circuit.png b/tools/previews/kart_circuit.png index 5f04e7a5fdf439f2da3a7a2123b5e0e61c1d0692..cd719eec0da9c11320b89d0f138004b63b27a058 100644 GIT binary patch literal 1545 zcmeAS@N?(olHy`uVBq!ia0vp^|AF{02OE&|-Edrnfr0gcr;B4q#jUq@6{iV13N$>l zJyKl}s6aG8Se^Oml zU0w0?l6~{$uyEFSe(-LJ7y2{l!E~1WDnC>Y>LUx4xczx=zkhPuL9YD|#Q!wKf8ee= zsQ+Ut$A0cmHugV4e?EABIIMq=e?QCrXRH5S6o2{qIRC93x8HsX_>#`c`GeK|4^YF8 zhvI*Ls(w72-(X+Nv{?UFzxSH>_4e|oY|a%wkuz_StBv`3!BFKvyF5@Sg7_};;qd&7 zl27ja(y<)ZUoXG3Nc{Nq>(c|`>;6}2{Mzm^U%7bC)$@UWf4g09??25mH`JL4ML)!( z{~`JB=Q7$BD;|jd4^sJD-%4OnD1YZ%b|#>s_<;^YBI=9&%sl_({DF8|0ln$3ohARw zbgMtz^L*_(sj9CNEN(kApt=xj!+oaze{bJzchkPH__4&p_y4Bd3O&CuYwP~s*i|QW zzk9LjchC9H?)o)9;kXS%2%P`_{p% zShe>6n%hC#{Ee>VZ2w;@>GP{g^9afRzoz2i+Z~IUkI6D12P`y*|GEA!W?uNK^)ye* znU7lKvMK)}cQ7-c1_;;=`^41xtFLmub}xx@Rx%b?bw7^J{!bGyC=k(m`_cO?TGdCy zGoPIBv1va zd&7R;Wt{Qp==7_RZ@1rqxC_PO`91r8%QjqGr*pNO!ASqkx!XDA|C#*fKgZ$>|JMDN zxpKZvJ8fL{ZD!z?{Bu|SR^+l{G4t^K8ttb%uio8fHF*4|SMNaJ;`z3ZEMM%i=Eo9B z_1S-{>)-AF#oD*;#mw%SV_xOY&UutB-oNzM{G;b?A4E#CaGU18D*i9YBdM@DAbfuF znFT%fDp%B5S3H~V@csNdZ0fh~WG*~uj5zF>L18< z!R-IOcxt7i6>lfczgOLRe$SmKd3?8V36_Lk$DGJC>x`bAvEGYUkI&9&4qZ{+2u#r! zM()p@+vc*Id)M!ePP|y8`Ri9JUer9U4YuJR&=H^{1}?u!8x$W_05eBl_=m#}fD9fe kV?UMxmSz8vzQ3%;+Abanb=%AfEaDkFUHx3vIVCg!0C!AJ0RR91 literal 1015 zcmeAS@N?(olHy`uVBq!ia0vp^Gl962gAGW=-g0(kU|`EaktaqI0}#YrNDB5Vog zHZt`(Ce#A!756l^wi$!aa9QKBr!H+0=Hn-@bKKvptu` z-@UuHPE2xUxRs-V@Cg?nW-$^3;)#YQ?$uUl_crnvolt1b$xLBr;E+t2z?l+zp!WZV zxPKFuFJB&=HGij&9Z;HQlkWsqy#?1I?cc?pTYr51c9*{O6?v6=T0*Vo9o@wRQ*zZ{ z>haTk)fGQxc&!!QZ&O!mIKTe?&ZXbxZY@@Rdvf0FzcrkzZR8H#;+9XD;K(MK((aqM z@-ov9P1ZaDSr`hlyHj+!%DfbHW}?mqKuyTa+F-TUizs5a?L`eYrwPkpw|8l8PN z4}kPeaJ(ja^YeD|?Nh$`{w()SS-f1jYVPgTYSO%XAP@0udUtw#_STToJ@vT?;qGR; z)UsEWyEiw3e0y6aa_vRcrYNPaF>AM3Wcx;_O#GViG`)`{{Ix$wtI>&F`uqD7wDp(m zIl-}S?)k61xpHbPxc zt7HGq{j+9(Wu1HnQ-f zH6%vQ-uj{Ze~x82_CF1!}u3{GX+SoSGz zuX%NKo~R)tDz?e&yC}_de$PQeNRZuDivC+_YMsyoioIqZ7NbTV1z`|g`CLEh`LVdq To&4W{8JfY<)z4*}Q$iB}Koida diff --git a/tools/previews/speed_oval.png b/tools/previews/speed_oval.png index b64c4b54b353e1e624e490d120e6350bfa5eb20b..d8bab6799585b56fce8a85d7908806627686c5cb 100644 GIT binary patch literal 5349 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 POB~IDzA1Hs>*@ai-m!b{ literal 2855 zcmY*bdpwiv8+U3FGMyYsCWKxuZ9wo)rC& z@d3GtZ?bl4dJ$;LG*`umRh$aKv0*K;M=5Ql3iwjfZpL*+;grBzK z1Z$(2Gii}066&I98QF(_);z?0xNMK@4gT0i=+Kwe;{ zMBue9)a96ef={)Z0@F*|?n>-aAt5X-ww9F_8|&ubF>xYej!?>xPwlRo8RK6p-`58} z0R+d(h=U=;jmivIVdhdrmfL69s9}Xj=Sym2v%DVz%~a}zq{Rv!TUYaD@37X7YgRHs ziXD`=xRw-@heOETJ$RQF7DVlAdLun?wAMw7W^M?e0(n`+2ZcCz*z2AhLyOSHe3+ zQCZ4CqPkZo2;q-e5+Zgo3a~@zgbo%d_fjvVd?>K=e0{$X2xgfrpiQsvF6+Q^R_1&JCI>HtT(FMU=;H zjCTCjtom2Hs+HcW2(iFcr=VC{-P$r{R^Mrh(8zv}h4HsLhC} zkO$pqXTv$kW!T^&KVO;86bSd`=f~Ps-l)p%+t5{gDG+@@kbPCh; z3#vEWjD|PaQoWihJff3yB%1@Hmb^bRq63Z>tVb>wx~iLnY8+#D?FI5m)ahz+g7$LC z$*ZUHWuA@3ny4g$Rl$|LiB-HoR;}jqM^&s$caGFr2AC9(DGb8DXn(dQy!Vug@t9&% z1uIj6SPyC%UnTsz2~vD3Ru9hg%(ndfXdMJJhutK7Xktudv#wvs&IbtHy32E#Bz>^A zU>%%kmGaYWX1cvWmc}dPVX*aa=;}}$HyjlR41$Cb(|C)^9f2;!4{S{Xf>4aDc9Mb= z7$H=8x+)45d8Gldk?V<@U4hkoUzWk%4Q2fz?-C}Thoy<=m*HQ5y0S<|wJn-IKeDFM z0XSkG0$3Obqfl6+0$sq>z#eZPrP}8PeyV*(MguVU%Gwf`au^jTDZd}f5LwKWFoTGe z7em#9;wnQQ3R6ellhM@xoXBl8L}f{Wx~*IeE9TCB@pE0s`7BWaphP6B0$9Q>r!7v! zMQVvC10fN=J-gwF*M?)>Kelg2YtNt30?8EpK`_$jK$JGHc9~3!%I!y}G`d##u!`gx z5y1#%P+CAWiAdCg(o|`xs9=WO3t35djyCx0Hg-e{NgpnrOwnTZp(3IiTaHBNEfbC= zuY#00o%E+C)w+b}mM~$0n_MB2?O91H_}}FVvAR0gPNX~(o}Hkyd5Q(7&F zeku*fL||UiZazs;8F2u$4YvFF&V=n2hA7LyH|^KUOp>n<;e!oU;3$ik#t!#b!DY(IChpyLWnNpx z2~H4BnvVo5v+&0*(*vrng9?S}+QLg7tj!s|#|N~QtrhGzAZiLc;zG7sYS+mpdoG%A z9`i;D`nE>()xzFxUg#;0#Z~lIL%10%La+%_Zsi!;LUPOuy~=L6A3HlW_vr%h6vp9| zt`wg>y)s-NfV7D~TRPt4Ebra$(HZ$@LMIImb!RJ`Lp0*unNg3%H zjOKZ#U-Wwnwo7!cd7yDIDl58XAvy6P-RDRnZ z+z7fgp$B{d%*gNsMz{ROePpVW&WDVT#`({4@0@Z8AMXBK%E<_PhFz>ttosLp2ySXp z%+5CRdy8|SP~1Y}*xa1zj820h`>CMMd&$6dNw%QTePnly?n;yx5{%N@B zF6-XM^5sg-+rkX|K+9c)dDf5b(3h!}_v=Z}$X@(1Mlk;x475Rwy8$hp;Vq!mp#w7I z%t5ghI~YS10X^%*qrqFK3~=Q{Q{*tn>bxZ>QrZ^HTV(%2-~ud#barb", TAG_COMPOUND) + _nbt_string("", "") + _tag_payload(TAG_COMPOUND, root_compound) os.makedirs(os.path.dirname(path), exist_ok=True) if gzipped: - with gzip.open(path, "wb", compresslevel=9) as fh: + # mtime=0 keeps generated structures byte-identical between runs + with gzip.GzipFile(filename=path, mode="wb", compresslevel=9, mtime=0) as fh: fh.write(data) else: with open(path, "wb") as fh: @@ -581,7 +582,7 @@ def write_json(self, extra=None): return data # -- preview --------------------------------------------------------- - def render_preview(self, scale=3): + def render_preview(self, scale=5): (mn, mx) = self.bbox() w, h = mx[0] - mn[0] + 2, mx[2] - mn[2] + 2 img = [[(26, 26, 30) for _ in range(w)] for _ in range(h)] From a4b19fd1fde6949e9e41e8a76bac3996845929fe Mon Sep 17 00:00:00 2001 From: Qynl Date: Sun, 11 Oct 2026 08:55:20 +0000 Subject: [PATCH 24/24] Gate the circuit building commands behind creative mode or op rights Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- README.md | 4 ++++ src/main/java/dev/qynl/racers/command/RaceCommands.java | 9 +++++++++ 2 files changed, 13 insertions(+) diff --git a/README.md b/README.md index 701819b..27bc578 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,10 @@ Everything the mod does is scriptable - all commands are under `/race`: /race drift current drift score and boost charge ``` +The building commands (`/race wand`, `/race place`, `/race remove`) need +creative mode or operator rights because they place blocks; everything else +works for every player. + --- ## Making a circuit in 30 seconds diff --git a/src/main/java/dev/qynl/racers/command/RaceCommands.java b/src/main/java/dev/qynl/racers/command/RaceCommands.java index bb3e373..85e97fd 100644 --- a/src/main/java/dev/qynl/racers/command/RaceCommands.java +++ b/src/main/java/dev/qynl/racers/command/RaceCommands.java @@ -86,8 +86,10 @@ public static void init() { // --- tracks ------------------------------------------- .then(CommandManager.literal("tracks").executes(context -> listTracks(context.getSource()))) .then(CommandManager.literal("wand") + .requires(RaceCommands::canBuild) .executes(context -> giveWand(context.getSource()))) .then(CommandManager.literal("place") + .requires(RaceCommands::canBuild) .then(CommandManager.argument("track", StringArgumentType.word()) .suggests(TRACKS) .executes(context -> place(context.getSource(), @@ -97,6 +99,7 @@ public static void init() { StringArgumentType.getString(context, "track"), IntegerArgumentType.getInteger(context, "rotation")))))) .then(CommandManager.literal("remove") + .requires(RaceCommands::canBuild) .executes(context -> remove(context.getSource()))) .then(CommandManager.literal("record") .executes(context -> record(context.getSource(), 7, true)) @@ -210,6 +213,12 @@ private static int giveCar(ServerCommandSource source, String type, ServerPlayer return 1; } + /** Building circuits edits the world, so it needs creative mode or operator rights. */ + private static boolean canBuild(ServerCommandSource source) { + ServerPlayerEntity player = source.getPlayer(); + return source.hasPermissionLevel(2) || (player != null && player.isCreative()); + } + private static int giveWand(ServerCommandSource source) { ServerPlayerEntity player = source.getPlayer(); if (player == null) {