diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e518f1c..4b30c44 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -2,7 +2,8 @@ name: Build Mod on: push: - branches: ["main"] + branches-ignore: [] + pull_request: workflow_dispatch: jobs: @@ -19,16 +20,204 @@ jobs: distribution: temurin java-version: "21" - - name: Set up Gradle - uses: gradle/actions/setup-gradle@v4 - with: - gradle-version: "9.5.1" + - name: Validate assets + run: python3 tools/validate_assets.py + # `set -o pipefail` matters: without it `| tee` would swallow Gradle's + # exit code and a broken build would look green. - name: Build - run: gradle build + run: | + set -o pipefail + ./gradlew build --no-daemon --stacktrace --console=plain 2>&1 | tee gradle.log + + # 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 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') + 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 + 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 + if: failure() + uses: actions/upload-artifact@v4 + with: + name: gradle-log + path: gradle.log + if-no-files-found: warn - - name: Upload JAR + - name: Upload Mod JAR + if: success() uses: actions/upload-artifact@v4 with: - name: MyFirstMod + name: fabricracers path: build/libs/*.jar + if-no-files-found: error + + # 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 + echo "eula=true" > run/eula.txt + cat > run/server.properties <<'PROPS' + online-mode=false + view-distance=4 + 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 + 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 40 server.log + python3 - <<'PY' + 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('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, + 'block placement': 'Changed the block at' in transcript, + 'car entity spawn': 'Summoned new' 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, + # 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}") + if not started or failed: + raise SystemExit(1) + PY diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..256f281 --- /dev/null +++ b/.gitignore @@ -0,0 +1,31 @@ +# 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 + +# Python bytecode +__pycache__/ +*.pyc + +# 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 index 4854778..27bc578 100644 --- a/README.md +++ b/README.md @@ -1,120 +1,215 @@ -# ๐Ÿ•ณ๏ธ The Null Warden +# FabricRacers -> A handcrafted Minecraft 1.21.1 Fabric boss encounter hidden beneath an Ancient City. +**A complete racing game inside Minecraft** - build a circuit anywhere, tune your +car, and race it solo, against AI drivers, or against your friends. -![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) +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. -## โš”๏ธ 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: +## 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`, 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. +* 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/`. -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 +## Controls -The fight progressively introduces different attack patterns: +| 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 | -- **Phase 1:** Void Cleave -- **Phase 2:** Sculk Ring and Void Rain -- **Phase 3:** Null Dash and Gravity Well -- **Phase 4:** Reality Tear and Collapse +The HUD shows speed, boost, drift score, lap counter, timing, live standings, +the countdown and every message the race engine sends you. -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 +## Commands -The Null Realm contains a deterministic arena with: +Everything the mod does is scriptable - all commands are under `/race`: -- reinforced-deepslate pylons -- crying-obsidian perimeter -- polished-blackstone combat floor -- sculk structures -- dedicated return portal -- encounter-controlled boss state +``` +/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 +``` -The arena is rebuilt from a known footprint, making failed encounters recoverable instead of leaving an uncontrolled collection of boss entities behind. +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. -## ๐Ÿ‘ฅ Multiplayer +--- -The encounter tracks participating players by UUID. +## Making a circuit in 30 seconds -The system handles: +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. -- 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 +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. -The return portal also stores the player's original dimension, position, yaw, and pitch. +--- -## ๐Ÿ—ก๏ธ Nullblade +## Installation -The **Nullblade** is the primary boss reward. +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. -It is a custom Netherite-based weapon with a charged area ability, cooldown, knockback, and a visual Null-themed particle effect. +--- -## ๐Ÿ› ๏ธ Development +## Building from source -### Requirements +```bash +./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 +``` -- Minecraft **1.21.1** -- Fabric Loader -- Fabric API -- Java **21** -- Gradle / GitHub Actions +* `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. -### Build +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`. -Every push to `main` triggers the GitHub Actions build. +## Status -You can also run it manually from: +Every push is checked by CI: -**GitHub โ†’ Actions โ†’ Build Mod โ†’ Run workflow** +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, 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. -The generated JAR is uploaded as the **MyFirstMod** artifact when the build succeeds. +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. -### Source layout +## Layout -```text -src/ -โ”œโ”€โ”€ main/ -โ”‚ โ”œโ”€โ”€ java/dev/qynl/myfirstmod/ -โ”‚ โ”‚ โ”œโ”€โ”€ MyFirstMod.java -โ”‚ โ”‚ โ”œโ”€โ”€ boss/ -โ”‚ โ”‚ โ”‚ โ””โ”€โ”€ NullWardenManager.java -โ”‚ โ”‚ โ”œโ”€โ”€ block/ -โ”‚ โ”‚ โ”œโ”€โ”€ item/ -โ”‚ โ”‚ โ””โ”€โ”€ portal/ -โ”‚ โ””โ”€โ”€ resources/ -โ””โ”€โ”€ client/ - โ”œโ”€โ”€ java/ - โ””โ”€โ”€ resources/ +``` +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 ``` -## ๐Ÿงช 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 - ---- +## License -Made by **Qynl** ๐Ÿ•ณ๏ธ +MIT - see [LICENSE](LICENSE). 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 index 6a3058e..2f9afec 100644 --- a/build.gradle +++ b/build.gradle @@ -1,9 +1,20 @@ 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/" } + maven { + name = "Fabric" + url = "https://maven.fabricmc.net/" + } mavenCentral() } @@ -11,7 +22,7 @@ loom { splitEnvironmentSourceSets() mods { - "myfirstmod" { + "fabricracers" { sourceSet sourceSets.main sourceSet sourceSets.client } @@ -19,25 +30,46 @@ loom { } 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}" + 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.mod_version + inputs.property "version", project.version + filesMatching("fabric.mod.json") { - expand "version": project.mod_version + 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 - withSourcesJar() +} + +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 index f6f6398..2d0cb6f 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,11 +1,14 @@ -org.gradle.jvmargs=-Xmx1G +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 -loader_version=0.16.10 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 0000000..d997cfc Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ 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 index 29edc31..48d0454 100644 --- a/settings.gradle +++ b/settings.gradle @@ -1,9 +1,12 @@ pluginManagement { repositories { - maven { name = 'Fabric'; url = 'https://maven.fabricmc.net/' } + maven { + name = 'Fabric' + url = 'https://maven.fabricmc.net/' + } mavenCentral() gradlePluginPortal() } } -rootProject.name = 'myfirstmod' +rootProject.name = 'fabricracers' 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/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..0f93a37 --- /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, 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); + 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..3bf0bb3 --- /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/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/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..d61b4bf --- /dev/null +++ b/src/main/java/dev/qynl/racers/block/RacerBlocks.java @@ -0,0 +1,198 @@ +package dev.qynl.racers.block; + +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; +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.METAL); + } + + 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..a83a9e1 --- /dev/null +++ b/src/main/java/dev/qynl/racers/car/CarData.java @@ -0,0 +1,174 @@ +package dev.qynl.racers.car; + +import java.util.UUID; + +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; + +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; + } + + /** + * 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 = readLevels(nbt); + 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()); + } +} 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..85e97fd --- /dev/null +++ b/src/main/java/dev/qynl/racers/command/RaceCommands.java @@ -0,0 +1,527 @@ +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.item.ItemStack; +import net.minecraft.server.MinecraftServer; +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) -> { + 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( + 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") + .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(), + 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") + .requires(RaceCommands::canBuild) + .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; + } + + /** 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) { + 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(); + ItemStack parked = parkStack(car); + if (!player.getInventory().insertStack(parked)) { + player.dropItem(parked, false); + } + car.discard(); + return 1; + } + + 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; + } + + 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..84047df --- /dev/null +++ b/src/main/java/dev/qynl/racers/event/CarServerEvents.java @@ -0,0 +1,50 @@ +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.minecraft.entity.player.PlayerEntity; +import net.minecraft.server.network.ServerPlayerEntity; +import net.minecraft.text.Text; +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() { + 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..8d6275b --- /dev/null +++ b/src/main/java/dev/qynl/racers/item/CarItem.java @@ -0,0 +1,141 @@ +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.TypedActionResult; +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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + if (world.isClient) { + return TypedActionResult.success(stack); + } + // 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 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 TypedActionResult.success(stack); + } + + 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..10ce44c --- /dev/null +++ b/src/main/java/dev/qynl/racers/item/ToolItems.java @@ -0,0 +1,366 @@ +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.TypedActionResult; +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; + } + + /** 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) { + 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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + if (world.isClient) { + 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 TypedActionResult.pass(stack); + } + return wrap(interact(car, user, stack, user.isSneaking()), stack); + } + + 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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + if (world.isClient) { + 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 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 TypedActionResult.success(stack); + } + user.sendMessage(Text.translatable("message.fabricracers.key_owner", + Text.literal(car.ownerName())).formatted(Formatting.YELLOW), true); + return TypedActionResult.success(stack); + } + + @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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + if (world.isClient) { + return TypedActionResult.success(stack); + } + return wrap(paint(carAt(world, user), user, stack), stack); + } + + 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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + BlockPos pos = user == null ? BlockPos.ORIGIN : user.getBlockPos(); + return wrap(use(world, user, hand, pos), stack); + } + + 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.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; + } + 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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + if (world.isClient) { + dev.qynl.racers.util.Stopwatch.items().toggle(); + return TypedActionResult.success(stack); + } + return TypedActionResult.success(stack); + } + + @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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + if (world.isClient) { + return TypedActionResult.success(stack); + } + if (user instanceof ServerPlayerEntity player) { + StatsManager.get(player.server).sendRecord(player); + } + return TypedActionResult.success(stack); + } + + @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 TypedActionResult use(World world, PlayerEntity user, Hand hand) { + ItemStack stack = user.getStackInHand(hand); + if (world.isClient) { + return TypedActionResult.success(stack); + } + 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 TypedActionResult.success(stack); + } + + @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..8886828 --- /dev/null +++ b/src/main/java/dev/qynl/racers/net/ModNetworking.java @@ -0,0 +1,311 @@ +package dev.qynl.racers.net; + +import java.util.List; + +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; + +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 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() { + 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 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() { + 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 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() { + 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 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() { + 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..2f87540 --- /dev/null +++ b/src/main/java/dev/qynl/racers/net/ServerActions.java @@ -0,0 +1,351 @@ +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.track.TrackRecorder; +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.partItem(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.partItem(tune)) >= cost) { + if (!player.isCreative()) { + consumeParts(player, dev.qynl.racers.registry.ModItems.partItem(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.partItem(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 z, String text) { + TrackManager manager = TrackManager.get(player.server); + 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); + } + 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..d2faba0 --- /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()); + 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..233d5bc --- /dev/null +++ b/src/main/java/dev/qynl/racers/registry/ModItems.java @@ -0,0 +1,212 @@ +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); + } + + /** The upgrade part item for a tuning system. */ + public static Item partItem(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 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/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..7cd6b6b --- /dev/null +++ b/src/main/java/dev/qynl/racers/shop/PartShop.java @@ -0,0 +1,96 @@ +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.TradedItem; +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() { + 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)); + 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)); + factories.add(buy(ModItems.BLOCK_KERB_ITEM, 16, 5, 6)); + }); + + // --- tier 2: real tuning ---------------------------------------- + TradeOfferHelper.registerVillagerOffers(VillagerProfession.TOOLSMITH, 2, factories -> { + 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.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, + 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); + }); + 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. */ + private static TradeOffers.Factory buy(Item item, int amount, int price, int maxUses) { + return (entity, random) -> new TradeOffer(new TradedItem(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 TradedItem(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..f9a974d --- /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 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).", + "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/myfirstmod/models/block/void_portal.json b/src/main/resources/assets/fabricracers/models/block/asphalt.json similarity index 59% rename from src/main/resources/assets/myfirstmod/models/block/void_portal.json rename to src/main/resources/assets/fabricracers/models/block/asphalt.json index 8385237..d48449a 100644 --- a/src/main/resources/assets/myfirstmod/models/block/void_portal.json +++ b/src/main/resources/assets/fabricracers/models/block/asphalt.json @@ -1,6 +1,6 @@ { "parent": "minecraft:block/cube_all", "textures": { - "all": "minecraft:block/portal" + "all": "fabricracers:block/asphalt" } -} \ No newline at end of file +} 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/myfirstmod/models/item/null_relic.json b/src/main/resources/assets/fabricracers/models/item/car_f1.json similarity index 59% rename from src/main/resources/assets/myfirstmod/models/item/null_relic.json rename to src/main/resources/assets/fabricracers/models/item/car_f1.json index a6f71e7..8ac5895 100644 --- a/src/main/resources/assets/myfirstmod/models/item/null_relic.json +++ b/src/main/resources/assets/fabricracers/models/item/car_f1.json @@ -1,6 +1,6 @@ { "parent": "minecraft:item/generated", "textures": { - "layer0": "minecraft:item/echo_shard" + "layer0": "fabricracers:item/car_f1" } -} \ No newline at end of file +} 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 0000000..1a7e582 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/asphalt.png differ diff --git a/src/main/resources/assets/fabricracers/textures/block/asphalt_cracked.png b/src/main/resources/assets/fabricracers/textures/block/asphalt_cracked.png new file mode 100644 index 0000000..8b08c4f Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/asphalt_cracked.png differ 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 0000000..f910092 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/boost_pad.png differ 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 0000000..04d8eeb Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/checkpoint_marker.png differ diff --git a/src/main/resources/assets/fabricracers/textures/block/fake_ice.png b/src/main/resources/assets/fabricracers/textures/block/fake_ice.png new file mode 100644 index 0000000..261ba8c Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/fake_ice.png differ diff --git a/src/main/resources/assets/fabricracers/textures/block/fake_slime.png b/src/main/resources/assets/fabricracers/textures/block/fake_slime.png new file mode 100644 index 0000000..25b2f80 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/fake_slime.png differ 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 0000000..a514c3d Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/finish_line.png differ 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 0000000..3a47145 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/kerb_red.png differ diff --git a/src/main/resources/assets/fabricracers/textures/block/kerb_white.png b/src/main/resources/assets/fabricracers/textures/block/kerb_white.png new file mode 100644 index 0000000..ad985b6 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/kerb_white.png differ diff --git a/src/main/resources/assets/fabricracers/textures/block/pit_lane.png b/src/main/resources/assets/fabricracers/textures/block/pit_lane.png new file mode 100644 index 0000000..8934101 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/pit_lane.png differ diff --git a/src/main/resources/assets/fabricracers/textures/block/road_block.png b/src/main/resources/assets/fabricracers/textures/block/road_block.png new file mode 100644 index 0000000..4819509 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/road_block.png differ diff --git a/src/main/resources/assets/fabricracers/textures/block/safety_barrier.png b/src/main/resources/assets/fabricracers/textures/block/safety_barrier.png new file mode 100644 index 0000000..74091ce Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/safety_barrier.png differ 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 0000000..a1242a3 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/start_gate.png differ 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 0000000..ea8cb80 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/block/station.png differ 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 0000000..a27c5d5 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/drift.png differ 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 0000000..29550d3 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/f1.png differ 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 0000000..77e0bd2 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/flame.png differ 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 0000000..82c173f Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/hotrod.png differ diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/hyper.png b/src/main/resources/assets/fabricracers/textures/entity/car/hyper.png new file mode 100644 index 0000000..cedb6cc Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/hyper.png differ diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/kart.png b/src/main/resources/assets/fabricracers/textures/entity/car/kart.png new file mode 100644 index 0000000..3974ebe Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/kart.png differ diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/phantom.png b/src/main/resources/assets/fabricracers/textures/entity/car/phantom.png new file mode 100644 index 0000000..c1fd60f Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/phantom.png differ diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/rally.png b/src/main/resources/assets/fabricracers/textures/entity/car/rally.png new file mode 100644 index 0000000..e5c34d2 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/rally.png differ 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 0000000..4835949 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/shade.png differ 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 0000000..2769f7d Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/shadow.png differ diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/sport.png b/src/main/resources/assets/fabricracers/textures/entity/car/sport.png new file mode 100644 index 0000000..70ee932 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/sport.png differ diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/template.png b/src/main/resources/assets/fabricracers/textures/entity/car/template.png new file mode 100644 index 0000000..7f06a82 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/template.png differ 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 0000000..1c0adcc Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/truck.png differ diff --git a/src/main/resources/assets/fabricracers/textures/entity/car/wraith.png b/src/main/resources/assets/fabricracers/textures/entity/car/wraith.png new file mode 100644 index 0000000..d174fb3 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/entity/car/wraith.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/aero_wing.png b/src/main/resources/assets/fabricracers/textures/item/aero_wing.png new file mode 100644 index 0000000..b853c59 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/aero_wing.png differ 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 0000000..c1fba41 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/boost_nitro.png differ 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 0000000..0a48838 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/brakes_carbon.png differ 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 0000000..e1142a4 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_drift.png differ 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 0000000..47c578d Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_f1.png differ 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 0000000..4d03846 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_hotrod.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/car_hyper.png b/src/main/resources/assets/fabricracers/textures/item/car_hyper.png new file mode 100644 index 0000000..097e419 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_hyper.png differ 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 0000000..5247d9c Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_kart.png differ 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 0000000..8fdde44 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_rally.png differ 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 0000000..097e419 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_shadow.png differ 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 0000000..3433249 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_sport.png differ 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 0000000..bf16c13 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/car_truck.png differ 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 0000000..3d3a8f0 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/carbon_weave.png differ 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 0000000..293b4ab Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/carrot_key.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/chassis_plate.png b/src/main/resources/assets/fabricracers/textures/item/chassis_plate.png new file mode 100644 index 0000000..31c7d4f Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/chassis_plate.png differ 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 0000000..ce5ac92 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/engine_turbo.png differ 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 0000000..1e92685 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/engine_v8.png differ 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 0000000..5a194a4 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/gearbox_dct.png differ 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 0000000..9075a59 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/gearbox_sequential.png differ 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 0000000..60d3ad8 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/ground_effect.png differ 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 0000000..6e46f7e Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_black.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_blue.png b/src/main/resources/assets/fabricracers/textures/item/paint_blue.png new file mode 100644 index 0000000..ac074f1 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_blue.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_green.png b/src/main/resources/assets/fabricracers/textures/item/paint_green.png new file mode 100644 index 0000000..9fc1226 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_green.png differ 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 0000000..81b8230 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_orange.png differ 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 0000000..08b5f86 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_purple.png differ 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 0000000..7ad9ab8 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_red.png differ 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 0000000..90281d8 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_white.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/paint_yellow.png b/src/main/resources/assets/fabricracers/textures/item/paint_yellow.png new file mode 100644 index 0000000..3ac02cd Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/paint_yellow.png differ 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 0000000..3cb71e3 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/race_guide.png differ 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 0000000..cc15898 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/race_pass.png differ 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 0000000..9a352e3 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/roll_cage.png differ 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 0000000..c6b27b7 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/stopwatch.png differ 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 0000000..785e432 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/tires_hard.png differ 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 0000000..408c988 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/tires_medium.png differ 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 0000000..f2c8b71 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/tires_soft.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/tires_street.png b/src/main/resources/assets/fabricracers/textures/item/tires_street.png new file mode 100644 index 0000000..cf517a4 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/tires_street.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/track_wand.png b/src/main/resources/assets/fabricracers/textures/item/track_wand.png new file mode 100644 index 0000000..f0ca8d2 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/track_wand.png differ 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 0000000..e3b9daf Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/trophy_bronze.png differ 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 0000000..f4131e4 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/trophy_gold.png differ diff --git a/src/main/resources/assets/fabricracers/textures/item/trophy_silver.png b/src/main/resources/assets/fabricracers/textures/item/trophy_silver.png new file mode 100644 index 0000000..fd90531 Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/trophy_silver.png differ 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 0000000..18697ec Binary files /dev/null and b/src/main/resources/assets/fabricracers/textures/item/wrench.png differ 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/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/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 0000000..0df0e73 Binary files /dev/null and b/src/main/resources/data/fabricracers/structure/tracks/asphalt_park.nbt differ diff --git a/src/main/resources/data/fabricracers/structure/tracks/chaos_junction.nbt b/src/main/resources/data/fabricracers/structure/tracks/chaos_junction.nbt new file mode 100644 index 0000000..a050dca Binary files /dev/null and b/src/main/resources/data/fabricracers/structure/tracks/chaos_junction.nbt differ diff --git a/src/main/resources/data/fabricracers/structure/tracks/drift_arena.nbt b/src/main/resources/data/fabricracers/structure/tracks/drift_arena.nbt new file mode 100644 index 0000000..094da8e Binary files /dev/null and b/src/main/resources/data/fabricracers/structure/tracks/drift_arena.nbt differ 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 0000000..94d00a8 Binary files /dev/null and b/src/main/resources/data/fabricracers/structure/tracks/kart_circuit.nbt differ 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 0000000..564b79b Binary files /dev/null and b/src/main/resources/data/fabricracers/structure/tracks/speed_oval.nbt differ 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/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 index 3d59fe7..9891d2a 100644 --- a/src/main/resources/fabric.mod.json +++ b/src/main/resources/fabric.mod.json @@ -1,27 +1,34 @@ { "schemaVersion": 1, - "id": "myfirstmod", + "id": "fabricracers", "version": "${version}", - "name": "The Null Warden", - "description": "A handcrafted Ancient City boss encounter built around the Null Warden, its realm, and the Nullblade.", + "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": { - "sources": "https://github.com/Qynl/MyFirstMod" + "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.myfirstmod.MyFirstMod" - ], - "client": [ - "dev.qynl.myfirstmod.MyFirstModClient" - ] + "main": ["dev.qynl.racers.FabricRacers"], + "client": ["dev.qynl.racers.client.FabricRacersClient"] }, "depends": { - "fabricloader": ">=0.16.10", + "fabricloader": ">=0.16.0", "minecraft": "~1.21.1", "java": ">=21", "fabric-api": "*" + }, + "suggests": {}, + "custom": { + "modmenu": { + "links": { + "modmenu.discord": "https://github.com/Qynl/MyFirstMod" + } + } } -} \ No newline at end of file +} diff --git a/src/main/resources/icon.png b/src/main/resources/icon.png new file mode 100644 index 0000000..d253c90 Binary files /dev/null and b/src/main/resources/icon.png differ 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..da4fb54 --- /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": sorted(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 0000000..3eecca8 Binary files /dev/null and b/tools/preview_items.png differ diff --git a/tools/previews/asphalt_park.png b/tools/previews/asphalt_park.png new file mode 100644 index 0000000..d70601c Binary files /dev/null and b/tools/previews/asphalt_park.png differ diff --git a/tools/previews/chaos_junction.png b/tools/previews/chaos_junction.png new file mode 100644 index 0000000..001f51d Binary files /dev/null and b/tools/previews/chaos_junction.png differ diff --git a/tools/previews/drift_arena.png b/tools/previews/drift_arena.png new file mode 100644 index 0000000..93ab6c7 Binary files /dev/null and b/tools/previews/drift_arena.png differ diff --git a/tools/previews/kart_circuit.png b/tools/previews/kart_circuit.png new file mode 100644 index 0000000..cd719ee Binary files /dev/null and b/tools/previews/kart_circuit.png differ diff --git a/tools/previews/speed_oval.png b/tools/previews/speed_oval.png new file mode 100644 index 0000000..d8bab67 Binary files /dev/null and b/tools/previews/speed_oval.png differ diff --git a/tools/track_lib.py b/tools/track_lib.py new file mode 100644 index 0000000..1e6a751 --- /dev/null +++ b/tools/track_lib.py @@ -0,0 +1,690 @@ +#!/usr/bin/env python3 +""" +FabricRacers track toolkit. + +Pure-stdlib helpers used by gen_tracks.py: + + * a tiny NBT writer (vanilla structure-file layout, so the results also load + in a vanilla structure block) + * spline sampling + "stamping" of roads / kerbs / walls / verges onto a grid + * track map preview rendering (so designers can eyeball a layout) + * emission of the data/fabricracers/tracks/*.json definition files + +Structure coordinate contract (mirrored by TrackStructureBuilder.java): + + local y=0 -> 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: + # 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: + 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=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)] + + 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())