Big, tough bosses; working machines show it; JEI drop pages on the roadmap
Bosses (Adrian, 2026-11-02: Eternatus too small and too easy; guardians way bigger and tougher; only defensive stats up; no attack takes more than 25 %): - Guardians scale to about 8 blocks tall (1.5x-12x), Eternatus to 15 blocks (2.5x, was 0.2x). Cave guardians look for a spot with room above them. Bosses already in a world grow on the next check outside battle. - Perfect IVs and 170 EVs each in HP, Defense and Sp. Def, nothing offensive. - The Guardian's Ward, a Showdown-only held item (held_items/guardiansward.js) handed to every boss by a HeldItemProvider: Defense and Sp. Def x2 while defending, and one attack (all hits together) takes at most 25 % of max HP. - Area attacks that start at the boss reach half its width further. Machines (Adrian, 2026-11-02): a working core is active: it glows, its front animates (flickering fire on the furnace core, pulsing emblems elsewhere), and particles show in the chamber and at the core (client side). The Pokemon inside flinches about every 15 s (red flash, Cobblemon's hit sound, no damage). Roadmap Phase 8: every item that can drop gets a JEI page naming its sources. Self-tests: practice_bosses_spawn checks size and training, boss_takes_a_quarter_per_attack runs a real battle (each Lv 100 Bullet Seed takes exactly a quarter), heat_core_cooks_with_hoppers checks the active state and the flinch. Self-tests and the island check pass. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
4f0bc03919
commit
120c664cb0
58 files changed
+681
-86
No files matched your search
@@ -3,10 +3,10 @@
|
||||
"end": {
|
||||
"species": "eternatus",
|
||||
"level": 80,
|
||||
"scale": 0.2,
|
||||
"respawn_hours": 5,
|
||||
"xp_first_win": 12000,
|
||||
"xp": 500
|
||||
"xp": 500,
|
||||
"scale": 2.5
|
||||
},
|
||||
"arena": 40,
|
||||
"patterns": [
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "basalt",
|
||||
"pokemon": "moltres level=75 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 2.0,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:exp_candy_xl",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "button_isle",
|
||||
"pokemon": "morpeko level=15 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 12,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:poke_ball",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "deep_dark",
|
||||
"pokemon": "regigigas level=65 uncatchable=yes",
|
||||
"scale": 1.0,
|
||||
"scale": 1.5,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:icy_rock",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "deep_sea",
|
||||
"pokemon": "kingdra level=55 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 2.96,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:mystic_water",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "frostpeak",
|
||||
"pokemon": "abomasnow level=60 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 3.2,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:icy_rock",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "highlands",
|
||||
"pokemon": "mudsdale level=40 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 3.08,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:icy_rock",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "jungle",
|
||||
"pokemon": "tangrowth level=35 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 3.33,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblesurvival:mire_waders",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "mire",
|
||||
"pokemon": "swampert level=43 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 5.0,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:icy_rock",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "southwold",
|
||||
"pokemon": "pinsir level=23 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 6.13,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:great_ball",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "sunscorch",
|
||||
"pokemon": "flygon level=50 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 2.78,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblemon:heat_rock",
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": "westmarch",
|
||||
"pokemon": "scyther level=30 uncatchable=yes",
|
||||
"scale": 1.5,
|
||||
"scale": 4.73,
|
||||
"drops": [
|
||||
{
|
||||
"item": "cobblesurvival:mire_waders",
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
/*
|
||||
* Cobblemon Survival Pack: the Guardian's Ward, a Showdown held item (Cobblemon loads data/<ns>/held_items/<id>.js).
|
||||
* No Minecraft item exists for it: the companion mod gives it to every boss in battle (boss/BossToughness.kt), so it
|
||||
* can't be stolen, knocked off or suppressed (the work is done by a lasting condition it starts). Adrian, 2026-11-02:
|
||||
* bosses are tougher but not stronger, and no single attack takes more than a quarter of their HP.
|
||||
*
|
||||
* Showdown names the item after "name" (guardiansward), which must match this file's name. Cobblemon joins the file
|
||||
* into one line before Showdown reads it: block comments only.
|
||||
* Tunables (docs/balance.md, "Boss toughness"): DEF_FACTOR, SPD_FACTOR and HP_SHARE below.
|
||||
*/
|
||||
{
|
||||
name: "Guardian's Ward",
|
||||
spritenum: 0,
|
||||
ignoreKlutz: true,
|
||||
onTakeItem: false,
|
||||
onStart(pokemon) {
|
||||
pokemon.addVolatile('guardiansward');
|
||||
},
|
||||
condition: {
|
||||
noCopy: true,
|
||||
/* Defense and Sp. Def only while the boss defends: Body Press (its own Defense as attack) stays as it is. */
|
||||
onModifyDefPriority: 1,
|
||||
onModifyDef(def, pokemon) {
|
||||
const DEF_FACTOR = 2;
|
||||
if (this.activePokemon !== pokemon) return this.chainModify(DEF_FACTOR);
|
||||
},
|
||||
onModifySpDPriority: 1,
|
||||
onModifySpD(spd, pokemon) {
|
||||
const SPD_FACTOR = 2;
|
||||
if (this.activePokemon !== pokemon) return this.chainModify(SPD_FACTOR);
|
||||
},
|
||||
/* One attack (every hit of a multi-hit move together) takes at most HP_SHARE of the boss's max HP. */
|
||||
onDamagePriority: -100,
|
||||
onDamage(damage, target, source, effect) {
|
||||
const HP_SHARE = 0.25;
|
||||
if (!effect || effect.effectType !== 'Move') return;
|
||||
if (this.effectState.move !== effect) {
|
||||
this.effectState.move = effect;
|
||||
this.effectState.taken = 0;
|
||||
}
|
||||
const left = Math.max(0, Math.floor(target.maxhp * HP_SHARE) - this.effectState.taken);
|
||||
const dealt = Math.min(damage, left);
|
||||
this.effectState.taken += dealt;
|
||||
return dealt;
|
||||
},
|
||||
},
|
||||
num: -2001,
|
||||
gen: 9,
|
||||
}
|
||||
+5
-2
@@ -24,7 +24,7 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue
|
||||
| Sewn-in insulation | goat fur 2 cold, hoglin hide 2 heat, wool 0.5 cold, leather 0.5/0.5 (Cold Sweat: wool 1.5, leather 1/1, rabbit hide 1.5 heat) | Decided (Adrian, 2026-10-30) | `config/coldsweat/item.toml` | |
|
||||
| Goat Fur / Hoglin Hide recipes | 8 wool + 1 Heat Rock → 8 Goat Fur; 8 leather + 1 Icy Rock → 8 Hoglin Hide (a set takes 24) | Decided (Adrian, 2026-10-30) | `kubejs/server_scripts/insulation.js` | Heat and Icy Rocks come from the guardians before Frostpeak and Sunscorch (archaeology finds too) |
|
||||
| Guardian respawn | 5 hours of game time after being beaten | Decided (Adrian, 2026-10-29) | `world/zones.json` → `guardian_respawn_hours` | Fixed spots |
|
||||
| Guardian strength | strongest Pokémon spawning in the zone, band top + 5 levels, 1.5× size, uncatchable | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`GUARDIAN_*`), `world/zones.json` → `guardian.species` | Pinned by hand: Southwold Pinsir, Highland Meadows Mudsdale, Sunscorch Flygon, Frostpeak Abomasnow, Deep Dark Regigigas (normal size), Basalt Furnace Moltres (Lv 75; drops 3 Exp. Candy XL + 2 netherite scrap, placeholder) (Adrian, 2026-11-02: guardians fit their zone, a legendary for the Deep Dark) |
|
||||
| Guardian strength | strongest Pokémon spawning in the zone, band top + 5 levels, scaled to stand about 8 blocks tall (1.5× to 12× its normal size), uncatchable | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`GUARDIAN_*`), `world/zones.json` → `guardian.species` | Pinned by hand: Southwold Pinsir, Highland Meadows Mudsdale, Sunscorch Flygon, Frostpeak Abomasnow, Deep Dark Regigigas, Basalt Furnace Moltres (Lv 75; drops 3 Exp. Candy XL + 2 netherite scrap, placeholder) (Adrian, 2026-11-02: guardians fit their zone, a legendary for the Deep Dark) |
|
||||
| Guardian drops | Button Isle 10 Poké Balls + 4 Potions; Southwold 6 Great Balls + 4 Exp. Candy S; Westmarch and Jungle Mire Waders + 4 Pecha Berries + 2 Exp. Candy M; Mire and Highlands 3 Icy Rocks + 4 Super Potions; Sunscorch 3 Heat Rocks + 2 Exp. Candy L; Frostpeak and Deep Dark 3 Icy Rocks + 2 Exp. Candy L; Deep Sea Mystic Water + 3 Exp. Candy M | Decided (Adrian, 2026-10-30) | `world/zones.json` → `guardian.drops` | 3 rocks make one full fur or hide set |
|
||||
| Mire poison | Poison I for 7 s, renewed every 5 s while in the Mire without Mire Waders | Decided (Adrian, 2026-10-30) | `datapacks/core/data/cobblesurvival/zone_hazards/mire.json` | Vanilla Poison stops at half a heart |
|
||||
| Field moves (Cobblemon HMs) | mod defaults: all 39 abilities on, no level minimum, no fullness or friendship cost; Rock Smash breaks up to 32 connected ore blocks with no cooldown | Placeholder | `config/cobblemon_hms.json` (generated by the mod; the pack ships none yet) | Rock Smash mines any ore, whatever pickaxe the player has. HMs' `minLevel` is the player's XP level, not the Pokémon's |
|
||||
@@ -38,8 +38,11 @@ Every tunable lives in a config file or in this table. **Placeholder** means gue
|
||||
| Rebirth Chamber | 144,000 ticks (2 hours of server time); 3 IVs raised to 31; an Ability Patch comes with it | Placeholder | `machines/rebirth_core.json` | |
|
||||
| Boss attacks (guardians) | by guardian level: up to Lv 20 a circle under the player; up to 35 + a line from the boss; up to 50 + a cone; up to 65 + a ring around the boss at half HP; above + a wide ring at a quarter HP. Telegraph 2.5 s at the lowest tier down to 1.5 s; damage level/6 health points (Morpeko 3, Moltres 12); circle every 7.4 s down to 5.6 s, line and cone less often; Fire types burn, Ice types freeze, Poison types poison | Placeholder | `tools/zone_bosses.py` → `datapacks/core/data/cobblesurvival/bosses/*.json` | Adrian, 2026-11-02: every guardian, harder in harder zones |
|
||||
| Boss arena | 24 blocks around where the boss stood; Basalt Furnace 32; the End 40; leaving it or dying loses the fight | Placeholder | same files, `arena` | |
|
||||
| End boss | Eternatus Lv 80, 0.2× size (its model is 20 m tall), back 5 hours after a win; 12,000 XP the first time, 500 after | Placeholder | `bosses/end.json` (`tools/zone_bosses.py` → `END`) | Stands in for Giratina, which Cobblemon 1.8.4 has no model for |
|
||||
| End boss | Eternatus Lv 80, 2.5× size (15 blocks tall; it was 0.2×, about a block, until Adrian found it too small, 2026-11-02), back 5 hours after a win; 12,000 XP the first time, 500 after | Placeholder | `bosses/end.json` (`tools/zone_bosses.py` → `END`) | Stands in for Giratina, which Cobblemon 1.8.4 has no model for |
|
||||
| End spawns | the End is a zone, Lv 50–70; Cobblemon's End lines spawn there (Beheeyem, Reuniclus, Metagross; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack), all in the common bucket, each line's heaviest entry weight 10, Metagross 2 | Placeholder | `world/zones.json` → `dimensions` (`levels`, `bucket`, `line_weight`, `rare_factor`), then `tools/zone_layout.py` and `tools/zone_spawns.py` | Adrian, 2026-11-02: the End was barren; Lv 50–70 his pick |
|
||||
| Boss size | guardians about 8 blocks tall (Morpeko 4.9 at the 12× cap, Moltres 8, Regigigas 9.8 at the 1.5× floor); Eternatus 15 blocks; a cave guardian's spot needs that much air above it, else the roomiest floor within 24 blocks | Placeholder | `tools/zone_spawns.py` (`GUARDIAN_HEIGHT`, `GUARDIAN_SCALE_MIN`, `GUARDIAN_SCALE_MAX`), `tools/zone_bosses.py` (`END_HEIGHT`) | Adrian, 2026-11-02: way bigger. Area attacks that start at the boss reach half its width further |
|
||||
| Boss toughness | perfect IVs and 170 EVs each in HP, Defense and Sp. Def (510, the games' limit), nothing in Attack, Sp. Atk or Speed; in battle the Guardian's Ward: Defense and Sp. Def ×2 while defending, and one attack (all hits of a multi-hit move together) takes at most 25 % of max HP | Placeholder | `BossToughness.EVS`; `datapacks/core/data/cobblesurvival/held_items/guardiansward.js` (`DEF_FACTOR`, `SPD_FACTOR`, `HP_SHARE`) | Adrian, 2026-11-02: tougher, not stronger; the 25 % cap is his number. Applies to guardians, the End boss and practice copies |
|
||||
| Machine look | a working core glows (light 10), its front animates (8 frames, 3 ticks each) and particles show; it keeps looking busy 2 s after the work stops; the Pokémon inside flinches (red flash, hit sound, no damage) about every 15 s while it works | Placeholder | `MachineBlock.ACTIVE_LIGHT`, `tools/machine_assets.py` (`FRAMES`, `FRAME_TICKS`), `MachineEffects` (particle and sound chances), `MachineBlockEntity` (`ACTIVE_LINGER`, `FLINCH_ONE_IN`) | Adrian, 2026-11-02. Working means: a heated furnace has something to cook, the generator isn't full, the synthesizer gets energy, a rebirth runs, the altar holds a Pokémon |
|
||||
| Level pressure | full experience up to 5 levels above the zone's band, then 10 % less per level, at least 10 % (Exp. Candy and commands exempt) | Decided (Adrian, 2026-10-30) | `world/zones.json` → `level_pressure` | E.g. on Button Isle (up to Lv 10): Lv 15 full, Lv 20 half, Lv 24+ a tenth |
|
||||
| Strong flyer | ridable through the air with top speed ≥ 60 or top stamina ≥ 85 (Cobblemon ride stats) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`STRONG_FLYER_*`) | 29 species |
|
||||
| High zone for strong flyers | band starts at Lv 30 or more (Sunscorch, Frostpeak, Deep Sea, Deep Dark, Basalt Furnace) | Decided (Adrian, 2026-10-30) | `tools/zone_spawns.py` (`HIGH_ZONE_LEVEL`) | |
|
||||
|
||||
@@ -20,6 +20,7 @@ The middle of each side wall and the top centre may be glass, to see the Pokémo
|
||||
|
||||
- Send the Pokémon out near the core and use the core with an empty hand: it walks in and leaves the party. Use the core again to take it back. Sneak-use shows what the machine is doing (for the altar: what a sacrifice gives).
|
||||
- Breaking the core or the shell sends it home (party, else PC), even if you're offline. Automated: `machine_pokemon_goes_in_and_out`, `machine_refuses_what_doesnt_fit`, `machine_shell_break_returns_pokemon`, `machine_core_break_returns_pokemon_to_pc`, `machine_survives_unload`, `machine_survives_restart`.
|
||||
- **A working machine shows it** (added 2026-11-02): the core glows, its front animates (flickering fire on the furnace core, pulsing emblems on the others) and particles show around the Pokémon and at the core; about every 15 s the Pokémon flinches (red flash, hit sound) without taking damage. Automated in `heat_core_cooks_with_hoppers`.
|
||||
- Each machine's work: `heat_core_cooks_with_hoppers`, `dynamo_core_powers_cables`, `synthesis_core_rolls_drops`, `altar_core_sacrifice_needs_confirmation`, `rebirth_core_makes_a_shiny_first_stage`.
|
||||
|
||||
## In game (Prism instance)
|
||||
@@ -31,6 +32,7 @@ Elsewhere, creative mode gives the cores in the Functional Blocks tab; each has
|
||||
- [ ] Use a bare core (third row): ghost blocks show its shell for ten seconds, and chat lists what's missing. Place a new core anywhere: the same.
|
||||
- [ ] Hover a core in the inventory: the tooltip says what it does and what its shell is made of.
|
||||
- [ ] Look at a working machine from the front: the core is see-through (frame and tinted glass), the Pokémon shows behind it.
|
||||
- [ ] Watch the working machines in the first row: each core glows with an animated front (the furnace core's fire flickers), particles show in the chamber (flames, sparks, glyphs, souls, end rods) and at the core; now and then the Pokémon flashes red with a hit sound. An empty machine, or a furnace with nothing to cook, stays dark.
|
||||
- [ ] Sacrifice a Pokémon, or finish a rebirth: the Pokémon in the chamber faints with its cry pitched down, then disappears (a reborn one comes back in its new form).
|
||||
|
||||
- [ ] Build a Heat Core shell of bricks with glass in a side window, send out a Fire type and use the core: the Pokémon stands in the chamber, small enough to fit, and doesn't move. **Does it look right?** (the roadmap asks you to confirm this)
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
- **Boss fights:** a battle with a guardian (or the End boss) becomes a fight with area attacks: red or coloured particles on the ground show where an attack lands, about 1.5–2.5 seconds later it hits whoever stands inside. Circles, lines, cones and rings; harder zones get more of them. Fire, Ice and Poison bosses burn, freeze or poison. Automated: `boss_attack_shapes`, `boss_attacks_land_and_stop_with_the_battle`.
|
||||
- **Arena:** leaving it (24 blocks; Basalt 32; End 40) or dying ends the battle as lost; the boss heals and stays. Automated: `boss_arena_leaving_loses`.
|
||||
- **The End:** the Ender Dragon is gone; Eternatus appears on the main island, with the dragon's boss bar; beating it opens the exit portal and gateway, leaves the egg the first time and gives experience. Automated: `end_boss_replaces_the_dragon`.
|
||||
- **Big and tough bosses** (Adrian, 2026-11-02): guardians stand about 8 blocks tall, Eternatus 15. Only HP, Defense and Sp. Def are raised (training, and the Guardian's Ward in battle doubles Defense and Sp. Def); no single attack takes more than a quarter of their HP. Guardians already in your world grow on the next check. Automated: `practice_bosses_spawn`, `boss_takes_a_quarter_per_attack`.
|
||||
- **End spawns:** the End is a zone (Lv 50–70) with Cobblemon's End lines: Beheeyem, Reuniclus, Metagross; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack. Automated: `end_has_spawns`.
|
||||
|
||||
## In game (Prism instance)
|
||||
@@ -22,6 +23,8 @@ On the island save (or a new one), in creative or survival:
|
||||
- [ ] Leave the End and come back without beating Eternatus: still only one (fixed 2026-11-02).
|
||||
- [ ] Go to the End: no dragon; Eternatus stands south of the portal, the boss bar shows its HP. Beat it: the exit portal opens, the egg appears.
|
||||
- [ ] Find Regigigas in a Deep Dark cave and Moltres in the Basalt Furnace.
|
||||
- [ ] Go to the End: Eternatus is huge now (15 blocks). Fight it: no attack takes more than a quarter of its HP bar, even a super-effective one; it needs at least four hits. Its attacks are as strong as before.
|
||||
- [ ] Visit a guardian (`/cobblesurvival boss <zone>` spawns a practice copy next to you): about 8 blocks tall; Regigigas and Moltres stand in caves with room above them.
|
||||
- [ ] Walk around the End's islands for a minute: Beheeyem, Reuniclus and now and then Metagross appear, Lv 50–70 (`/cobblesurvival zone` shows "The End").
|
||||
|
||||
## Ask Adrian
|
||||
|
||||
+6
-2
@@ -261,6 +261,8 @@ Adrian's choices: the Large Furnace has a Fire Pokémon heat real furnaces, and
|
||||
|
||||
Self-tests (`MachineTests`): in and out, wrong Pokémon refused, broken shell and broken core (to the PC when the party is full), chunk unload, server restart, and each machine's work (a furnace fed and emptied by hoppers without fuel, a Poke Power cable charged by a Dynamo Core, Magikarp's salmon through a hopper under a gold Synthesis Core, a confirmed sacrifice with a Lv 100 Dragonite's rewards, a rebirth from Charizard to a shiny Lv 1 Charmander with its moves and ability).
|
||||
|
||||
**A working machine shows it** (Adrian, 2026-11-02: particles around the Pokémon and an animated core, like the fire in a furnace; the Pokémon should now and then look as if it takes a hit). While its machine works, the core's block state is `active`: the core gives off light, its front switches to an animated face (the furnace's flame flickers; the other emblems pulse with sparks rising; `tools/machine_assets.py`), and particles show (`MachineEffects`, client side): flames around a Fire Pokémon and at the furnace core's front with smoke and the furnace crackle; electric sparks in the generator; enchanting glyphs flowing into the Pokémon in the synthesizer; souls and soul fire at the altar; end rods and glyphs in the rebirth chamber. It stays active two seconds after the work stops, so it doesn't flicker. About every 15 seconds of work the Pokémon inside flinches: it flashes red and Cobblemon's hit sound plays, but nothing is hurt (`MachineDisplay.flinch`). Working means: a heated furnace has something to cook, the generator isn't full, the synthesizer gets energy, a rebirth runs, the altar holds a Pokémon. Self-test: `heat_core_cooks_with_hoppers` also checks that the core turns active while cooking and back afterwards, and that a flinch leaves the Pokémon unharmed.
|
||||
|
||||
**Generator.** The Dynamo Core stores Forge Energy and pushes it to whatever touches the core and takes energy (cables, machines from other mods); its output per tick depends on the Pokémon's level and Special Attack, in line with Poke Power's 50 FE per level.
|
||||
|
||||
## Phase 7: boss battles with area attacks (Adrian, 2026-11-02)
|
||||
@@ -274,7 +276,7 @@ Adrian's choices: every zone guardian gets area attacks (more and harder in hard
|
||||
| Highland Meadows | Cinderace | **Mudsdale** | a draft horse of highland pastures, not a starter |
|
||||
| Sunscorch | Delphox | **Flygon** | the desert spirit of sandstorms |
|
||||
| Frostpeak Range | Tyranitar | **Abomasnow** | lives on snowy mountains; spawns there |
|
||||
| Deep Dark | Crobat | **Regigigas** (normal size) | a legendary; the ancient colossus sleeping in ruins, like the Ancient Cities |
|
||||
| Deep Dark | Crobat | **Regigigas** | a legendary; the ancient colossus sleeping in ruins, like the Ancient Cities |
|
||||
| Basalt Furnace | none | **Moltres** (Lv 75) | the flame bird of the volcano; stands in for Groudon |
|
||||
| The End | Ender Dragon | **Eternatus** | the cosmic final boss; stands in for Giratina |
|
||||
|
||||
@@ -288,7 +290,9 @@ The others fit and stay: Morpeko (Button Isle), Pinsir (Southwold), Scyther (Wes
|
||||
|
||||
Self-tests (`BossTests`): every guardian has a fight and a Cobblemon model; each attack shape hits a player standing in it and misses one ten blocks away; attacks stop when the battle ends; leaving the arena ends the battle and the boss heals; the dragon can't join the End, Eternatus appears, and beating it opens the exit portal with the egg.
|
||||
|
||||
**Eternatus** replaces the Ender Dragon: the dragon no longer spawns; Eternatus (uncatchable, Lv 80, scaled down to fit) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win). Only one at a time: it waits until the entities around its spot have loaded before deciding it's missing, and a second copy never joins while the recorded one is alive (Adrian met a second Eternatus on his second visit, 2026-11-02); extra copies left in older worlds are removed.
|
||||
**Eternatus** replaces the Ender Dragon: the dragon no longer spawns; Eternatus (uncatchable, Lv 80, 15 blocks tall) appears on the main End island while a player is there, and beating it does what killing the dragon did (exit portal, gateways, the egg on the first win). Only one at a time: it waits until the entities around its spot have loaded before deciding it's missing, and a second copy never joins while the recorded one is alive (Adrian met a second Eternatus on his second visit, 2026-11-02); extra copies left in older worlds are removed.
|
||||
|
||||
**Bosses are big and tough, not strong** (Adrian, 2026-11-02: Eternatus was too small and too easy; guardians should be way bigger and tougher; only defensive stats go up, never Attack or Speed; no single attack may take more than a quarter of a boss's HP). Every boss (guardian, End boss, practice copy) is scaled to stand about 8 blocks tall (Eternatus 15; `tools/zone_spawns.py`, `tools/zone_bosses.py`) and gets perfect IVs and 170 EVs each in HP, Defense and Sp. Def, nothing offensive (`BossToughness`). In battle it brings the **Guardian's Ward**, a held item that exists only inside Cobblemon's battle engine (Showdown): Cobblemon loads it from `datapacks/core/data/cobblesurvival/held_items/guardiansward.js`, and the companion mod hands it to every boss ahead of whatever it holds, so it can't be stolen, knocked off or suppressed. It starts a lasting condition that doubles Defense and Sp. Def while the boss defends (not for Body Press) and caps one attack, all hits of a multi-hit move together, at a quarter of the boss's max HP. Guardians and an Eternatus already standing in a world grow and get their training on the next check outside battle; a cave guardian moves to a spot it fits in. Area attacks that start at the boss (rings, lines, cones) reach half its width further, so a big boss's ring starts at its edge. Self-tests: `practice_bosses_spawn` (size, training, full health) and `boss_takes_a_quarter_per_attack` (a real battle: a Lv 100 Garchomp's Bullet Seed, every hit enough to knock out the Lv 15 boss, takes exactly a quarter per use).
|
||||
|
||||
**The End has Pokémon** (Adrian, 2026-11-02: it looked barren). The End is a zone of its own, "The End", Lv 50–70 (`world/zones.json` → `dimensions`; `Zones.at` returns it anywhere in the End). Every line Cobblemon spawns in End biomes spawns there too, on top of its home on the island: Beheeyem, Reuniclus and Metagross anywhere; Gothitelle and Sigilyph in End cities; Malamar and Unown with a Poké Snack. Gothita had no island home (it only spawns in the End), so it's new. Cobblemon has all of these uncommon or rare, and it rolls the bucket before the Pokémon, so in the End they move to the common bucket or the End would stay nearly empty; each line weighs the same, Metagross a fifth. Being a zone, the End also has level pressure (full experience up to Lv 75, like the Basalt Furnace) and the wild-Pokémon cleanup above Lv 80; Eternatus is exempt as an uncatchable boss. Self-test `end_has_spawns`.
|
||||
|
||||
|
||||
+2
-1
@@ -182,9 +182,10 @@ Goal: a quest book that points players onward, laid out like a research tree who
|
||||
- **Soft dependencies.** Use FTB Quests' Flexible progression mode, where a quest's tasks can be completed before its dependencies. A community fix mod reports a [soft-lock in that mode](https://modrinth.com/project/SWZGEk5x) when tasks finish first; test for it and add the fix if needed. Hide the per-quest lock icon ("Hide Lock Icon") so nothing looks locked, and keep dependency lines visible.
|
||||
- **Layout.** One chapter per zone plus chapters for base building, Pokémon jobs and machines. Each chapter reads left to right, from basics to advanced.
|
||||
- **Content.** Tasks: catch a species, obtain a drop, visit a biome, build a machine, beat a guardian. Rewards stay small and never unlock anything. Cobblemon-specific task types come from a Cobblemon–FTB Quests integration addon; check its 1.8 support.
|
||||
- **Drop sources in JEI** (Adrian, 2026-11-02). Every item that can drop gets a JEI page (JEI is the pack's NEI) showing where it drops: which Pokémon (chance, amount, and the zone they spawn in), guardian and End boss rewards, the Sacrifice Altar's level tiers, the Drop Synthesizer, zone ore veins and the pasture mining/finder tables. Generate the pages from the data the game itself uses (species drops, `guardians/`, `bosses/`, `machines/`, loot tables) through a JEI plugin in the companion mod, so they can't drift. First check whether a Cobblemon–JEI addon already shows Pokémon drops on 1.8 and build on it if so.
|
||||
- **Source of truth.** Write quests in `docs/quests.yaml` (id, title, description, tasks, soft dependencies, grid position) and generate FTB Quests SNBT with a script in `tools/`. Adrian can then rearrange nodes in the in-game editor; note which file wins on regeneration.
|
||||
|
||||
**Done when:** every zone has a chapter, every quest is completable in any order, and the generator round-trips without losing Adrian's manual position edits.
|
||||
**Done when:** every zone has a chapter, every quest is completable in any order, the generator round-trips without losing Adrian's manual position edits, and every item that can drop has a JEI page naming all its sources.
|
||||
|
||||
## Phase 9 – Balancing and release
|
||||
|
||||
|
||||
@@ -64,7 +64,7 @@ Beldum (Metagross), Elgyem (Beheeyem), Gothita (Gothitelle), Inkay (Malamar), Si
|
||||
|
||||
## Alpha guardians
|
||||
|
||||
One per zone at its sample point, uncatchable, five levels above the band; it returns 5 hours after it is beaten. Its drops help with the next zone.
|
||||
One per zone at its sample point, uncatchable, about 8 blocks tall, five levels above the band, with full HP, Defense and Sp. Def training and the Guardian's Ward in battle; it returns 5 hours after it is beaten. Its drops help with the next zone.
|
||||
|
||||
| Zone | Guardian | Level | Drops |
|
||||
| --- | --- | --- | --- |
|
||||
|
||||
@@ -11,6 +11,7 @@ import dev.cobblesurvival.ore.OreNodes
|
||||
import dev.cobblesurvival.permadeath.Permadeath
|
||||
import dev.cobblesurvival.boss.BossCommands
|
||||
import dev.cobblesurvival.boss.BossFights
|
||||
import dev.cobblesurvival.boss.BossToughness
|
||||
import dev.cobblesurvival.boss.EndBoss
|
||||
import dev.cobblesurvival.machine.MachineShowcase
|
||||
import dev.cobblesurvival.machine.Machines
|
||||
@@ -78,6 +79,7 @@ class CobbleSurvival {
|
||||
NeoForge.EVENT_BUS.addListener(Zones::onAddReloadListeners)
|
||||
Guardians.register()
|
||||
BossFights.register()
|
||||
BossToughness.register()
|
||||
EndBoss.register()
|
||||
Gates.register()
|
||||
ShoulderPassives.register()
|
||||
|
||||
@@ -53,8 +53,9 @@ object BossCommands {
|
||||
Triple(guardian.pokemon, guardian.scale, BossDefs.byGuardian[which] ?: return null)
|
||||
}
|
||||
val pokemon = PokemonProperties.parse("$properties uncatchable=yes").create()
|
||||
pokemon.scaleModifier = scale
|
||||
pokemon.persistentData.putString(BossFights.TAG, def.id.toString())
|
||||
BossToughness.toughen(pokemon, scale)
|
||||
pokemon.heal()
|
||||
val entity = PokemonEntity(level, pokemon)
|
||||
entity.moveTo(pos.x, pos.y, pos.z, 0f, 0f)
|
||||
entity.setPersistenceRequired()
|
||||
|
||||
@@ -36,14 +36,18 @@ object BossDefs : SimpleJsonResourceReloadListener(Gson(), "bosses") {
|
||||
val effect: Effect,
|
||||
val trigger: Trigger,
|
||||
) {
|
||||
/** Where this pattern lands, aimed from [boss] at [target]. */
|
||||
fun area(boss: Vec3, target: Vec3): Area {
|
||||
/**
|
||||
* Where this pattern lands, aimed from [boss] at [target]. Shapes that start at the boss reach [body] further
|
||||
* (the boss's half width): a big boss's ring starts at its edge, not inside it.
|
||||
*/
|
||||
fun area(boss: Vec3, target: Vec3, body: Double = 0.0): Area {
|
||||
val origin = if (aim == Aim.BOSS) boss else target
|
||||
val grow = if (aim == Aim.BOSS) body else 0.0
|
||||
return when (shape) {
|
||||
Shape.CIRCLE -> Area.Circle(origin, radius)
|
||||
Shape.RING -> Area.Ring(origin, inner, outer)
|
||||
Shape.LINE -> Area.Line(boss, heading(boss, target), length, width / 2)
|
||||
Shape.CONE -> Area.Cone(boss, heading(boss, target), length, Math.toRadians(angle / 2))
|
||||
Shape.CIRCLE -> Area.Circle(origin, radius + grow)
|
||||
Shape.RING -> Area.Ring(origin, inner + grow, outer + grow)
|
||||
Shape.LINE -> Area.Line(boss, heading(boss, target), length + body, width / 2)
|
||||
Shape.CONE -> Area.Cone(boss, heading(boss, target), length + body, Math.toRadians(angle / 2))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -79,7 +79,7 @@ object BossFights {
|
||||
fights += fight
|
||||
battle.players.forEach {
|
||||
it.sendSystemMessage(Component.translatableWithFallback("message.cobblesurvival.boss.start",
|
||||
"Boss fight: watch the ground and dodge its attacks. Leaving the arena (%s blocks) loses the fight.",
|
||||
"Boss fight: watch the ground and dodge its attacks. Leaving the arena (%s blocks) loses the fight. No single attack takes more than a quarter of its HP.",
|
||||
def.arena.toInt()).withStyle(ChatFormatting.GOLD))
|
||||
}
|
||||
CobbleSurvival.LOGGER.info("Boss fight {} begins against {}", def.id, battle.players.joinToString { it.gameProfile.name })
|
||||
@@ -135,7 +135,7 @@ object BossFights {
|
||||
/** Starts [pattern], aimed at [target]: it telegraphs, then lands. */
|
||||
fun cast(fight: Fight, pattern: BossDefs.Pattern, target: Vec3): Telegraph {
|
||||
val level = fight.boss.level() as ServerLevel
|
||||
val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target), level.gameTime + pattern.telegraphTicks)
|
||||
val telegraph = Telegraph(pattern, pattern.area(fight.boss.position(), target, fight.boss.bbWidth / 2.0), level.gameTime + pattern.telegraphTicks)
|
||||
fight.telegraphs += telegraph
|
||||
val origin = telegraph.area.origin
|
||||
level.playSound(null, origin.x, origin.y, origin.z, SoundEvents.NOTE_BLOCK_BELL.value(), SoundSource.HOSTILE, 1.2f, 0.6f)
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package dev.cobblesurvival.boss
|
||||
|
||||
import com.cobblemon.mod.common.api.Priority
|
||||
import com.cobblemon.mod.common.api.battles.interpreter.BattleMessage
|
||||
import com.cobblemon.mod.common.api.battles.model.PokemonBattle
|
||||
import com.cobblemon.mod.common.api.pokemon.helditem.HeldItemManager
|
||||
import com.cobblemon.mod.common.api.pokemon.helditem.HeldItemProvider
|
||||
import com.cobblemon.mod.common.api.pokemon.stats.Stats
|
||||
import com.cobblemon.mod.common.battles.pokemon.BattlePokemon
|
||||
import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
|
||||
import com.cobblemon.mod.common.pokemon.IVs
|
||||
import com.cobblemon.mod.common.pokemon.Pokemon
|
||||
import dev.cobblesurvival.zone.Guardians
|
||||
import net.minecraft.network.chat.Component
|
||||
|
||||
/**
|
||||
* Bosses are big and tough, not strong (Adrian, 2026-11-02: Eternatus was too small and too easy; guardians should be
|
||||
* way bigger and tougher; only their defensive stats go up, never Attack or Speed; no single attack may take more
|
||||
* than a quarter of their HP). Every boss (zone guardian, End boss, practice copy) gets:
|
||||
* - its size ([toughen]'s scale, from tools/zone_spawns.py and tools/zone_bosses.py);
|
||||
* - perfect IVs and [EVS] EVs in HP, Defense and Sp. Def, nothing else (510 in all, the games' limit);
|
||||
* - in battle the Guardian's Ward, a Showdown held item no Minecraft item stands for
|
||||
* (`data/cobblesurvival/held_items/guardiansward.js`): Defense and Sp. Def multiplied, and at most a quarter of
|
||||
* its max HP lost to one attack. This provider hands it out ahead of whatever the boss really holds.
|
||||
*/
|
||||
object BossToughness : HeldItemManager {
|
||||
const val WARD = "guardiansward"
|
||||
const val EVS = 170
|
||||
private val DEFENSIVE = listOf(Stats.HP, Stats.DEFENCE, Stats.SPECIAL_DEFENCE)
|
||||
|
||||
fun register() = HeldItemProvider.register(this, Priority.HIGHEST)
|
||||
|
||||
fun isBoss(pokemon: Pokemon): Boolean {
|
||||
val data = pokemon.persistentData
|
||||
return data.contains(Guardians.TAG) || data.contains(EndBoss.TAG) || data.contains(BossFights.TAG)
|
||||
}
|
||||
|
||||
/** Gives [pokemon] its boss [scale] and training; true if anything changed (a boss from before these rules). */
|
||||
fun toughen(pokemon: Pokemon, scale: Float): Boolean {
|
||||
var changed = false
|
||||
if (pokemon.scaleModifier != scale) {
|
||||
pokemon.scaleModifier = scale
|
||||
changed = true
|
||||
}
|
||||
for (stat in DEFENSIVE) {
|
||||
if (pokemon.ivs[stat] != IVs.MAX_VALUE) {
|
||||
pokemon.setIV(stat, IVs.MAX_VALUE)
|
||||
changed = true
|
||||
}
|
||||
if (pokemon.evs[stat] != EVS) {
|
||||
pokemon.setEV(stat, EVS)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
return changed
|
||||
}
|
||||
|
||||
/** [toughen]s a boss already in the world, outside battle: at full health and its new size. */
|
||||
fun refresh(entity: PokemonEntity, scale: Float): Boolean {
|
||||
if (entity.isBattling || !toughen(entity.pokemon, scale)) return false
|
||||
entity.pokemon.heal()
|
||||
entity.refreshDimensions()
|
||||
return true
|
||||
}
|
||||
|
||||
override fun showdownId(pokemon: BattlePokemon): String? = WARD.takeIf { isBoss(pokemon.effectedPokemon) }
|
||||
|
||||
override fun nameOf(showdownId: String): Component =
|
||||
Component.translatableWithFallback("item.cobblesurvival.guardian_ward", "Guardian's Ward")
|
||||
|
||||
override fun handleStartInstruction(pokemon: BattlePokemon, battle: PokemonBattle, message: BattleMessage) {}
|
||||
|
||||
override fun handleEndInstruction(pokemon: BattlePokemon, battle: PokemonBattle, message: BattleMessage) {}
|
||||
|
||||
override fun give(pokemon: BattlePokemon, itemID: String) {}
|
||||
|
||||
override fun take(pokemon: BattlePokemon, itemID: String) {}
|
||||
}
|
||||
@@ -89,6 +89,7 @@ object EndBoss {
|
||||
.filter { it !== (boss ?: it) }
|
||||
.forEach { it.discard() }
|
||||
if (boss != null) {
|
||||
BossToughness.refresh(boss, def.scale)
|
||||
if (boss.distanceToSqr(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5) > LEASH * LEASH) boss.teleportTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5)
|
||||
level.dragonFight?.let { fight ->
|
||||
(bossBar.get(fight) as ServerBossEvent).apply {
|
||||
@@ -111,8 +112,9 @@ object EndBoss {
|
||||
fun spawn(level: ServerLevel): PokemonEntity? {
|
||||
val def = BossDefs.end?.end ?: return null
|
||||
val pokemon = PokemonProperties.parse("${def.species} level=${def.level} uncatchable=yes").create()
|
||||
pokemon.scaleModifier = def.scale
|
||||
pokemon.persistentData.putBoolean(TAG, true)
|
||||
BossToughness.toughen(pokemon, def.scale)
|
||||
pokemon.heal()
|
||||
val entity = PokemonEntity(level, pokemon)
|
||||
val spot = spot(level)
|
||||
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, 180f, 0f)
|
||||
|
||||
@@ -22,18 +22,22 @@ import net.minecraft.world.level.block.entity.BlockEntityType
|
||||
import net.minecraft.world.level.block.state.BlockState
|
||||
import net.minecraft.world.level.block.state.StateDefinition
|
||||
import net.minecraft.world.level.block.state.properties.BlockStateProperties
|
||||
import net.minecraft.world.level.block.state.properties.BooleanProperty
|
||||
import net.minecraft.world.phys.BlockHitResult
|
||||
|
||||
/** A machine's core block; which machine it is comes from its id ([MachineDefs]). It faces the player who placed it. */
|
||||
/**
|
||||
* A machine's core block; which machine it is comes from its id ([MachineDefs]). It faces the player who placed it.
|
||||
* While its machine works it is [ACTIVE]: it glows, its face animates and particles show ([MachineEffects]).
|
||||
*/
|
||||
class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
|
||||
init {
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH))
|
||||
registerDefaultState(stateDefinition.any().setValue(FACING, Direction.NORTH).setValue(ACTIVE, false))
|
||||
}
|
||||
|
||||
override fun codec(): MapCodec<out BaseEntityBlock> = CODEC
|
||||
|
||||
override fun createBlockStateDefinition(builder: StateDefinition.Builder<Block, BlockState>) {
|
||||
builder.add(FACING)
|
||||
builder.add(FACING, ACTIVE)
|
||||
}
|
||||
|
||||
override fun getStateForPlacement(context: BlockPlaceContext): BlockState =
|
||||
@@ -55,7 +59,7 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
|
||||
override fun newBlockEntity(pos: BlockPos, state: BlockState): BlockEntity = MachineBlockEntity(pos, state)
|
||||
|
||||
override fun <T : BlockEntity> getTicker(level: Level, state: BlockState, type: BlockEntityType<T>): BlockEntityTicker<T>? =
|
||||
if (level.isClientSide) null
|
||||
if (level.isClientSide) createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, pos, tickState, _ -> MachineEffects.clientTick(tickLevel, pos, tickState) })
|
||||
else createTickerHelper(type, Machines.BLOCK_ENTITY.get(), BlockEntityTicker { tickLevel, _, _, core -> core.serverTick(tickLevel as ServerLevel) })
|
||||
|
||||
override fun useWithoutItem(state: BlockState, level: Level, pos: BlockPos, player: Player, hitResult: BlockHitResult): InteractionResult {
|
||||
@@ -71,6 +75,9 @@ class MachineBlock(properties: Properties) : BaseEntityBlock(properties) {
|
||||
|
||||
companion object {
|
||||
val FACING = BlockStateProperties.HORIZONTAL_FACING
|
||||
val ACTIVE: BooleanProperty = BooleanProperty.create("active")
|
||||
/** Light a working core gives off. */
|
||||
const val ACTIVE_LIGHT = 10
|
||||
val CODEC: MapCodec<MachineBlock> = simpleCodec(::MachineBlock)
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import net.minecraft.server.level.ServerLevel
|
||||
import net.minecraft.server.level.ServerPlayer
|
||||
import net.minecraft.world.Containers
|
||||
import net.minecraft.world.item.ItemStack
|
||||
import net.minecraft.world.level.block.Block
|
||||
import net.minecraft.world.level.block.entity.BlockEntity
|
||||
import net.minecraft.world.level.block.state.BlockState
|
||||
import net.minecraft.world.phys.AABB
|
||||
@@ -57,6 +58,8 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
|
||||
/** No Pokémon shown before this game time (the old one is still fainting). */
|
||||
private var showFrom = 0L
|
||||
private var ticks = 0
|
||||
/** The core shows as working ([MachineBlock.ACTIVE]) until this game time; work keeps pushing it on. */
|
||||
private var activeUntil = 0L
|
||||
|
||||
val def: MachineDefs.Def? get() = MachineDefs.byCore[BuiltInRegistries.BLOCK.getKey(blockState.block)]
|
||||
val machineName: Component get() = blockState.block.name
|
||||
@@ -73,9 +76,22 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
|
||||
if (display?.isAlive != true && level.gameTime >= showFrom) display = MachineDisplay.spawn(level, pokemon, worldPosition, shell!!.chamber, facing())
|
||||
}
|
||||
MachineWork.tick(this, level, def)
|
||||
showWork(level, def)
|
||||
receivedThisTick = 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows that the machine works (Adrian, 2026-11-02): the core turns [MachineBlock.ACTIVE] (it glows, its face
|
||||
* animates, particles show) and stays so [ACTIVE_LINGER] ticks after the work stops, so it doesn't flicker; now and
|
||||
* then the Pokémon inside flinches as if hit, without taking damage ([MachineDisplay.flinch]).
|
||||
*/
|
||||
private fun showWork(level: ServerLevel, def: MachineDefs.Def) {
|
||||
if (MachineWork.working(this, level, def)) activeUntil = level.gameTime + ACTIVE_LINGER
|
||||
val active = level.gameTime < activeUntil
|
||||
if (blockState.getValue(MachineBlock.ACTIVE) != active) level.setBlock(worldPosition, blockState.setValue(MachineBlock.ACTIVE, active), Block.UPDATE_CLIENTS)
|
||||
if (active && level.random.nextInt(FLINCH_ONE_IN) == 0) display?.takeIf { it.isAlive }?.let { MachineDisplay.flinch(level, it) }
|
||||
}
|
||||
|
||||
private fun refreshShell(level: ServerLevel, def: MachineDefs.Def) {
|
||||
val min = worldPosition.offset(-SHELL_REACH, -SHELL_REACH, -SHELL_REACH)
|
||||
val max = worldPosition.offset(SHELL_REACH, SHELL_REACH, SHELL_REACH)
|
||||
@@ -268,6 +284,10 @@ class MachineBlockEntity(pos: BlockPos, state: BlockState) : BlockEntity(Machine
|
||||
private const val SHELL_REACH = 2
|
||||
/** How near the core a sent-out Pokémon must be to walk in. */
|
||||
private const val REACH = 16.0
|
||||
/** Ticks a core keeps showing as working after its work stops. */
|
||||
private const val ACTIVE_LINGER = 40
|
||||
/** While working, the Pokémon inside flinches on average once in this many ticks (15 s). */
|
||||
private const val FLINCH_ONE_IN = 300
|
||||
|
||||
fun tell(player: ServerPlayer, message: Component) = player.sendSystemMessage(message)
|
||||
}
|
||||
|
||||
@@ -65,6 +65,18 @@ object MachineDisplay {
|
||||
fading += Fading(entity, level.gameTime + FAINT_TICKS)
|
||||
}
|
||||
|
||||
/**
|
||||
* The shown Pokémon flinches as if hit (Adrian, 2026-11-02): it flashes red as a hurt entity does and a hit sounds,
|
||||
* but it takes no damage (the display copy can't, and the Pokémon in the core is untouched).
|
||||
*/
|
||||
fun flinch(level: ServerLevel, entity: PokemonEntity) {
|
||||
level.broadcastDamageEvent(entity, level.damageSources().generic())
|
||||
level.playSound(null, entity.x, entity.y, entity.z, HIT, SoundSource.NEUTRAL, 0.7f, 0.9f + level.random.nextFloat() * 0.2f)
|
||||
}
|
||||
|
||||
/** Cobblemon's own sound for a Pokémon taking a hit in battle. */
|
||||
private val HIT = SoundEvent.createVariableRangeEvent(ResourceLocation.fromNamespaceAndPath("cobblemon", "impact.normal"))
|
||||
|
||||
fun isDisplay(entity: Entity?): Boolean = entity is PokemonEntity && entity.pokemon.persistentData.contains(TAG)
|
||||
|
||||
fun spawn(level: ServerLevel, pokemon: Pokemon, core: BlockPos, chamber: BlockPos, facing: Direction): PokemonEntity {
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package dev.cobblesurvival.machine
|
||||
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.particles.ParticleOptions
|
||||
import net.minecraft.core.particles.ParticleTypes
|
||||
import net.minecraft.core.registries.BuiltInRegistries
|
||||
import net.minecraft.sounds.SoundEvent
|
||||
import net.minecraft.sounds.SoundEvents
|
||||
import net.minecraft.sounds.SoundSource
|
||||
import net.minecraft.world.level.Level
|
||||
import net.minecraft.world.level.block.state.BlockState
|
||||
import kotlin.math.PI
|
||||
import kotlin.math.cos
|
||||
import kotlin.math.sin
|
||||
|
||||
/**
|
||||
* What a working machine looks like (Adrian, 2026-11-02: particles around the Pokémon and an animated core show that
|
||||
* something is happening, like the fire in a furnace). Client side, from the core's [MachineBlock.ACTIVE] state:
|
||||
* particles rise around the Pokémon in the chamber and flicker at the core's face, now and then with a sound. The core's
|
||||
* animated face and its glow come with the active state itself (models from tools/machine_assets.py, light in [Machines]).
|
||||
*/
|
||||
object MachineEffects {
|
||||
/** [chamber] around the Pokémon, [face] at the core's front (with [smoke] beside it), [sound] now and then. */
|
||||
private class Look(val chamber: ParticleOptions, val face: ParticleOptions, val smoke: ParticleOptions?, val sound: SoundEvent?, val volume: Float = 0.6f)
|
||||
|
||||
private val LOOKS = mapOf(
|
||||
"heat_core" to Look(ParticleTypes.FLAME, ParticleTypes.FLAME, ParticleTypes.SMOKE, SoundEvents.FURNACE_FIRE_CRACKLE, 1f),
|
||||
"dynamo_core" to Look(ParticleTypes.ELECTRIC_SPARK, ParticleTypes.ELECTRIC_SPARK, null, SoundEvents.COPPER_BULB_TURN_ON, 0.3f),
|
||||
"synthesis_core" to Look(ParticleTypes.ENCHANT, ParticleTypes.HAPPY_VILLAGER, null, SoundEvents.BEACON_AMBIENT, 0.3f),
|
||||
"altar_core" to Look(ParticleTypes.SOUL, ParticleTypes.SOUL_FIRE_FLAME, ParticleTypes.SMOKE, SoundEvents.SCULK_CATALYST_BLOOM, 0.5f),
|
||||
"rebirth_core" to Look(ParticleTypes.END_ROD, ParticleTypes.ENCHANT, null, SoundEvents.AMETHYST_BLOCK_CHIME),
|
||||
)
|
||||
|
||||
/** Per tick: chance of a particle around the Pokémon, at the face, and of a sound. */
|
||||
private const val CHAMBER_CHANCE = 0.35
|
||||
private const val FACE_CHANCE = 0.2
|
||||
private const val SOUND_CHANCE = 0.02
|
||||
|
||||
fun clientTick(level: Level, pos: BlockPos, state: BlockState) {
|
||||
if (!state.getValue(MachineBlock.ACTIVE)) return
|
||||
val look = LOOKS[BuiltInRegistries.BLOCK.getKey(state.block).path] ?: return
|
||||
val random = level.random
|
||||
val facing = state.getValue(MachineBlock.FACING)
|
||||
val chamber = pos.relative(facing.opposite)
|
||||
if (random.nextDouble() < CHAMBER_CHANCE) {
|
||||
val angle = random.nextDouble() * 2 * PI
|
||||
val radius = 0.35 + random.nextDouble() * 0.15
|
||||
val x = chamber.x + 0.5 + cos(angle) * radius
|
||||
val y = chamber.y + 0.1 + random.nextDouble() * 0.8
|
||||
val z = chamber.z + 0.5 + sin(angle) * radius
|
||||
// Enchanting glyphs fly from where they start to the given offset's origin: here, into the Pokémon.
|
||||
if (look.chamber == ParticleTypes.ENCHANT) level.addParticle(look.chamber, chamber.x + 0.5, chamber.y + 0.5, chamber.z + 0.5, x - chamber.x - 0.5, y - chamber.y - 0.5, z - chamber.z - 0.5)
|
||||
else level.addParticle(look.chamber, x, y, z, 0.0, 0.02, 0.0)
|
||||
}
|
||||
if (random.nextDouble() < FACE_CHANCE) {
|
||||
// Like a lit furnace: low on the core's front, a little in front of it.
|
||||
val across = random.nextDouble() * 0.6 - 0.3
|
||||
val x = pos.x + 0.5 + facing.stepX * 0.52 + facing.stepZ * across
|
||||
val y = pos.y + 0.1 + random.nextDouble() * 0.4
|
||||
val z = pos.z + 0.5 + facing.stepZ * 0.52 + facing.stepX * across
|
||||
level.addParticle(look.face, x, y, z, 0.0, 0.0, 0.0)
|
||||
look.smoke?.let { level.addParticle(it, x, y + 0.2, z, 0.0, 0.0, 0.0) }
|
||||
}
|
||||
if (look.sound != null && random.nextDouble() < SOUND_CHANCE) {
|
||||
level.playLocalSound(pos, look.sound, SoundSource.BLOCKS, look.volume, 0.9f + random.nextFloat() * 0.2f, false)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,23 @@ object MachineWork {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether the machine is doing something this tick, for how it looks ([MachineBlockEntity.showWork]): a furnace it
|
||||
* heats has something to cook; the generator isn't full; the synthesizer gets energy; a rebirth runs; the altar
|
||||
* holds a Pokémon.
|
||||
*/
|
||||
fun working(core: MachineBlockEntity, level: ServerLevel, def: MachineDefs.Def): Boolean {
|
||||
val pokemon = core.pokemon ?: return false
|
||||
return when (def.kind) {
|
||||
MachineDefs.Kind.FURNACE -> core.furnaces.take(def.furnaces!!.count(pokemon.level))
|
||||
.any { (level.getBlockEntity(it) as? AbstractFurnaceBlockEntity)?.getItem(0)?.isEmpty == false }
|
||||
MachineDefs.Kind.GENERATOR -> core.energy < def.energy!!.capacity
|
||||
MachineDefs.Kind.SYNTHESIS -> core.receivedThisTick > 0
|
||||
MachineDefs.Kind.REBIRTH -> core.rebirthAt > 0
|
||||
MachineDefs.Kind.ALTAR -> true
|
||||
}
|
||||
}
|
||||
|
||||
// --- Large Furnace -------------------------------------------------------------------------------------------
|
||||
|
||||
private fun heat(core: MachineBlockEntity, level: ServerLevel, rules: MachineDefs.FurnaceRules, pokemon: Pokemon) {
|
||||
|
||||
@@ -41,7 +41,8 @@ object Machines {
|
||||
private fun core(name: String, color: MapColor, sound: SoundType): DeferredBlock<MachineBlock> = BLOCKS.registerBlock(
|
||||
name, ::MachineBlock, BlockBehaviour.Properties.of().mapColor(color).strength(3.5f).requiresCorrectToolForDrops().sound(sound)
|
||||
// See-through, so the Pokémon in the chamber behind the core shows (Adrian, 2026-11-02).
|
||||
.noOcclusion().isViewBlocking { _, _, _ -> false }.isSuffocating { _, _, _ -> false },
|
||||
.noOcclusion().isViewBlocking { _, _, _ -> false }.isSuffocating { _, _, _ -> false }
|
||||
.lightLevel { if (it.getValue(MachineBlock.ACTIVE)) MachineBlock.ACTIVE_LIGHT else 0 },
|
||||
)
|
||||
|
||||
fun register(bus: IEventBus) {
|
||||
|
||||
@@ -2,10 +2,14 @@ package dev.cobblesurvival.selftest
|
||||
|
||||
import com.cobblemon.mod.common.api.pokemon.PokemonProperties
|
||||
import com.cobblemon.mod.common.api.pokemon.PokemonSpecies
|
||||
import com.cobblemon.mod.common.api.pokemon.stats.Stats
|
||||
import com.cobblemon.mod.common.battles.MoveActionResponse
|
||||
import com.cobblemon.mod.common.battles.BattleRegistry
|
||||
import com.cobblemon.mod.common.util.getBattleState
|
||||
import dev.cobblesurvival.boss.Area
|
||||
import dev.cobblesurvival.boss.BossDefs
|
||||
import dev.cobblesurvival.boss.BossFights
|
||||
import dev.cobblesurvival.boss.BossToughness
|
||||
import dev.cobblesurvival.boss.EndBoss
|
||||
import dev.cobblesurvival.zone.Guardians
|
||||
import net.minecraft.core.BlockPos
|
||||
@@ -33,8 +37,13 @@ object BossTests {
|
||||
SelfTestCase("boss_arena_leaving_loses", 400, ::arena),
|
||||
SelfTestCase("end_boss_replaces_the_dragon", 200, ::endBoss),
|
||||
SelfTestCase("practice_bosses_spawn", 20, ::practice),
|
||||
SelfTestCase("boss_takes_a_quarter_per_attack", 1200, ::damageCap),
|
||||
)
|
||||
|
||||
/** Guardians stand at least this tall (Morpeko, the smallest, reaches about 5 blocks), the End boss [END_HEIGHT]. */
|
||||
private const val GUARDIAN_HEIGHT = 4.5f
|
||||
private const val END_HEIGHT = 12f
|
||||
|
||||
/** `/cobblesurvival boss <zone|end>`: a copy that fights like the real boss, for every guardian and the End. */
|
||||
private fun practice(s: Scenario) {
|
||||
for (which in Guardians.definitions.keys + "end") {
|
||||
@@ -42,11 +51,51 @@ object BossTests {
|
||||
?: throw GameTestAssertException("no practice boss for $which")
|
||||
s.check(BossFights.defOf(entity) != null && entity.pokemon.isUncatchable()) { "the practice $which is no uncatchable boss" }
|
||||
s.check(entity.pokemon.persistentData.getString(Guardians.TAG).isEmpty()) { "the practice $which counts as the real guardian" }
|
||||
// Adrian, 2026-11-02: way bigger, tougher but not stronger.
|
||||
val tall = if (which == "end") END_HEIGHT else GUARDIAN_HEIGHT
|
||||
s.check(entity.bbHeight >= tall) { "the $which boss is only %.1f blocks tall".format(entity.bbHeight) }
|
||||
val p = entity.pokemon
|
||||
val defensive = listOf(Stats.HP, Stats.DEFENCE, Stats.SPECIAL_DEFENCE)
|
||||
s.check(defensive.all { p.ivs[it] == 31 && p.evs[it] == BossToughness.EVS }) { "the $which boss lacks its HP/Defense/Sp. Def training" }
|
||||
s.check(listOf(Stats.ATTACK, Stats.SPECIAL_ATTACK, Stats.SPEED).all { p.evs[it] == 0 }) { "the $which boss has offensive EVs" }
|
||||
s.check(p.currentHealth == p.maxHealth && BossToughness.isBoss(p)) { "the $which boss isn't at full health or isn't a boss" }
|
||||
entity.discard()
|
||||
}
|
||||
s.helper.succeed()
|
||||
}
|
||||
|
||||
/**
|
||||
* The Guardian's Ward (Adrian, 2026-11-02: no single attack takes more than a quarter of a boss's HP): a Lv 100
|
||||
* Garchomp's Bullet Seed, every hit enough to knock out a Lv 15 boss, takes a quarter at most per use.
|
||||
*/
|
||||
private fun damageCap(s: Scenario) {
|
||||
val player = s.player()
|
||||
s.give(player, s.pokemon("garchomp", 100, "bulletseed"))
|
||||
val bossPokemon = s.pokemon("morpeko", 15, "splash")
|
||||
bossPokemon.persistentData.putString(BossFights.TAG, TEST_BOSS)
|
||||
val boss = s.wild(bossPokemon, s.at(7.5, 1.0, 3.5))
|
||||
val battle = { BattleRegistry.getBattleByParticipatingPlayer(player) }
|
||||
s.afterTest { battle()?.end() }
|
||||
// Two attacks, then the battle waits for a third choice that never comes.
|
||||
var attacks = 0
|
||||
s.answerBattles(player) { actor, _ ->
|
||||
if (attacks == 2) null else MoveActionResponse("bulletseed", actor.getSide().getOppositeSide().activePokemon.first().getPNX()).also { attacks++ }
|
||||
}
|
||||
s.startWildBattle(player, boss)
|
||||
.thenWaitUntil {
|
||||
val b = battle()
|
||||
val waiting = player.getBattleState()?.second?.let { it.mustChoose && it.request != null } == true
|
||||
s.check(b == null || (attacks == 2 && b.turn >= 3 && waiting)) { "two attacks aren't done yet: ${s.battleState(player)}" }
|
||||
}
|
||||
.thenExecute {
|
||||
val max = boss.pokemon.maxHealth
|
||||
val hp = boss.pokemon.currentHealth
|
||||
s.check(hp in (max - 2 * (max / 4))..<max) { "after two attacks that each could knock it out, the boss has $hp of $max HP (a quarter at most per attack)" }
|
||||
battle()?.end()
|
||||
}
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
private fun implemented(species: String) = PokemonSpecies.getByName(species)?.implemented == true
|
||||
|
||||
private fun coverage(s: Scenario) {
|
||||
|
||||
@@ -247,7 +247,17 @@ object MachineTests {
|
||||
level.setBlockAndUpdate(furnace.below(), Blocks.HOPPER.defaultBlockState().setValue(HopperBlock.FACING, Direction.DOWN))
|
||||
(level.getBlockEntity(furnace.above()) as HopperBlockEntity).setItem(0, ItemStack(Items.RAW_IRON, 4))
|
||||
val output = level.getBlockEntity(furnace.below()) as HopperBlockEntity
|
||||
val active = { core.blockState.getValue(MachineBlock.ACTIVE) }
|
||||
s.helper.startSequence()
|
||||
// Adrian, 2026-11-02: a working machine shows it (the core glows and animates, particles show).
|
||||
.thenWaitUntil { s.check(active()) { "the Large Furnace's core doesn't show that it works" } }
|
||||
.thenExecute {
|
||||
// The Pokémon inside flinches as if hit, but takes no damage.
|
||||
val shown = displays(level, core.blockPos).firstOrNull() ?: throw GameTestAssertException("no Pokémon shown in the chamber")
|
||||
MachineDisplay.flinch(level, shown)
|
||||
val inside = core.pokemon!!
|
||||
s.check(shown.isAlive && inside.currentHealth == inside.maxHealth) { "a flinch hurt the Pokémon (${inside.currentHealth}/${inside.maxHealth})" }
|
||||
}
|
||||
.thenWaitUntil {
|
||||
val ingots = (0 until output.containerSize).sumOf { output.getItem(it).takeIf { s -> s.`is`(Items.IRON_INGOT) }?.count ?: 0 }
|
||||
s.check(ingots >= 4) { "the hopper under the furnace has $ingots iron ingots, not 4" }
|
||||
@@ -256,6 +266,7 @@ object MachineTests {
|
||||
val fuel = (level.getBlockEntity(furnace) as AbstractFurnaceBlockEntity).getItem(1)
|
||||
s.check(fuel.isEmpty) { "the furnace got fuel: $fuel" }
|
||||
}
|
||||
.thenWaitUntil { s.check(!active()) { "the Large Furnace's core still shows work with nothing left to cook" } }
|
||||
.thenSucceed()
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import com.cobblemon.mod.common.entity.pokemon.PokemonEntity
|
||||
import com.google.gson.Gson
|
||||
import com.google.gson.JsonElement
|
||||
import dev.cobblesurvival.CobbleSurvival
|
||||
import dev.cobblesurvival.boss.BossToughness
|
||||
import net.minecraft.ChatFormatting
|
||||
import net.minecraft.core.BlockPos
|
||||
import net.minecraft.core.HolderLookup
|
||||
@@ -31,6 +32,7 @@ import net.neoforged.neoforge.event.entity.EntityJoinLevelEvent
|
||||
import net.neoforged.neoforge.event.tick.ServerTickEvent
|
||||
import java.util.UUID
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.ceil
|
||||
|
||||
/**
|
||||
* One Alpha guardian per zone (roadmap Phase 3): a big, uncatchable Pokémon standing at the zone's sample point.
|
||||
@@ -48,6 +50,10 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
|
||||
private const val NEAR = 96.0
|
||||
private const val LEASH = 24.0
|
||||
private const val TICKS_PER_HOUR = 72_000L
|
||||
/** How far (blocks, each way) a cave guardian's spot may lie from the zone's sample point. */
|
||||
private const val CAVE_SEARCH = 24
|
||||
/** A cave spot needs at least this much air above it, even when no spot fits the whole guardian. */
|
||||
private const val MIN_ROOM = 3
|
||||
|
||||
class Definition(
|
||||
val zone: String,
|
||||
@@ -94,6 +100,11 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
|
||||
val entry = state.entry(zone)
|
||||
val guardian = entry.entity?.let { level.getEntity(it) as? PokemonEntity }?.takeIf { it.isAlive }
|
||||
if (guardian != null) {
|
||||
// A guardian from before it grew (Adrian, 2026-11-02): grown, it may need a roomier cave spot.
|
||||
if (BossToughness.refresh(guardian, definition.scale) && sample.y != null) {
|
||||
spotFor(level, zone, sample, guardian.bbHeight)?.let { entry.spot = it }
|
||||
entry.spot?.let { guardian.teleportTo(it.x + 0.5, it.y.toDouble(), it.z + 0.5) }
|
||||
}
|
||||
entry.spot?.let { leash(guardian, it) }
|
||||
continue
|
||||
}
|
||||
@@ -103,21 +114,29 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
|
||||
// The old guardian may still be loading from disk with its chunk; wait for that.
|
||||
val chunk = ChunkPos(SectionPos.blockToSectionCoord(sample.x), SectionPos.blockToSectionCoord(sample.z))
|
||||
if (!level.areEntitiesLoaded(chunk.toLong())) continue
|
||||
val spot = entry.spot ?: spotFor(level, zone, sample)?.also { entry.spot = it } ?: continue
|
||||
val spawned = spawn(level, definition, spot) ?: continue
|
||||
val entity = create(level, definition)
|
||||
// A spot picked for a smaller guardian is kept only while this one still fits there.
|
||||
val spot = entry.spot?.takeIf { sample.y == null || room(level, it, entity.bbHeight) >= ceil(entity.bbHeight) }
|
||||
?: spotFor(level, zone, sample, entity.bbHeight)?.also { entry.spot = it } ?: continue
|
||||
val spawned = spawn(level, entity, spot) ?: continue
|
||||
entry.entity = spawned.uuid
|
||||
}
|
||||
}
|
||||
|
||||
private fun spawn(level: ServerLevel, definition: Definition, spot: BlockPos): PokemonEntity? {
|
||||
private fun create(level: ServerLevel, definition: Definition): PokemonEntity {
|
||||
val pokemon = PokemonProperties.parse(definition.pokemon).create()
|
||||
pokemon.scaleModifier = definition.scale
|
||||
pokemon.persistentData.putString(TAG, definition.zone)
|
||||
val entity = PokemonEntity(level, pokemon)
|
||||
BossToughness.toughen(pokemon, definition.scale)
|
||||
pokemon.heal()
|
||||
return PokemonEntity(level, pokemon)
|
||||
}
|
||||
|
||||
private fun spawn(level: ServerLevel, entity: PokemonEntity, spot: BlockPos): PokemonEntity? {
|
||||
val zone = entity.pokemon.persistentData.getString(TAG)
|
||||
entity.moveTo(spot.x + 0.5, spot.y.toDouble(), spot.z + 0.5, level.random.nextFloat() * 360f, 0f)
|
||||
entity.setPersistenceRequired()
|
||||
if (!level.addFreshEntity(entity)) return null
|
||||
CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}", definition.zone, spot.toShortString())
|
||||
CobbleSurvival.LOGGER.info("Guardian of {} appeared at {}, {} blocks tall", zone, spot.toShortString(), "%.1f".format(entity.bbHeight))
|
||||
return entity
|
||||
}
|
||||
|
||||
@@ -128,18 +147,33 @@ object Guardians : SimpleJsonResourceReloadListener(Gson(), "guardians") {
|
||||
}
|
||||
}
|
||||
|
||||
/** Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it. */
|
||||
private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample): BlockPos? {
|
||||
/** Air above [pos], counted up to the [height] a guardian needs. */
|
||||
private fun room(level: ServerLevel, pos: BlockPos, height: Float): Int {
|
||||
val need = ceil(height).toInt()
|
||||
return (0 until need).firstOrNull { !level.getBlockState(pos.above(it)).isAir } ?: need
|
||||
}
|
||||
|
||||
/**
|
||||
* Where the guardian stands: the surface at the sample point, or for a cave zone open floor near it with room for
|
||||
* its [height]; when no floor has that much, the roomiest one found (at least [MIN_ROOM]).
|
||||
*/
|
||||
private fun spotFor(level: ServerLevel, zone: String, sample: ZoneSample, height: Float): BlockPos? {
|
||||
val caveY = sample.y ?: return BlockPos(sample.x, level.getHeight(Heightmap.Types.MOTION_BLOCKING_NO_LEAVES, sample.x, sample.z), sample.z)
|
||||
for (radius in 0..16 step 2) for (dx in -radius..radius) for (dz in -radius..radius) {
|
||||
var best: BlockPos? = null
|
||||
var bestRoom = MIN_ROOM - 1
|
||||
for (radius in 0..CAVE_SEARCH step 2) for (dx in -radius..radius) for (dz in -radius..radius) {
|
||||
if (maxOf(abs(dx), abs(dz)) != radius) continue
|
||||
for (dy in 0..24) for (sign in intArrayOf(1, -1)) {
|
||||
val pos = BlockPos(sample.x + dx, caveY + sign * dy, sample.z + dz)
|
||||
val open = (0 until 3).all { level.getBlockState(pos.above(it)).isAir }
|
||||
if (open && level.getBlockState(pos.below()).isSolid && Zones.at(level, pos)?.id == zone) return pos
|
||||
if (!level.getBlockState(pos).isAir || !level.getBlockState(pos.below()).isSolid) continue
|
||||
val room = room(level, pos, height)
|
||||
if (room <= bestRoom || Zones.at(level, pos)?.id != zone) continue
|
||||
best = pos
|
||||
bestRoom = room
|
||||
if (room >= ceil(height)) return pos
|
||||
}
|
||||
}
|
||||
return null
|
||||
return best
|
||||
}
|
||||
|
||||
/** A second copy of a zone's guardian (e.g. one that wandered into a chunk loading late) never joins. */
|
||||
|
||||
@@ -1,19 +1,34 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": {
|
||||
"active=false,facing=north": {
|
||||
"model": "cobblesurvival:block/altar_core"
|
||||
},
|
||||
"facing=east": {
|
||||
"active=false,facing=east": {
|
||||
"model": "cobblesurvival:block/altar_core",
|
||||
"y": 90
|
||||
},
|
||||
"facing=south": {
|
||||
"active=false,facing=south": {
|
||||
"model": "cobblesurvival:block/altar_core",
|
||||
"y": 180
|
||||
},
|
||||
"facing=west": {
|
||||
"active=false,facing=west": {
|
||||
"model": "cobblesurvival:block/altar_core",
|
||||
"y": 270
|
||||
},
|
||||
"active=true,facing=north": {
|
||||
"model": "cobblesurvival:block/altar_core_on"
|
||||
},
|
||||
"active=true,facing=east": {
|
||||
"model": "cobblesurvival:block/altar_core_on",
|
||||
"y": 90
|
||||
},
|
||||
"active=true,facing=south": {
|
||||
"model": "cobblesurvival:block/altar_core_on",
|
||||
"y": 180
|
||||
},
|
||||
"active=true,facing=west": {
|
||||
"model": "cobblesurvival:block/altar_core_on",
|
||||
"y": 270
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,34 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": {
|
||||
"active=false,facing=north": {
|
||||
"model": "cobblesurvival:block/dynamo_core"
|
||||
},
|
||||
"facing=east": {
|
||||
"active=false,facing=east": {
|
||||
"model": "cobblesurvival:block/dynamo_core",
|
||||
"y": 90
|
||||
},
|
||||
"facing=south": {
|
||||
"active=false,facing=south": {
|
||||
"model": "cobblesurvival:block/dynamo_core",
|
||||
"y": 180
|
||||
},
|
||||
"facing=west": {
|
||||
"active=false,facing=west": {
|
||||
"model": "cobblesurvival:block/dynamo_core",
|
||||
"y": 270
|
||||
},
|
||||
"active=true,facing=north": {
|
||||
"model": "cobblesurvival:block/dynamo_core_on"
|
||||
},
|
||||
"active=true,facing=east": {
|
||||
"model": "cobblesurvival:block/dynamo_core_on",
|
||||
"y": 90
|
||||
},
|
||||
"active=true,facing=south": {
|
||||
"model": "cobblesurvival:block/dynamo_core_on",
|
||||
"y": 180
|
||||
},
|
||||
"active=true,facing=west": {
|
||||
"model": "cobblesurvival:block/dynamo_core_on",
|
||||
"y": 270
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,34 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": {
|
||||
"active=false,facing=north": {
|
||||
"model": "cobblesurvival:block/heat_core"
|
||||
},
|
||||
"facing=east": {
|
||||
"active=false,facing=east": {
|
||||
"model": "cobblesurvival:block/heat_core",
|
||||
"y": 90
|
||||
},
|
||||
"facing=south": {
|
||||
"active=false,facing=south": {
|
||||
"model": "cobblesurvival:block/heat_core",
|
||||
"y": 180
|
||||
},
|
||||
"facing=west": {
|
||||
"active=false,facing=west": {
|
||||
"model": "cobblesurvival:block/heat_core",
|
||||
"y": 270
|
||||
},
|
||||
"active=true,facing=north": {
|
||||
"model": "cobblesurvival:block/heat_core_on"
|
||||
},
|
||||
"active=true,facing=east": {
|
||||
"model": "cobblesurvival:block/heat_core_on",
|
||||
"y": 90
|
||||
},
|
||||
"active=true,facing=south": {
|
||||
"model": "cobblesurvival:block/heat_core_on",
|
||||
"y": 180
|
||||
},
|
||||
"active=true,facing=west": {
|
||||
"model": "cobblesurvival:block/heat_core_on",
|
||||
"y": 270
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,34 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": {
|
||||
"active=false,facing=north": {
|
||||
"model": "cobblesurvival:block/rebirth_core"
|
||||
},
|
||||
"facing=east": {
|
||||
"active=false,facing=east": {
|
||||
"model": "cobblesurvival:block/rebirth_core",
|
||||
"y": 90
|
||||
},
|
||||
"facing=south": {
|
||||
"active=false,facing=south": {
|
||||
"model": "cobblesurvival:block/rebirth_core",
|
||||
"y": 180
|
||||
},
|
||||
"facing=west": {
|
||||
"active=false,facing=west": {
|
||||
"model": "cobblesurvival:block/rebirth_core",
|
||||
"y": 270
|
||||
},
|
||||
"active=true,facing=north": {
|
||||
"model": "cobblesurvival:block/rebirth_core_on"
|
||||
},
|
||||
"active=true,facing=east": {
|
||||
"model": "cobblesurvival:block/rebirth_core_on",
|
||||
"y": 90
|
||||
},
|
||||
"active=true,facing=south": {
|
||||
"model": "cobblesurvival:block/rebirth_core_on",
|
||||
"y": 180
|
||||
},
|
||||
"active=true,facing=west": {
|
||||
"model": "cobblesurvival:block/rebirth_core_on",
|
||||
"y": 270
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,19 +1,34 @@
|
||||
{
|
||||
"variants": {
|
||||
"facing=north": {
|
||||
"active=false,facing=north": {
|
||||
"model": "cobblesurvival:block/synthesis_core"
|
||||
},
|
||||
"facing=east": {
|
||||
"active=false,facing=east": {
|
||||
"model": "cobblesurvival:block/synthesis_core",
|
||||
"y": 90
|
||||
},
|
||||
"facing=south": {
|
||||
"active=false,facing=south": {
|
||||
"model": "cobblesurvival:block/synthesis_core",
|
||||
"y": 180
|
||||
},
|
||||
"facing=west": {
|
||||
"active=false,facing=west": {
|
||||
"model": "cobblesurvival:block/synthesis_core",
|
||||
"y": 270
|
||||
},
|
||||
"active=true,facing=north": {
|
||||
"model": "cobblesurvival:block/synthesis_core_on"
|
||||
},
|
||||
"active=true,facing=east": {
|
||||
"model": "cobblesurvival:block/synthesis_core_on",
|
||||
"y": 90
|
||||
},
|
||||
"active=true,facing=south": {
|
||||
"model": "cobblesurvival:block/synthesis_core_on",
|
||||
"y": 180
|
||||
},
|
||||
"active=true,facing=west": {
|
||||
"model": "cobblesurvival:block/synthesis_core_on",
|
||||
"y": 270
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"parent": "minecraft:block/orientable",
|
||||
"render_type": "minecraft:translucent",
|
||||
"textures": {
|
||||
"top": "cobblesurvival:block/altar_core_top",
|
||||
"front": "cobblesurvival:block/altar_core_front_on",
|
||||
"side": "cobblesurvival:block/altar_core_side"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"parent": "minecraft:block/orientable",
|
||||
"render_type": "minecraft:translucent",
|
||||
"textures": {
|
||||
"top": "cobblesurvival:block/dynamo_core_top",
|
||||
"front": "cobblesurvival:block/dynamo_core_front_on",
|
||||
"side": "cobblesurvival:block/dynamo_core_side"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"parent": "minecraft:block/orientable",
|
||||
"render_type": "minecraft:translucent",
|
||||
"textures": {
|
||||
"top": "cobblesurvival:block/heat_core_top",
|
||||
"front": "cobblesurvival:block/heat_core_front_on",
|
||||
"side": "cobblesurvival:block/heat_core_side"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"parent": "minecraft:block/orientable",
|
||||
"render_type": "minecraft:translucent",
|
||||
"textures": {
|
||||
"top": "cobblesurvival:block/rebirth_core_top",
|
||||
"front": "cobblesurvival:block/rebirth_core_front_on",
|
||||
"side": "cobblesurvival:block/rebirth_core_side"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"parent": "minecraft:block/orientable",
|
||||
"render_type": "minecraft:translucent",
|
||||
"textures": {
|
||||
"top": "cobblesurvival:block/synthesis_core_top",
|
||||
"front": "cobblesurvival:block/synthesis_core_front_on",
|
||||
"side": "cobblesurvival:block/synthesis_core_side"
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.2 KiB |
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"animation": {
|
||||
"frametime": 3,
|
||||
"interpolate": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1010 B |
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"animation": {
|
||||
"frametime": 3,
|
||||
"interpolate": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"animation": {
|
||||
"frametime": 3,
|
||||
"interpolate": true
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 996 B |
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"animation": {
|
||||
"frametime": 3,
|
||||
"interpolate": true
|
||||
}
|
||||
}
|
||||
BIN
Binary file not shown.
|
After Width: | Height: | Size: 928 B |
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"animation": {
|
||||
"frametime": 3,
|
||||
"interpolate": true
|
||||
}
|
||||
}
|
||||
+58
-8
@@ -5,10 +5,15 @@ Each core is an "orientable" block (front, side, top) facing the player who plac
|
||||
here, from a palette and an emblem per machine, so they can be redrawn after a change. Our own art (pack license).
|
||||
The cores are see-through (Adrian, 2026-11-02: the Pokémon in the chamber behind the core must be visible): a solid
|
||||
frame around tinted glass, the emblem faint on the front; the models render translucent.
|
||||
A working core (block state active=true; Adrian, 2026-11-02: show that something is happening, like fire in a furnace)
|
||||
switches to an animated front: the emblem glows and pulses, sparks drift up the glass, and the furnace's flame
|
||||
flickers. The core also gives off light then, and particles show (mod: MachineEffects).
|
||||
|
||||
Usage: tools/machine_assets.py
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
import random
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
@@ -87,6 +92,10 @@ EMBLEMS = {
|
||||
|
||||
GLASS_ALPHA = 70
|
||||
EMBLEM_ALPHA = 150
|
||||
# The working front: this many frames, each shown this many ticks, blended into one another.
|
||||
FRAMES = 8
|
||||
FRAME_TICKS = 3
|
||||
FLAME_COLORS = [(255, 70, 20), (255, 140, 30), (255, 200, 60), (255, 240, 150)]
|
||||
|
||||
|
||||
def frame(image, dark, mid, light):
|
||||
@@ -136,6 +145,43 @@ def front(dark, mid, light, accent, emblem):
|
||||
return image
|
||||
|
||||
|
||||
def lerp(a, b, t):
|
||||
return tuple(round(x + (y - x) * t) for x, y in zip(a, b))
|
||||
|
||||
|
||||
def working_front(dark, mid, light, accent, emblem, name):
|
||||
"""The animated front of a working core: FRAMES frames stacked top to bottom."""
|
||||
rng = random.Random(name)
|
||||
rows = EMBLEMS[emblem]
|
||||
top_row = (16 - len(rows)) // 2
|
||||
glow = tuple(min(255, c + 60) for c in accent)
|
||||
white = (255, 255, 240)
|
||||
strip = Image.new("RGBA", (16, 16 * FRAMES), (0, 0, 0, 0))
|
||||
sparks = [(rng.randint(3, 12), rng.randint(0, 15)) for _ in range(3)]
|
||||
for f in range(FRAMES):
|
||||
pulse = 0.5 + 0.5 * math.sin(2 * math.pi * f / FRAMES)
|
||||
image = glass(dark, mid, light, accent)
|
||||
for y, row in enumerate(rows):
|
||||
for x, c in enumerate(row):
|
||||
if not (2 <= x <= 13 and 2 <= top_row + y <= 13 and c in "#+"):
|
||||
continue
|
||||
if emblem == "flame":
|
||||
# Flicker: each pixel picks a flame colour, hotter towards the core; the tips come and go.
|
||||
if y < 3 and rng.random() < 0.35:
|
||||
continue
|
||||
heat = (1 if c == "+" else 0) + rng.choice((0, 0, 1))
|
||||
color = FLAME_COLORS[min(len(FLAME_COLORS) - 1, heat + (1 if pulse > 0.6 else 0))]
|
||||
else:
|
||||
color = lerp(accent if c == "#" else glow, white, pulse * (0.6 if c == "+" else 0.35))
|
||||
image.putpixel((x, top_row + y), color + (round(EMBLEM_ALPHA + (255 - EMBLEM_ALPHA) * (0.5 + 0.5 * pulse)),))
|
||||
# Sparks drifting up the glass, two pixels a frame.
|
||||
for sx, sy in sparks:
|
||||
y = 13 - (sy + 2 * f) % 12
|
||||
image.putpixel((sx, y), glow + (230,))
|
||||
strip.paste(image, (0, 16 * f))
|
||||
return strip
|
||||
|
||||
|
||||
def write_json(path, data):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(json.dumps(data, indent=2) + "\n")
|
||||
@@ -148,17 +194,21 @@ def main():
|
||||
side(dark, mid, light, accent).save(textures / f"{name}_side.png")
|
||||
top(dark, mid, light, accent).save(textures / f"{name}_top.png")
|
||||
front(dark, mid, light, accent, emblem).save(textures / f"{name}_front.png")
|
||||
write_json(ASSETS / f"models/block/{name}.json", {
|
||||
"parent": "minecraft:block/orientable",
|
||||
"render_type": "minecraft:translucent",
|
||||
"textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{name}_front",
|
||||
"side": f"cobblesurvival:block/{name}_side"},
|
||||
})
|
||||
working_front(dark, mid, light, accent, emblem, name).save(textures / f"{name}_front_on.png")
|
||||
write_json(textures / f"{name}_front_on.png.mcmeta", {"animation": {"frametime": FRAME_TICKS, "interpolate": True}})
|
||||
for model, front_texture in ((name, f"{name}_front"), (f"{name}_on", f"{name}_front_on")):
|
||||
write_json(ASSETS / f"models/block/{model}.json", {
|
||||
"parent": "minecraft:block/orientable",
|
||||
"render_type": "minecraft:translucent",
|
||||
"textures": {"top": f"cobblesurvival:block/{name}_top", "front": f"cobblesurvival:block/{front_texture}",
|
||||
"side": f"cobblesurvival:block/{name}_side"},
|
||||
})
|
||||
write_json(ASSETS / f"models/item/{name}.json", {"parent": f"cobblesurvival:block/{name}"})
|
||||
rotation = {"north": 0, "east": 90, "south": 180, "west": 270}
|
||||
write_json(ASSETS / f"blockstates/{name}.json", {
|
||||
"variants": {f"facing={facing}": {"model": f"cobblesurvival:block/{name}", **({"y": y} if y else {})}
|
||||
for facing, y in rotation.items()},
|
||||
"variants": {f"active={str(active).lower()},facing={facing}": {
|
||||
"model": f"cobblesurvival:block/{name}{'_on' if active else ''}", **({"y": y} if y else {})}
|
||||
for active in (False, True) for facing, y in rotation.items()},
|
||||
})
|
||||
print(f"wrote {len(CORES)} cores to {ASSETS.relative_to(ROOT)}")
|
||||
|
||||
|
||||
+11
-6
@@ -22,7 +22,9 @@ MODS = ROOT / "build/server/mods"
|
||||
|
||||
ARENA = 24
|
||||
ARENA_BY_ZONE = {"basalt": 32}
|
||||
END = {"species": "eternatus", "level": 80, "scale": 0.2, "respawn_hours": 5, "xp_first_win": 12000, "xp": 500}
|
||||
END = {"species": "eternatus", "level": 80, "respawn_hours": 5, "xp_first_win": 12000, "xp": 500}
|
||||
# Adrian, 2026-11-02: Eternatus was too small (0.2x, about a block). Scaled to stand this many blocks tall.
|
||||
END_HEIGHT = 15
|
||||
END_ARENA = 40
|
||||
|
||||
# Up to this level: these patterns.
|
||||
@@ -61,19 +63,21 @@ def boss(level, types, arena, **extra):
|
||||
return {**extra, "arena": arena, "patterns": [pattern(name, tier, damage, effect) for name in TIERS[tier][1]]}
|
||||
|
||||
|
||||
def species_types(jar):
|
||||
types = {}
|
||||
def species_data(jar):
|
||||
"""({species: its types}, {species: how tall it stands at scale 1, in blocks})."""
|
||||
types, heights = {}, {}
|
||||
with zipfile.ZipFile(jar) as z:
|
||||
for name in z.namelist():
|
||||
if name.startswith("data/cobblemon/species/") and name.endswith(".json"):
|
||||
d = json.loads(z.read(name))
|
||||
types[d["name"].lower()] = {t for t in (d.get("primaryType"), d.get("secondaryType")) if t}
|
||||
return types
|
||||
heights[d["name"].lower()] = (d.get("hitbox") or {}).get("height", 1) * d.get("baseScale", 1)
|
||||
return types, heights
|
||||
|
||||
|
||||
def main():
|
||||
jar = next(MODS.glob("Cobblemon-*.jar"))
|
||||
types = species_types(jar)
|
||||
types, heights = species_data(jar)
|
||||
OUT.mkdir(parents=True, exist_ok=True)
|
||||
for old in OUT.glob("*.json"):
|
||||
old.unlink()
|
||||
@@ -86,7 +90,8 @@ def main():
|
||||
data = boss(level, types.get(species, set()), ARENA_BY_ZONE.get(zone, ARENA), guardian=zone)
|
||||
(OUT / f"{zone}.json").write_text(json.dumps({"_comment": comment, **data}, indent=1) + "\n")
|
||||
print(f"{zone}: {species} Lv {level}, {len(data['patterns'])} patterns")
|
||||
end = boss(END["level"], types.get(END["species"], set()), END_ARENA, end=END)
|
||||
end_def = {**END, "scale": round(END_HEIGHT / heights[END["species"]], 2)}
|
||||
end = boss(END["level"], types.get(END["species"], set()), END_ARENA, end=end_def)
|
||||
(OUT / "end.json").write_text(json.dumps({"_comment": comment, **end}, indent=1) + "\n")
|
||||
print(f"end: {END['species']} Lv {END['level']}, {len(end['patterns'])} patterns")
|
||||
|
||||
|
||||
+27
-5
@@ -30,8 +30,8 @@ datapacks/core/data/cobblesurvival/spawn_pool_world/<zone>.json:
|
||||
of day, excluded biomes and the "natural" ground preset are dropped (there's no sky or day underground).
|
||||
5. Strong flyers (STRONG_FLYER_*) only spawn in zones whose band starts at HIGH_ZONE_LEVEL or more.
|
||||
6. Each zone with a "guardian" gets datapacks/core/data/cobblesurvival/guardians/<zone>.json: the
|
||||
strongest Pokémon spawning in the zone (or "species"), uncatchable, half again as big (or "scale"), five levels
|
||||
above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
|
||||
strongest Pokémon spawning in the zone (or "species"), uncatchable, scaled to stand about GUARDIAN_HEIGHT blocks
|
||||
tall (or "scale"; Adrian, 2026-11-02: way bigger), five levels above the band, with the zone's boss drops and world/zones.json → guardian_respawn_hours.
|
||||
7. world/zones.json → "dimensions" are zones that are a whole other dimension (the End; Adrian, 2026-11-02: it was
|
||||
barren). Every line Cobblemon spawns in their "biome_tags" spawns there too, on top of its island home, with
|
||||
its stages fitted to the band as in 2. The entries go into "bucket" (Cobblemon rolls the bucket first, and its
|
||||
@@ -58,7 +58,11 @@ from pathlib import Path
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
OUT = ROOT / "datapacks/core/data/cobblesurvival/spawn_pool_world"
|
||||
GUARDIANS = ROOT / "datapacks/core/data/cobblesurvival/guardians"
|
||||
GUARDIAN_SCALE = 1.5 # "Alpha": half again as big as a normal one
|
||||
# Adrian, 2026-11-02: guardians way bigger. Each is scaled to stand about this many blocks tall (its Cobblemon hitbox
|
||||
# height times base scale), but at least GUARDIAN_SCALE_MIN and at most GUARDIAN_SCALE_MAX times its normal size.
|
||||
GUARDIAN_HEIGHT = 8
|
||||
GUARDIAN_SCALE_MIN = 1.5
|
||||
GUARDIAN_SCALE_MAX = 12
|
||||
GUARDIAN_LEVELS_ABOVE_BAND = 5
|
||||
REPORT = ROOT / "docs/zone-species.md"
|
||||
EXCLUDED_LABELS = {"legendary", "mythical", "ultra_beast", "paradox", "restricted"}
|
||||
@@ -118,6 +122,22 @@ def zone_biomes(plan, layout, preview):
|
||||
return shares
|
||||
|
||||
|
||||
def natural_heights(jar):
|
||||
"""{species id: how tall its entity stands at scale 1, in blocks} from Cobblemon's species files."""
|
||||
heights = {}
|
||||
with zipfile.ZipFile(jar) as z:
|
||||
for name in z.namelist():
|
||||
m = re.fullmatch(r"data/cobblemon/species/[^/]+/([^/]+)\.json", name)
|
||||
if m:
|
||||
data = json.loads(z.read(name))
|
||||
heights[f"cobblemon:{m[1]}"] = (data.get("hitbox") or {}).get("height", 1) * data.get("baseScale", 1)
|
||||
return heights
|
||||
|
||||
|
||||
def guardian_scale(height):
|
||||
return round(min(GUARDIAN_SCALE_MAX, max(GUARDIAN_SCALE_MIN, GUARDIAN_HEIGHT / height)), 2)
|
||||
|
||||
|
||||
def species_data(jar):
|
||||
"""({species id: base-stat total}, {species ids that are strong flyers}) from Cobblemon's species files."""
|
||||
totals, flyers = {}, set()
|
||||
@@ -369,6 +389,7 @@ def main():
|
||||
for old in GUARDIANS.glob("*.json"):
|
||||
old.unlink()
|
||||
guardians = {}
|
||||
heights = natural_heights(jar)
|
||||
for zid, zone in zones.items():
|
||||
config = zone.get("guardian")
|
||||
if not config:
|
||||
@@ -383,7 +404,7 @@ def main():
|
||||
"_comment": "Generated by tools/zone_spawns.py from world/zones.json; do not edit by hand.",
|
||||
"zone": zid,
|
||||
"pokemon": f"{species_name} level={level} uncatchable=yes",
|
||||
"scale": config.get("scale", GUARDIAN_SCALE),
|
||||
"scale": config.get("scale") or guardian_scale(heights.get(f"cobblemon:{species_name}", 1)),
|
||||
"drops": [{"item": item, "count": count} for item, count in config["drops"]],
|
||||
"respawn_hours": plan["guardian_respawn_hours"],
|
||||
}, indent=1) + "\n")
|
||||
@@ -398,7 +419,8 @@ def main():
|
||||
lines_out += [f"## {zone['name']} (Lv {zone['levels'][0]}–{zone['levels'][1]}, {len(rows)} lines)", ""]
|
||||
lines_out += [", ".join(f"{root}" + (f" ({', '.join(n)})" if n != [root] else "") for root, n in rows) or "none", ""]
|
||||
lines_out += ["## Alpha guardians", "",
|
||||
f"One per zone at its sample point, uncatchable, five levels above the band; it returns "
|
||||
f"One per zone at its sample point, uncatchable, about {GUARDIAN_HEIGHT} blocks tall, five levels above the "
|
||||
f"band, with full HP, Defense and Sp. Def training and the Guardian's Ward in battle; it returns "
|
||||
f"{plan['guardian_respawn_hours']} hours after it is beaten. Its drops help with the next zone.", "",
|
||||
"| Zone | Guardian | Level | Drops |", "| --- | --- | --- | --- |"]
|
||||
for zid, species_name in guardians.items():
|
||||
|
||||
+1
-1
@@ -76,7 +76,7 @@
|
||||
"caves": [
|
||||
{"id": "lush_caves", "types": ["grass", "bug", "fairy", "poison", "water"], "lines": 20, "name": "Lush Caves", "biome": "minecraft:lush_caves", "levels": [12, 28], "hazard": "none"},
|
||||
{"id": "dripstone", "types": ["rock", "ground", "steel", "fairy", "psychic", "dragon"], "lines": 20, "name": "Dripstone Depths", "biome": "minecraft:dripstone_caves", "levels": [22, 38], "hazard": "none"},
|
||||
{"id": "deep_dark", "types": ["ghost", "dark", "steel", "poison", "psychic", "rock"], "guardian": {"species": "regigigas", "scale": 1.0, "drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 21, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"},
|
||||
{"id": "deep_dark", "types": ["ghost", "dark", "steel", "poison", "psychic", "rock"], "guardian": {"species": "regigigas", "drops": [["cobblemon:icy_rock", 3], ["cobblemon:exp_candy_l", 2]]}, "lines": 21, "name": "Deep Dark", "biome": "minecraft:deep_dark", "levels": [45, 60], "hazard": "darkness (vanilla sculk; no Warden)"},
|
||||
{"id": "basalt", "types": ["fire", "rock", "ground", "steel", "dark"], "guardian": {"species": "moltres", "drops": [["cobblemon:exp_candy_xl", 3], ["minecraft:netherite_scrap", 2]]}, "lines": 15, "name": "Basalt Furnace", "biome": "minecraft:basalt_deltas", "levels": [55, 70], "hazard": "heat (Cold Sweat)"}
|
||||
],
|
||||
"dimensions": [
|
||||
|
||||
Reference in new issue
Block a user