Compare commits

...
Author SHA1 Message Date
Ludovic MarieandClaude Opus 5.5 84afe5a020 Merge origin/parity/rendering-hud into parity/rendering-be
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:40:54 +02:00
Ludovic MarieandClaude Opus 5.5 36b944ec3d Merge origin/parity/rendering-anim into parity/rendering-be
Client-ticked animation state now drives the renderer alongside the End
portal and gateway rendering; gateways, whose ages and cooldowns the client
does not track yet, keep rendering settled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:40:50 +02:00
Ludovic MarieandClaude Opus 5.5 af463e976b Merge origin/parity/entity-ai into parity/rendering-be
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 15:39:05 +02:00
Ludovic MarieandClaude Opus 5.5 fc7a95ee10 Merge branch 'parity-base' into parity/rendering-be
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 14:08:08 +02:00
Ludovic Marie fd79addba4 Merge branch 'parity/saves' into parity-base 2026-10-12 14:06:53 +02:00
Ludovic Marie 7f8bec70bc Merge branch 'parity/blocks' into parity-base 2026-10-12 14:06:53 +02:00
Ludovic MarieandClaude Opus 5.5 22bc86739b Port the villager brain from VillagerGoalPackages
Villagers ran an approximation built from goals. They now run vanilla's
Brain: expiring memories, the nine sensors with their randomly delayed
scan clocks, activities from the villager_activity timeline, and every
package's behaviours (core, work, play, meet, rest, idle, panic, pre-raid,
raid, hide), started and ticked in Brain.availableBehaviorsByPriority's
order with RunOne's shuffles, the level random for behaviour timing and
POI scans, and Villager.refreshBrain when the profession changes.

The mob AI probe prints the activities and memories as the oracle does,
and the harness's three villager scenarios now match: 34/34.
Sleeping villagers take LivingEntity.SLEEPING_DIMENSIONS instead of being
frozen in travel.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:37:39 +02:00
Ludovic MarieandClaude Opus 5.5 b0527b4818 Check a shulker box's lid room before opening and tick animations only while the world runs
ShulkerBoxBlock.canOpen: a closed box opens only when the half-open lid
box is free of collisions. Bells gather and test entities by their real
bounding boxes and liveness. Client block-entity tickers run with
ClientLevel.tickBlockEntities, which Minecraft.tick skips while paused.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:32:58 +02:00
Ludovic MarieandClaude Opus 5.5 d48017ab70 Build the player's attack strength model with its tests
Player.attackStrengthTicker and itemSwapTicker: both advance each tick, a
different main-hand item resets them, and getAttackStrengthScale divides
by the ATTACK_SPEED delay. The game does not drive it yet; the attack
indicator still shows full strength until combat uses the cooldown.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:29:20 +02:00
Ludovic MarieandClaude Opus 5.5 5b201965af Compare the chat with vanilla's ChatComponent
The chat cases add messages (with the system and error tags) at given Hud
ticks and draw ChatComponent in background mode: wrapping, the 200-tick
fade, scale, width, height, spacing and opacity options. Horizon's chat now
wraps like StringSplitter.splitLines and ComponentRenderUtils.wrapComponents
(the indent on wrapped lines, breaks at the last space, a dropped newline
or space), keeps the options as doubles and draws through a layout with
vanilla's fills and text offsets. A component list whose first part has
empty text now still passes its style to the rest. The harness runs 200
cases per kind.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:28:05 +02:00
Ludovic MarieandClaude Opus 5.5 ef0c6604a5 Compare camera overlays and the sleep overlay with vanilla's Hud
The overlays cases run Hud.extractCameraOverlays and extractSleepOverlay:
the vignette with its brightness and world border warning, the spyglass
scope and its scopeScale easing (read back and compared), equipment
camera overlays, powder snow, the nether portal sprite and the nausea
swirl. The game feeds its full-screen camera overlays from the same
layout (the vignette option and first person now count), draws the
spyglass with vanilla's unclamped easing and the sleep fade at vanilla's
opacity under the sidebar, titles and chat.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:19:38 +02:00
Ludovic MarieandClaude Opus 5.5 51d4598632 Draw End portals and gateways with vanilla's end portal shader
The entity shader gains rendertype_end_portal as layers 9 (15 portal
layers) and 10 (16 for gateways): end_sky and the rotating, scrolling
layers of end_portal.png projected from the screen, repeated inside their
atlas sprites, with vanilla's GameTime uniform. The world-space
approximation and its portal block scan are gone; the block-entity
renderer draws both blocks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:16:32 +02:00
Ludovic MarieandClaude Opus 5.5 cd116711c2 Seed the mob AI oracle's gate shuffles so villager runs are reproducible
Every GateBehavior (RunOne, and the gates of TradeWithVillager and
VillagerMakeLove) shuffles its behaviours with a ShufflingList random that
RandomSource.create() seeds from the clock, so two oracle runs of the same
villager scenario diverged after the first RunOne. The oracle now gives
each gate a LegacyRandomSource seeded with the mob's seed, and seeds the
gates of a brain rebuilt by Villager.refreshBrain before its next tick.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:16:13 +02:00
Ludovic MarieandClaude Opus 5.5 5b53d37e30 Compare item decorations with GuiGraphicsExtractor.itemDecorations
The decor cases run vanilla's itemDecorations on stacks with counts,
durability (Item.getBarWidth/getBarColor), unbreakable stacks and item
cooldowns ticked through ItemCooldowns. Horizon's item drawing now uses
the same layout: the bar rounds and colours like vanilla's and the count
sits at vanilla's offsets.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:13:12 +02:00
Ludovic MarieandClaude Opus 5.5 6bc85d48c6 Tick chest lids and enchanting books as vanilla and drive the renderer with it
block_entity_anim now also ports ChestLidController (chests, trapped and
copper chests, ender chests: triggerEvent(1, openCount), tickLid,
getOpenness), ChestBlock.opennessCombiner and ChestRenderer's lid curve,
and EnchantingTableBlockEntity.bookAnimationTick with its render state
(nearest non-spectator player by feet position within 3 blocks, Mth.atan2,
the class's shared RANDOM). The renderer pairs a double chest half with its
neighbour only as DoubleBlockCombiner does (same block, other half, same
facing) and reads the book from BlockAnim instead of ticking its own copy.

The game keeps a BlockEntityAnims and runs the client tickers of every
loaded animated block entity each tick: chest lids, books, shulker boxes,
bells (resonance sound, entity-effect particles, raider glow), conduits
(nautilus particles) and dragon/piglin heads. Opening and closing chests,
ender chests and shulker boxes sends the opener-count block event (both
halves of a double chest); a ring's block event reaches the bell through a
new LevelEvent::BlockEntityEvent. The renderer now gets this state instead
of BlockAnim::default(), so shulker lids, bells, conduits and heads move.

block_entity_anim harness: 256/256 cases (new: 48 chest, 36 book).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:12:30 +02:00
Ludovic MarieandClaude Opus 5.5 f55b4281e5 Compare boss bars, the jump bar and the scoreboard sidebar with vanilla
The oracle fills a BossHealthOverlay with LerpingBossEvents, runs
JumpableVehicleBar on a jumpable mount and displayScoreboardSidebar on a
real Scoreboard with teams, display names and number formats. The game
collects the dragon, wither, raid and custom bars into one
BossHealthOverlay layout (the backgrounds and notches now draw in vanilla's
order) and draws the sidebar with the text background options and
String.CASE_INSENSITIVE_ORDER.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:10:33 +02:00
Ludovic MarieandClaude Opus 5.5 613702c0a7 Render End portals and End gateways as vanilla's block-entity renderers
TheEndPortalRenderer and TheEndGatewayRenderer: the position-only portal
cube in FaceInfo order (only the vertical faces for portals, the faces
Block.shouldRenderFace leaves for gateways) and the gateway beam through a
shared submitBeaconBeam. The beacon beam now goes through the same
function. The client does not track gateway ages and cooldowns yet, so
gateways in game render settled.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:05:36 +02:00
Ludovic MarieandClaude Opus 5.5 fda4d65a02 Give the block-entity render oracle an item resolver and an End gateway level
Campfires and brushable blocks resolve their items (empty here) through the
context's ItemModelResolver, which was null. End gateways read the level for
their beam (max y, game time) and visible faces, so they get a stub level
with the case's neighbours, light, build height and game time. New cases
cover gateways spawning, cooling down and settled against neighbours, and
End portals beside the same neighbours.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:05:36 +02:00
Ludovic MarieandClaude Opus 5.5 b7cfe5578c Compare effect icons, titles, the action bar and the selected item name with vanilla's Hud
The HUD harness now draws cases by kind. The oracle runs Hud.extractEffects,
extractTitle, extractOverlayMessage and extractSelectedItemName after
replaying setTitle/setTimes/setOverlayMessage and Hud.tick, and
hud_overlays lays out the same state: MobEffectInstance ordering through
Java's small-array TimSort, the title and action bar fades, the backdrop
from the text background options and the tool highlight timer. The game
draws its effect icons, titles and action bar through these layouts and
now shows the selected item name.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-12 13:05:05 +02:00
Ludovic Marie 06abd528c5 Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/rendering-anim 2026-10-12 12:43:27 +02:00
Ludovic Marie c36190dc4d Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/rendering-hud 2026-10-12 12:43:16 +02:00
Ludovic Marie 0b86f5f4c2 Merge remote-tracking branch 'origin/t3/achieve-full-parity' into parity/entity-ai 2026-10-12 12:43:08 +02:00
Ludovic MarieandClaude Opus 5.5 b036c0c90d Document the mob AI harness --trace option
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 20:32:11 +02:00
Ludovic MarieandClaude Opus 5.5 d572cd7262 Link only the vanilla data into new worktrees
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:38:16 +02:00
Ludovic MarieandClaude Opus 5.5 82dceec5e7 Keep the datafix sample worlds with the shared vanilla data, and generate them when missing
The old server jars and the 22 worlds made from them are inputs, but they
lived in each worktree's .local and were lost when its outputs were
cleared. They now sit beside the shared vanilla data, written atomically,
and the harness generates any that are missing (under a lock, since
several worktrees may run it at once).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:36:09 +02:00
Ludovic MarieandClaude Opus 5.5 c7a2fbb182 Match vanilla wither tick order, underwater checks and gravity
The wither's customServerAiStep (charging, side heads, block breaking,
healing) now runs inside serverAiStep, and aiStep draws the heads' smoke
particle offsets after travel as vanilla does; its steering uses the 0.6F
and Mth.atan2 float maths, and its level-event sounds (shoot, spawn, block
break) no longer draw from its random: MobEvent::LevelEventSound lets the
client pick their pitch. Entity.getGravity is zero while isNoGravity, which
the flying move control sets. Drowned swim with isUnderWater (the previous
tick's eye state, Entity.wasEyeInWater), and the eyes count as in water per
26.3's EntityFluidInteraction (between a water block's bottom and its camera
height) rather than the older 0.11 offset.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:18:23 +02:00
Ludovic MarieandClaude Opus 5.5 a466af6adc Match vanilla projectile randoms, witch target goal and enderman goal names
Projectiles shot by mobs (arrows, splash potions, snowballs, llama spit,
wind charges, crossbow bolts, tridents) take their spread, and arrows their
base damage, from the new projectile's own random as vanilla does, leaving
the shooter's random alone. The witch's player target goal reports as
NearestAttackableWitchTargetGoal (NearestTarget gains a subclass label), and
the probe names the enderman goals as vanilla's classes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 19:02:46 +02:00
Ludovic MarieandClaude Opus 5.5 5ede186d40 Integrate several branches behind one gate
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:56:43 +02:00
Ludovic MarieandClaude Opus 5.5 d2c91b513b Match vanilla drowned AI, skeleton weapon goals, trader look goals and monster idle time
Drowned register Zombie's goals and then Drowned.addBehaviourGoals (go to
water, trident and melee attacks with okTarget, go to beach, swim up,
RandomStrollGoal) with DrownedMoveControl, Drowned.travelInWater and
AmphibiousPathNavigation. Skeletons get their bow from finalizeSpawn and
pick the bow goal or the AbstractSkeleton$1 melee goal from the held item,
as reassessWeaponGoal does. Monster.aiStep adds 2 to noActionTime in bright
light (raiders always), which stops RandomStrollGoal; LookAtPlayerGoal
supports Mob.class targets and InteractGoal, used by the wandering trader
(with vanilla's AvoidEntityGoal order), illagers, ravagers and vexes.
Entities below the world take 4 damage a tick instead of vanishing, water
travel uses the 0.8F/0.6F/0.3F float constants, and MobWorld carries the
level's sea level.

The oracle refreshes the player's water state after placing it (it is never
ticked, so isInWater kept the previous scenario's value) and keeps its world
under the harness build directory, deleting it before halting instead of
leaving /tmp/horizon-mob-ai* behind. The probe reports KILLED removals.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:56:36 +02:00
Ludovic MarieandClaude Opus 5.5 e8f2ccd403 Fail the gate when the harness runner dies, and clear large harness outputs
tools/gate.py took a crashed tools/parity.py for a pass with no new
failures. The runner now records each completed run, and the gate fails
unless its own run finished. A missing or empty trace (the disk was full)
no longer crashes the runner.

After a harness passes, the runner deletes its large regenerated outputs
(64 MB or more), keeping the vanilla oracle outputs that reusing an oracle
run needs. Full suites left 2-4 GB files in every worktree and filled the
disk.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:56:28 +02:00
Ludovic MarieandClaude Opus 5.5 d4144d33b4 Match vanilla zombie turtle-egg goal, entity pushing and raider goal order
Zombies (and husks, drowned, zombie villagers, zombified piglins) now
register ZombieAttackTurtleEggGoal first, as vanilla does, so their goal
selector draws the same random numbers. LivingEntity.pushEntities runs in
aiStep against the player and other mobs, with pushes to other mobs applied
at once as Entity.push does (replacing the post-tick push_apart). Spider
target goals check the light before their random interval, LookAtPlayerGoal
uses the non-combat targeting conditions, raiders register the
PatrollingMonster/Raider goals before their own, and the witch runs its
aiStep (heal cooldown, drinking, particle roll) before super.aiStep with
NearestHealableRaiderTargetGoal's nextBoolean.

The mob AI oracle's player is now a fully loaded, hurtable client: its
connection starts with a 60-tick load timer and 26.3 moved the hurt cooldown
to damageCooldownTime, both of which only ServerPlayer.tick counts down, so
whether mobs could hurt the player depended on the scenario order. The probe
reports the player's hurt sound, and an rng-trace feature records the call
sites of Horizon's mob random draws next to the oracle's trace.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 18:24:38 +02:00
Ludovic Marie 4eb2f53ffb Merge branch 'parity-base' into parity/saves
# Conflicts:
#	README.md
2026-10-11 17:51:57 +02:00
Ludovic MarieandClaude Opus 5.5 99057953e0 Mark dispensers as matching vanilla in the parity table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:51:49 +02:00
Ludovic Marie d0815052cd Merge branch 'parity-base' into parity/blocks 2026-10-11 17:51:37 +02:00
Ludovic Marie 168bc1d0a3 Merge branch 'parity/loot-spawn' into parity-base 2026-10-11 17:36:20 +02:00
Ludovic MarieandClaude Opus 5.5 2eb688cc6a Decrypt bytes already read when a connection enables encryption
Encrypted bytes that arrived in the same read as the last plain packet
stayed in the buffer undecrypted, and the connection then waited forever
for a frame it could not parse. The loopback test hit this under load.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 17:36:20 +02:00
Ludovic Marie 6ed726e935 Merge branch 'parity-base' into parity/loot-spawn 2026-10-11 17:26:12 +02:00
Ludovic Marie 0a1ce1314c Merge branch 'parity-base' into parity/saves 2026-10-11 17:12:11 +02:00
Ludovic MarieandClaude Opus 5.5 ae3f129c8e Format the dispenser parity work
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:54:06 +02:00
Ludovic MarieandClaude Opus 5.5 54a92f23f0 Give every block update oracle level a chunk generator
Bone meal and saplings reach for getChunkSource().getGenerator(); the
shared oracle level now answers it, as the dispenser oracle did.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:52:18 +02:00
Ludovic MarieandClaude Opus 5.5 a47f1ec58b Dispense mobs with their jockeys as vanilla's spawn eggs do
A dispensed spawn egg's mob gets the riders finalizeSpawn adds (chicken
jockeys, spider jockeys, strider jockeys). The harness leaves which mob
comes out a baby and rides to the spawning port's finalization draws, as
it already does for the level random of spawning cases. Cases no longer
grow plants out of a dispenser or build a door from mismatched halves,
and the oracle runs every case three times so an unseeded value is
marked even when two runs happen to agree.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:50:20 +02:00
Ludovic Marie 3ba0204bc0 Merge branch 'parity-base' into parity/blocks 2026-10-11 16:44:43 +02:00
Ludovic MarieandClaude Opus 5.5 d061bdf2fb Share harness passes between worktrees and cap harnesses machine-wide
A pass is keyed by the content of the harness's dependencies, so it is
also valid in any worktree with the same files. Passes are published to
parity-shared/ beside the shared vanilla data, and a worktree adopts one
whose key matches its own files instead of running the harness: an
agent's gate only runs what its own changes affect, instead of the whole
suite from scratch. Every runner on the machine also shares
PARITY_MACHINE_JOBS slots, so several agents' gates can't overload it.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:42:52 +02:00
Ludovic MarieandClaude Opus 5.5 877165a6c9 Read region headers as vanilla's RegionFile does in the region tests
Invalid allocations are dropped rather than refused, overlaps are not
checked, and an empty or truncated file is a region without chunks.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:41:30 +02:00
Ludovic MarieandClaude Opus 5.5 3a875c709e Refuse only worlds older than 1.18.2 when opening them
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:39:26 +02:00
Ludovic MarieandClaude Opus 5.5 6605e6cdf1 Run the dispenser fixture in the oracle's plains light and level
The fixture's chunks are plains (empty chunks read as badlands, which has
no bone meal flowers) and carry the case's sky and block light, as the
oracle level does, so grass bone meal places flowers and crops check
their light like vanilla's. The oracle's client sounds include the lava
fizz (1501). Cases build two-block plants whole: a lone lower half is not
a world vanilla makes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:32:28 +02:00
Ludovic MarieandClaude Opus 5.5 93b785a2ef Describe save upgrades from 1.18.2 in the status table
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:28:53 +02:00
Ludovic MarieandClaude Opus 5.5 7994c1e8c5 Count natural spawning among the entity checks that match vanilla
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:19:49 +02:00
Ludovic MarieandClaude Opus 5.5 6949ca4455 Target rarer natural spawns in the parity harness
Desert, snowy, swamp and jungle nights, lush, sulfur and dripstone caves,
warm and frozen oceans, crimson forests and slime chunks. A cube mob grown
by finalizeSpawn takes its constructor's head rotation from the entity's
unseeded random (AbstractCubeMob.onSyncedDataUpdated), so that yaw alone is
not compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:14:22 +02:00
Ludovic MarieandClaude Opus 5.5 36209e7827 Place dispensed shulker boxes only where DirectionalPlaceContext can
A dispenser places a shulker box in the block in front or nowhere, and
only when that block's canBeReplaced accepts it (a snow layer only when
it is a single layer).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:10:30 +02:00
Ludovic MarieandClaude Opus 5.5 b2812c55be Choose natural spawns exactly as vanilla's NaturalSpawner does
tools/test_natural_spawn_parity.py compares whole chunk spawn passes,
spawnCategoryForPosition from chosen sites and chunk generation packs
against vanilla's NaturalSpawner on generated box worlds.

Divergences fixed on the way: the reduced water ambient tag is
reduce_water_ambient_spawns, and the room check uses each type's spawn
dimensions scale (4 for slimes and magma cubes, 2 for sulfur cubes).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:10:12 +02:00
Ludovic MarieandClaude Opus 5.5 e1eabdc781 Pass the save upgrade harness from 1.18.2: block entity data stays empty
A block entity read inside an item stack remembers the map it was read from;
that memory is not part of the value, so an item's block entity tag with
only its id still counts as empty and is dropped, as in vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:07:10 +02:00
Ludovic MarieandClaude Opus 5.5 8eca39aff1 Equip, saddle and light from dispensers as vanilla does
Villagers, piglins, piglin brutes, allays, dolphins, foxes and pandas can
pick up loot from construction (their constructors call
setCanPickUpLoot), so they take dispensed equipment without spawn
finalization. Nautilus saddles play item.nautilus_saddle_equip. The
oracle level is the overworld, so fire in an obsidian frame lights a
portal there too, and it answers getEntity(id) and level-wide entity
queries, which leash cutting needs. Unseeded item motion is compared
within its full spread (item motion plus a shearing sheep's push).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 16:07:06 +02:00
Ludovic MarieandClaude Opus 5.5 bcfa2f6974 Retry fresh only when a harness that passed fails while reusing oracle results
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:56:45 +02:00
Ludovic MarieandClaude Opus 5.5 0de0379cc7 Expect an int page in the click event fix test, as createInt writes
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:53:55 +02:00
Ludovic MarieandClaude Opus 5.5 894113d7f4 Build golems and withers from dispensers as vanilla does
Golems and the wither come from EntityType.create (no spawn finalization,
no level random), after clearPatternBlocks sets every pattern cell to air
with flags 2 and a destroy event, and updatePatternBlocks follows. A
dispensed pumpkin that completes a golem skips setBlock's neighbour
updates as vanilla's does, and pumpkins and skulls post block_place.
Dispensed skulls face RotationSegment.convertToSegment(facing). Destroy
events for air play no break sound. The dispenser fixture plays on normal
difficulty like the oracle level.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:53:37 +02:00
Ludovic MarieandClaude Opus 5.5 a598255898 Add the natural spawn oracle and fixture
VanillaNaturalSpawnOracle runs NaturalSpawner (createState, the filtered
categories, spawnForChunk's loop, spawnCategoryForPosition and
spawnMobsForChunkGeneration) on a stand-in level built from boxes of blocks,
light and biomes with structure pieces, entities, players, the world spawn
and unloaded chunks; natural_spawn_fixture runs mobs::spawn_selection on the
same world.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:52:12 +02:00
Ludovic MarieandClaude Opus 5.5 139c5abd1c Port the DataFixers of 1.19 to 1.20 for worlds from 1.18.2 on
The fixes from 3076 to 3459 (game event, cat, frog, painting, goat, goat
horn, filtered text, POI, blending, spawn egg, effect duration, archaeology,
double-sided signs, the 1.20 experiment and chunk statuses), the block
entities their schemas add, and block entities' remainders (a decorated
pot's shards stay next to its sherds). The sample world generator logs in
to 1.18.2 to 1.19.4 servers and gates the scene by version.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:50:56 +02:00
Ludovic Marie 1b4c3dec6b Merge branch 'parity-base' into parity/blocks 2026-10-11 15:44:08 +02:00
Ludovic MarieandClaude Opus 5.5 3ab461cffc Upgrade older worlds when Horizon opens them, as 26.3 does
Opening a world saved by an older version runs the file fixes, level.dat and
the DataFixers upgrades of every file instead of refusing it. The save
round-trip harness treats a lit campfire's cooking progress as a timer in the
vanilla views: Horizon saves it as loaded (stage 3 checks it exactly), and it
moves on by however many ticks each vanilla server ran before data get.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:44:02 +02:00
Ludovic Marie 484bbb5ee8 Merge branch 'parity-base' into parity/loot-spawn 2026-10-11 15:43:57 +02:00
Ludovic MarieandClaude Opus 5.5 e019d361d9 WIP: natural spawn oracle and collision lookup (harness in progress)
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:43:57 +02:00
Ludovic Marie ef6499fb0e Merge branch 'parity-base' into parity/saves 2026-10-11 15:43:49 +02:00
Ludovic MarieandClaude Opus 5.5 13759ef16b Reuse unchanged vanilla oracle runs, and keep test games silent
tools/parity_shim.py stands in for java and javac in harness runs: it
records each oracle run's outputs and the content of every file under the
paths its arguments name, and replays the run while those are unchanged.
After a Rust-only change a harness only pays for its Rust side
(block_update 53 s -> 5 s). A harness that fails while reusing oracle
results is rerun fresh, and stops reusing them if that passes; --all and
--fresh never reuse them.

Harness probes built in the dev profile are optimised (overflow checks
and debug assertions kept), and the chunk generation harness runs its
vanilla and Horizon sides concurrently.

HORIZON_NO_AUDIO keeps the game off the speakers; tools/parity.py sets it,
since the save round-trip harness launches the game on a virtual screen.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:43:26 +02:00
Ludovic MarieandClaude Opus 5.5 c626f696f5 Set dispensed mobs down as EntityType.getYOffset does
Only the collision shapes overlapping the spawn block stop the fall, so a
wide mob beside the dispenser no longer lands a block high. Spawn egg
cases compare that the new mob speaks and where, not which of its voices
its own unseeded random picked.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:41:00 +02:00
Ludovic MarieandClaude Opus 5.5 49ecc8a7c3 Keep the fields DataFixerUpper's remainders bring back when upgrading saves
DataFixerUpper reads typed fields without taking them out of a record's
remainder, so a typed field a fix drops comes back on write with its value
from the record's last read: entities keep their old ArmorItems, HandItems,
body_armor_item, saddle, Trident and Particle next to the new fields, as
vanilla 26.3 writes them. Fixes that write and read entities refresh that
map. The area effect cloud particle moves to custom_particle (4420), boats
keep Type (BoatSplitFix's remainder update misses), and region files with a
short header read as empty, as vanilla's RegionFile does. The sample worlds
now use the item formats each version accepts and enable its experiments.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:40:30 +02:00
Ludovic MarieandClaude Opus 5.5 a21d268a41 Spawn naturally through a NaturalSpawner port shared by the game
mobs::spawn_selection ports NaturalSpawner over a NaturalLevel trait: the
spawn state (category counts, spawn potential charges, local caps), the
filtered categories, spawnForChunk, spawnCategoryForPosition with mobsAt,
isValidSpawnPostitionForType and isValidPositionForMob, and
spawnMobsForChunkGeneration. The game's spawning goes through it. Biomes
now load their spawn costs and creature_world_gen_spawn_probability.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:40:15 +02:00
Ludovic MarieandClaude Opus 5.5 f9fcb004a5 Dispense chest boats and command block minecarts as vanilla does
BoatDispenseItemBehavior spawns the chest boat or chest raft itself: boats
now carry ChestBoat's 27-slot chest, drawn with its model, saved with its
Items, opened by sneaking, spilt when broken. MinecartDispenseItemBehavior
spawns command block minecarts (an empty command). The dispenser harness
now draws fronts per item (fire, portal frames, anchors, crops, copper,
hives, golem and wither builds), every boat wood, more equipment, spawn
eggs, sulfur cube swallowables and leashed targets, over 6000 cases.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:38:13 +02:00
Ludovic MarieandClaude Opus 5.5 a5aaa53ab7 Light fires, portals, candles and anchors from dispensers as vanilla does
Flint and steel uses BaseFireBlock.canBePlacedAt and getState (a portal
frame draws the level random for vertical dispensers), posts block_place
and block_change, and lights only what CampfireBlock, CandleBlock and
CandleCakeBlock.canLight accept. Fire placed inside an empty obsidian
frame now lights the portal (BaseFireBlock.onPlace). Glowstone posts the
anchor's block_change. Shears cut leads (shearOffAllLeashConnections) and
empty a sulfur cube. Dropped leads draw motion from the item's own
random, not the level's. The oracle gives bone meal a chunk generator so
sapling, grass and moss cases are compared instead of erroring.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:31:35 +02:00
Ludovic Marie 888ce5a3ff Merge branch 'parity-base' into parity/loot-spawn 2026-10-11 15:27:03 +02:00
Ludovic Marie c9a1e959ee Merge branch 'parity-base' into parity/saves 2026-10-11 15:26:57 +02:00
Ludovic Marie 51b6b0b65e Merge branch 'parity-base' into parity/blocks 2026-10-11 15:26:48 +02:00
Ludovic MarieandClaude Opus 5.5 abee1f165b Let the gate pass with harnesses that already fail on the integration branch
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:05:57 +02:00
Ludovic MarieandClaude Opus 5.5 6396e8c161 Run the vanilla harnesses in parallel, only when their inputs change
tools/parity.py traces each harness (its Python, Java oracle sources and
the Rust sources of the probes it runs, from Cargo's dep-info) and reruns
it only when one of those, the vanilla artifacts or the toolchains change.
Harnesses run in parallel within the available memory, and their probes
build without LTO and with 16 codegen units into .local/parity-target: a
one-line change rebuilds every horizon-world probe in 3 seconds instead of
122. The full suite takes about 20 minutes instead of an hour.

tools/gate.py is the check a branch passes before integration, and
tools/supervise.py manages the per-area worktrees: status, new worktrees,
saving unfinished work, and integrating a branch only when the gate passes.

Harnesses find probes through lab.cargo_target() instead of a hard-coded
target/, the save round-trip harness builds the binaries it launches, and
the mob AI harness now fails on divergences like every other harness
(--report-only keeps the old behaviour). Debug builds keep line tables
only, so worktrees stay small.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 15:05:36 +02:00
Ludovic MarieandClaude Opus 5.5 bf3de4ef05 WIP: saves parity work, paused while the harness tooling is sped up
Uncommitted changes the agent had in progress when it was stopped; may
not build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:26:49 +02:00
Ludovic MarieandClaude Opus 5.5 45cb5850a9 WIP: blocks parity work, paused while the harness tooling is sped up
Uncommitted changes the agent had in progress when it was stopped; may
not build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:26:46 +02:00
Ludovic Marie 504c4df027 Merge branch 'parity-base' into parity/loot-spawn
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-11 14:17:15 +02:00
Ludovic MarieandClaude Opus 5.5 45b8861a85 Accept vanilla's clockless time sync in the LAN bot test
MinecraftServer.forceGameTimeSynchronization broadcasts SetTime with no
clocks every 20 ticks, and it can reach a joining bot before
PlayerList.sendLevelInfo's full sync. Wait for a sync with clocks instead
of failing on the first packet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:16:34 +02:00
Ludovic Marie 6391ef0444 Merge branch 'parity-base' into parity/blocks
# Conflicts:
#	crates/horizon-client/src/mobs.rs
2026-10-11 14:16:18 +02:00
Ludovic Marie e6e7de7ebe Merge branch 'parity-base' into parity/saves 2026-10-11 14:12:55 +02:00
Ludovic MarieandClaude Opus 5.5 6734879ff7 Pass rustfmt and clippy after merging the parity branches
Formats the overnight parity work, fixes the lints it introduced, and
updates the LAN bot test and mob AI probe for fields added on the other
side of the merges (the bot test also set a field that never existed).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:11:51 +02:00
Ludovic Marie 6421e48c8f Merge commit 'a903cb4' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie 46e54c642f Merge commit 'e6ef8c5' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie 05bcc61446 Merge branch 'parity/multiplayer' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie c47a853e49 Merge branch 'parity/entity-ai' into parity-base 2026-10-11 14:09:10 +02:00
Ludovic Marie 3950c0ef7b Merge parity/assets into parity-base
# Conflicts:
#	crates/horizon-client/src/game.rs
2026-10-11 14:09:10 +02:00
Ludovic MarieandClaude Opus 5.5 24a58c40d8 Query the shipped vanilla bytecode through REA
tools/rea_vanilla.py runs REA's JADX provider over the pinned client and
server JARs: class search, class members, method bodies and callers
(including lambdas). REA and the JADX engine are pinned in
research-lock.json.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-11 14:08:47 +02:00
Ludovic MarieandClaude Opus 5.5 37ae66c8c7 Track shulker, bell, conduit, skull and pot animation state as vanilla ticks it
block_entity_anim ports ShulkerBoxBlockEntity.updateAnimation and its
open-count events, BellBlockEntity triggerEvent/tick with resonation,
ConduitBlockEntity.clientTick (shape check, hunting, rotation, nautilus
particles), SkullBlockEntity.animation and DecoratedPotBlockEntity wobbles,
and maps the state to the renderer's BlockAnim.

tools/test_block_entity_anim_parity.py runs the real vanilla block entities
on a stand-in client level (VanillaBlockEntityAnimOracle) against
examples/block_entity_anim_fixture: 172/172 cases, 12720 ticked steps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:25:32 +02:00
Ludovic MarieandClaude Opus 5.5 6e9f218a14 WIP: unfinished rendering-hud parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 0a8fd9a78f WIP: unfinished loot-spawn parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 f78de6d9ae WIP: unfinished blocks parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 68092c4c7f WIP: unfinished saves parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 2012f8baad WIP: unfinished rendering-anim parity work, stopped for the night
Uncommitted changes the agent had in progress when it was stopped; may not
build or pass its harnesses yet.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:24:03 +02:00
Ludovic MarieandClaude Opus 5.5 fd23a8d682 Match vanilla atan2 tables, rotation constants, despawn checks and target search
Mth.atan2 builds its tables with StrictMath.asin (fdlibm, ported) and
HotSpot's cos; the controls multiply by the folded RAD_TO_DEG float
constant. Mob.checkDespawn runs before the tick and, in 26.3, persistent
mobs only reset their idle time near a player. NearestAttackableTargetGoal
always requires a line of sight and ignores mustReach when searching.
MeleeAttackGoal starts its canUse clock at game time 0. The harness now
records every random draw of the vanilla mobs with its caller, the random
state per tick, and starts each scenario at game time 1000.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:23:16 +02:00
Ludovic Marie 925e4d0f5f Merge remote-tracking branch 'desktop/parity-audio' into t3/achieve-full-parity 2026-10-10 22:22:41 +02:00
Ludovic Marie 1e0ad488bb Merge remote-tracking branch 'desktop/parity-input' into t3/achieve-full-parity 2026-10-10 22:21:34 +02:00
Ludovic MarieandClaude Opus 5.5 e6ef8c502b Check spawn placements and spawn predicates exactly as SpawnPlacements does
mobs::spawn_rules ports SpawnPlacements over a SpawnLevel trait the game's
MobWorld implements: the placement types with per-type isValidSpawn
(leaves, ice, magma for fire-immune types) and isValidEmptySpawnBlock with
each type's dangerous and immune blocks, and every registered predicate in
vanilla's draw order: the dimension's monster light test and block light
limit, thunder, peaceful, spawn reasons, guardians' sky from below water,
drowned, bats, ghasts, ocelots, slimes (surface chance and slime chunks),
strays under powder snow, striders, turtles, polar bears, tropical fish,
nautiluses, silverfish and endermites near players, patrols and the
spawnable-on tags.

Divergences fixed on the way: nether monsters ignored light, hoglins and
piglins had extra checks, slimes spawned outside slime chunks, ocelots and
ghasts skipped their odds, bats ignored the surface and their draw order,
and several predicates read the wrong blocks.

tools/test_spawn_rules_parity.py: 18,669 cases over 87 entity types match.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:17:01 +02:00
Ludovic MarieandClaude Opus 5.5 635acffda9 Draw every item through the resolved item model pipeline
The client now resolves stacks with horizon-assets' ItemModelResolver into
an ItemStackRenderState equivalent (item_render): baked cuboid layers with
their display and local transforms, tints and foil, and the special model
renderers (chests, shulker boxes, banners, heads, shields, tridents,
decorated pots, conduits, bells, books, copper golem statues) posed from
the entity model layers. GUI icons are submitted under the GuiItemAtlas
pose with ITEMS_3D/ITEMS_FLAT lighting, back faces culled and clipped to
the slot; dropped items follow ItemEntityRenderer (bounding box hover,
spin and seeded cluster copies); item frames, first-person and mob,
player and armour stand hands, worn skulls and head items submit the
same state in the world.

The client atlas also runs the atlas sprite sources (directory, single,
filter, unstitch, paletted_permutations), so armour trim sprites exist.
The ad-hoc icon, held-banner, shield, potion-layer and glint paths are
gone.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:16:52 +02:00
Ludovic MarieandClaude Opus 5.5 f77a22177a Click recipe book buttons at forUI volume and test the mixer
Recipe book widgets play SimpleSoundInstance.forUI(UI_BUTTON_CLICK, 1.0F),
whose volume is 0.25. Unit tests cover the mixer distance gain and the
stopped/looping source states.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:13:40 +02:00
Ludovic MarieandClaude Opus 5.5 17ac24f4db Drive toggle sprint/sneak as vanilla's key handlers do and compare
ToggleKeys holds the sneak, sprint, attack and use ToggleKeyMappings with
the dispatch of KeyboardHandler.keyPress (releases always reach them;
presses and SDL key repeats only without a screen), MouseHandler.onButton,
Gui.setScreen (releaseAll / restoreToggleStatesOnScreenClosed) and
LocalPlayer.respawn (resetToggleKeys, which Horizon lacked). Presses behind
a screen no longer flip a toggled key. tools/test_toggle_keys_parity.py
drives the real Options mappings through the real static KeyMapping
functions (8,000 sequences).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:12:23 +02:00
Ludovic MarieandClaude Opus 5.5 1357e2f01b Choose situational music exactly as Minecraft.getSituationalMusic does
Situation captures the screen music, the menu without a player, End boss
music, creative abilities and underwater selection and the music volume;
the harness calls the real getSituationalMusic/getMusicVolume with a real
camera attribute probe for every dimension and biome.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:10:06 +02:00
Ludovic MarieandClaude Opus 5.5 538347007a Add auto-jump and compare it with LocalPlayer.updateAutoJump
Horizon had the Auto-Jump option but never jumped. MotionWorld::move_self
now runs LocalPlayer.updateAutoJump after the player's own move (behind
canAutoJump: option, autoJumpTime, ground, shift, input, block jump
factor), the next aiStep presses jump for one tick (input.makeJump) without
toggling creative flight, and the game feeds the option each tick.
tools/test_auto_jump_parity.py runs the real updateAutoJump on a LocalPlayer
shell in an independently built copy of the resident test scene with real
block states (10,568 cases, 1,200 jumps).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:04:04 +02:00
Ludovic MarieandClaude Opus 5.5 b93f72c0da Play bubble column inside sounds as BubbleColumnAmbientSoundHandler does
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:03:51 +02:00
Ludovic Marie 8ef3195457 Merge remote-tracking branch 'desktop/parity-rendering' into t3/achieve-full-parity 2026-10-10 22:03:24 +02:00
Ludovic MarieandClaude Opus 5.5 d088140c0d Compare beacon beams with BeaconRenderer
Move the beam geometry into block_entity_render::beacon_beam so the
block entity render harness can replay it against vanilla submit calls
(positions, UVs and colours of the solid beam and glow per section).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 22:02:40 +02:00
Ludovic MarieandClaude Opus 5.5 9e4811dd6e Compare the ambient sound handlers tick by tick with vanilla LocalPlayer
The oracle runs the real updateIsUnderwater, UnderwaterAmbientSoundHandler
and BiomeAmbientSoundsHandler over the real SoundEngine, comparing every
level random draw, loop fade, mood sample and playing instance.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:57:02 +02:00
Ludovic MarieandClaude Opus 5.5 723ec3bb8a Drop entity death loot exactly as vanilla's LivingEntity.die does
Entity loot tables now run on the faithful loot engine with an entity
context: the victim, the killer and direct attacker, the damage type and
its tags, the last player to hurt it and their luck, entity predicates
(flags, equipment, components, sheep, cube size, raider captains,
vehicles, entity type tags), damage source predicates, killed_by_player,
random_chance_with_enchanted_bonus, enchanted_count_increase, furnace_smelt
and tag entries. Draws come from the world's random sequence for the table.

mobs::drops ports dropAllDeathLoot: Monster loot for babies, equipment
drops in EquipmentSlot order with Looting's equipment_drops effect and
durability, the fox's mouth item, the wither's star, the charged creeper
head hook, horse inventories and chests, and getBaseExperienceReward with
the equipment bonus and baby zombie reward, split into orbs.

Fixes found on the way: sheep dropped their wool twice, baby monsters
dropped nothing, armour bonus experience and the equipment slot order were
wrong, hoglins were fire immune, and fishing treasure was unreachable (the
hook predicate format changed).

tools/test_entity_death_loot_parity.py: 1,578 cases over 87 entity types
match vanilla.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:56:39 +02:00
Ludovic MarieandClaude Opus 5.5 268babc257 Lay out the HUD decorations exactly as vanilla Hud does
Add horizon_client::hud_layout (crosshair and attack indicator, hotbar
with selection, off hand by main arm, item slots with the pop squeeze,
armour, hearts including frozen and absorbing kinds, food, air bubbles,
mount hearts capped at 30, experience bar and level) and the
test_hud_parity harness whose oracle runs vanilla Hud methods on a
stand-in client. The game now draws these through hud_layout; the
experience level is centred with integer division like vanilla,
spectators get no crosshair and mount hearts show in creative too.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:56:23 +02:00
Ludovic MarieandClaude Opus 5.5 6eee2e3262 Compare audio environment attributes and Musics with the vanilla data
Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:52:28 +02:00
Ludovic MarieandClaude Opus 5.5 ebb66e451f Pace frames with vanilla's FramerateLimiter and compare it
The game now ends each frame with FramerateLimiter.limitDisplayFPS below
260 fps (sleep, overshoot averaging, spin-wait) instead of waking the event
loop at a computed deadline, and the limiter honours Thread.interrupted.
tools/test_frame_limiter_parity.py runs the real limiter bytecode with its
clock calls rewritten to a scripted virtual clock (5,288 cases).

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:51:04 +02:00
Ludovic MarieandClaude Opus 5.5 a903cb4dd5 Pass the save round-trip harness
Horizon spawned mobs whatever the spawn_mobs and spawn_monsters game rules
said; natural spawning now follows ServerChunkCache.tickChunks (the chunks
are still shuffled every tick) and the custom spawners tick only with
spawn_mobs. The slimes this put in the test world were what woke its sculk
sensor.

Falling blocks keep their UUID and loaded compound while they move, read
NoGravity, DropItem and a signed Time, and drop themselves as an item
after 600 ticks off the ground instead of landing. A crafter's saved
triggered flag only follows the block state when the state changes
(CrafterBlock.neighborChanged), and a horse saves the attribute instances
it was loaded with, as AttributeMap.pack does.

The harness compares vanilla log lines without their time stamps, accepts
extra entities only in chunks vanilla never generated, treats a falling
block's Time as a timer, and keeps the lit furnace's slots out of the
vanilla view. Its scene starts the falling block far below the 600-tick
limit and keeps the campfire from finishing during the run. The known-gap
list is empty.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:49:38 +02:00
Ludovic MarieandClaude Opus 5.5 13e434d49d Compare the music manager tick by tick with the real vanilla MusicManager
The oracle drives MusicManager over the real SoundEngine with the situation
as input; the script covers delays, replacements, frequencies, fades,
loading screens, pauses, silent starts, toasts and unplayable events.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:49:11 +02:00
Ludovic MarieandClaude Opus 5.5 b29f51f8e1 Add a differential mob AI harness and match vanilla mob movement math
VanillaMobAiOracle runs vanilla mobs in a real headless flat server level,
ticking them as ServerLevel does while the rest of the level stands
still; mob_ai_probe replays the same scenarios on Horizon. Both print each
mob's AI-visible state every tick (position, motion, rotations, goals,
targets, navigation, controls, input, brain activities and memories,
sounds) and tools/test_mob_ai_parity.py reports the first divergence.

Fixes found: float entity dimensions, Mth.sin/cos in moveRelative and the
strafe/swim controls, Mth.atan2 and the float thresholds of the controls,
RandomPos float constants, the ambient sound clock starting at zero, hurt
and death sounds drawn from the mob's own random (also when burning),
knockback for every damage type not tagged no_knockback, Entity.move's
collision flags, restitution and footstep/swim sounds, and mob sound
volumes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:46:57 +02:00
Ludovic MarieandClaude Opus 5.5 f85411f2fb Port the camera, field of view and view/hurt bobbing exactly
Camera.tick, tickFov, alignWithEntity with getMaxZoom over visual-shape
clipping, setRotation and move with JOML quaternion arithmetic,
calculateFov/calculateHudFov, getFieldOfViewModifier (now including
flight), ClientAvatarState walk/bob history (the backwards-extrapolated
walk distance and velocity-based bob) and GameRenderer.bobHurt/bobView as
the JOML pose matrix with property flags. The game renders through them,
adding hurt tilt, the camera distance and scale attributes, the mirrored
front camera and the sleeping camera. tools/test_camera_parity.py compares
18,502 cases against the real classes.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:45:35 +02:00
Ludovic MarieandClaude Opus 5.5 ccdf49d3e5 Play every sound through the vanilla SoundEngine model
Audio now keeps vanilla SoundEngine bookkeeping over a mixer that stands in
for OpenAL sources (gain, pitch, linear distance attenuation, looping,
relative, source states, the 247/8 channel pools), ticks once per client
tick with pauseAllExcept/resume and stops everything when the world closes.
MusicManager holds a forMusic instance and checks isActive as vanilla does;
the ambient handlers play LoopSoundInstance, UnderLiquid* and mood instances,
the enter/exit sounds seeded from the level random; jukebox songs use
forJukeboxSong instead of a hand-made volume ramp.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:44:58 +02:00
Ludovic MarieandClaude Opus 5.5 3429de0020 Compare block entity rendering with vanilla renderers
Add a differential harness (test_block_entity_render_parity) whose Java
oracle runs each vanilla BlockEntityRenderer on a real block entity and
captures the submitted model vertices, textures, light and text calls.
Horizon now animates shulker lids, bell shakes, active conduits (cage,
wind and the camera-facing eye), skull jaws and ears, pot wobble and
sherd sides; signs wrap lines like StringSplitter and measure spaces
with the font; books use the Mth sine table and player heads the slim
default skin.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:35:45 +02:00
Ludovic MarieandClaude Opus 5.5 d68efbabfc Compare the sound engine bookkeeping with the real vanilla SoundEngine
The oracle runs vanilla SoundManager/SoundEngine on OpenAL Soft null device
(inline executor, silent buffers) and replays scripts of plays, stops,
category gains, option volumes, channel ends, pauses, queued, delayed and
looping sounds and tickable ambient instances, printing every AL source
parameter, isActive, subtitle notification and pool count per step.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:33:17 +02:00
Ludovic MarieandClaude Opus 5.5 f2232c3ed7 Rescore the Java parity table at 84%
World generation and interaction now have every covering harness passing and
score 1. The other rows keep 0.75, with their gaps rewritten to name what is
still missing now that the block, command, entity model and item model
harnesses pass; the next steps follow the open gaps.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:22:38 +02:00
Ludovic MarieandClaude Opus 5.5 fc468a021d Resolve item models exactly as the vanilla client does
The item model harness ran out of memory, so it now streams both outputs and
looks Horizon's rows up by offset. With the vanilla side no longer crashing on
swap-animation lookups (properties now come from the baked set), on armour
(trim sprites are generated by the items atlas's paletted_permutations source
once SpriteSources is bootstrapped) or on compasses (item tags are bound from
the data pack), 197,000 more stacks were compared and 121,841 differed.

Horizon now composes transformations as JOML 1.10.9 does: rotationXYZ and
axis-angle quaternions with cosFromSin, and Matrix4f operations that take the
identity, translation, affine or generic path by matrix properties, with
unfused a * b + c. Float RGB tint constants are scaled by 255, the shulker box
renderer always encodes its texture, and a new palette module ports
PalettedPermutations and PaletteMapping so trim sprites exist on the Horizon
side too.

196,290 resolved stacks match; 2,582 cases are still rejected by vanilla's
stand-in client and not compared.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:21:50 +02:00
Ludovic MarieandClaude Opus 5.5 1d70ca47a8 Generate whole chunks block for block as vanilla does
The chunk generation harness found 70 differing chunks across three seeds in
the overworld, Nether and End. Proto chunks, biomes and decoration now span
the dimension type's build range rather than the noise height, so features
above y=128 in the Nether and End city upper floors survive; the decoration
level reports no sky light, as unlit vanilla chunks do, so mushrooms and crops
pass or fail their light checks the same way; beehive decorators store their
bees; iceberg ellipses skip NaN distances; double slabs never waterlog;
mineshaft spawners save an empty SpawnPotentials list; and the dump example
forgets structure starts between rectangles, as the oracle starts each with
fresh chunks.

The oracle prints absent block entity keys as vanilla loads them from an empty
tag, and the harness treats vanilla's save-format equivalences (plain sign
text, absent empty banner patterns, unfiltered sign messages) as equal. All 2,352
chunks over 225 structure sites match with zero block, biome or block entity
differences.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:20:51 +02:00
Ludovic MarieandClaude Opus 5.5 ad4d836f6f Resolve sound events exactly as vanilla SoundManager does
Add SoundRegistry (sounds.json with replace, subtitles, file validation and
event references weighing as much as their event) and a SoundEngine model,
with a differential harness resolving every sounds.json event against the
real SoundManager.prepare and SimpleSoundInstance.getOrResolve.

Co-Authored-By: Claude Opus 5.5 <[email protected]>
2026-10-10 21:16:28 +02:00
275 changed files with 50773 additions and 6675 deletions

No files matched your search

+5
View File
@@ -15,6 +15,11 @@ type_complexity = "allow"
needless_range_loop = "allow"
large_enum_variant = "allow"
# Line tables keep file:line in backtraces; full debug info made every
# example and test binary hundreds of megabytes and slow to link.
[profile.dev]
debug = "line-tables-only"
[profile.release]
lto = "thin"
codegen-units = 1
+18 -17
View File
@@ -16,41 +16,42 @@ Horizon is a Rust rewrite of Minecraft Java **26.3** targeting one-to-one vanill
Parity is **not** complete. Still missing: the warden and the sculk family, the breeze, sniffer, creaking, happy ghast, llamas and a few other mobs, raids and pillager patrols, villager breeding and gossip, the loom, crafter and recipe book, scoreboards, subtitles, and spawned mob gear enchantments. Multiplayer joins vanilla servers but Horizon cannot host one. See [Rust rewrite status](docs/rust-parity.md).
### Java parity: 80%
### Java parity: 84%
Each subsystem scores 0 (absent), 0.25 (started, not playable), 0.5 (playable,
major vanilla features missing), 0.75 (playable, mostly complete) or 1 (complete
and verified against vanilla by differential tests). The percentage is the
average; `tests/test_parity.py` keeps it in sync with this table. Last audited
2026-10-10 against the code, not the docs: of the 232 `tools/test_*_parity.py`
differential harnesses, 223 pass, 8 fail and the item model harness runs out of
memory; the failures are new harnesses that still find divergences.
2026-10-10 against the code, not the docs. The harnesses that failed in the
previous audit (block updates, random ticks, redstone, command parsing, menu
clicks, whole chunks, entity models and item models) now pass; the save
round-trip harness has not been rerun.
| Subsystem | Score | Main gaps |
| --- | ---: | --- |
| Window, input and game loop | 0.75 | Game loop and options match vanilla; camera, auto-jump, toggle sprint/sneak and the frame limiter not compared |
| Assets and resources | 0.75 | Item model pipeline not yet used for all GUI icons; item model harness does not finish; no pack screen |
| Assets and resources | 0.75 | Item models match vanilla over 196,290 stacks, but the client does not draw items through that pipeline yet; no pack screen |
| Network protocol and multiplayer | 0.75 | Opens worlds to LAN, but hosting is not tested against a vanilla client; no dedicated server mode |
| Chunk storage and palettes | 1 | None: client and saves share paletted containers, identical to vanilla over 266 scenarios |
| Section meshing | 1 | None: 112 scenes and over a million quads identical to vanilla |
| Rendering | 0.75 | Entity model harness fails; block entity renderers and HUD overlays not compared |
| Rendering | 0.75 | Entity models match over 150 layers; block entity renderers and HUD overlays not compared |
| Lighting | 1 | None: saved and relit light matches vanilla, slabs and stairs included |
| World generation | 0.75 | Whole-chunk harness still finds single-block differences near camps, shipwrecks and villages |
| Region files and saves | 0.75 | Save round-trip harness fails; older DataVersions are not upgraded |
| Entities and physics | 0.75 | Attributes and dimensions match; AI, loot and spawn rules not compared; drowned, villagers, traders and the wither unverified |
| Interaction and inventory | 0.75 | Menu click harness fails |
| Menus, HUD and chat | 0.75 | Command parse harness fails; no /item modify, /spectate, /waypoint or /dialog; no tab list or world border rendering |
| World generation | 1 | None: 2,352 whole chunks over 225 structure sites, in all three dimensions, identical to vanilla |
| Region files and saves | 0.75 | Saves round-trip with vanilla, and worlds from 1.18.2 to 26.2 upgrade as vanilla's DataFixers do over 22 sample worlds; worlds older than 1.18.2 are not upgraded |
| Entities and physics | 0.75 | Attributes, dimensions, death loot, spawn rules and natural spawning (15,500 NaturalSpawner cases) match; AI not compared; drowned, villagers, traders and the wither unverified |
| Interaction and inventory | 1 | None: menu clicks match over 6,000 click sequences |
| Menus, HUD and chat | 0.75 | Commands parse as vanilla over 39,685 inputs, but /item modify, /spectate, /waypoint and /dialog do not run; no tab list or world border rendering |
| Audio | 0.75 | Sound engine, music and ambience not compared with vanilla |
| Block behaviours and ticking | 0.75 | Block update, random tick and redstone harnesses fail; a few dispenser behaviours missing |
| Block behaviours and ticking | 0.75 | Block updates, random ticks, redstone and dispensers match vanilla; a container replaced by another block keeps its contents instead of spilling them, and there are no copper golems |
### Next steps
1. Fix the divergences the failing harnesses report: block updates, random ticks, redstone, command parsing, menu clicks, save round-trip, whole chunks and entity models.
2. Let the item model harness finish, and draw every GUI icon through the item model pipeline.
1. Upgrade saves older than 1.18.2 (the DataFixers before DataVersion 2970).
2. Draw every item through the item model pipeline, and add the resource pack screen.
3. An audio harness: sound engine attenuation, weighted sound events, music and ambience.
4. Mob AI, loot and spawn-rule harnesses, and an oracle for drowned, villagers, traders and the wither.
5. The missing commands, the tab list and world border rendering.
6. Test LAN hosting with a vanilla client, and upgrade saves from older DataVersions.
4. A mob AI harness, and an oracle for drowned, villagers, traders and the wither.
5. The missing commands, containers spilling when replaced, copper golems, the tab list and world border rendering.
6. Test LAN hosting with a vanilla client, and add a dedicated server mode.
```sh
python3 tools/prepare_native_client.py --prepare # needs JDK 25; extracts private vanilla resources
@@ -6,6 +6,7 @@ use horizon_assets::{
item_model::*,
math::Mat4,
model::{CuboidModel, Direction},
palette::{PalettedPermutations, apply_palette},
resolve::{MISSING_MODEL, ModelLibrary},
};
use serde_json::{Value, json};
@@ -23,6 +24,8 @@ fn bits(v: f32) -> u32 {
struct Sprites {
root: PathBuf,
/// The items atlas's paletted permutations (armour trim sprites).
permutations: Vec<PalettedPermutations>,
cache: RefCell<BTreeMap<String, Option<SpriteMask>>>,
}
impl SpriteLookup for Sprites {
@@ -38,27 +41,48 @@ impl SpriteLookup for Sprites {
}
}
impl Sprites {
fn load(&self, sprite: &str) -> Option<SpriteMask> {
let (namespace, path) = sprite.split_once(':')?;
let png = self
.root
.join(format!("assets/{namespace}/textures/{path}.png"));
let mut decoder = png::Decoder::new(BufReader::new(File::open(&png).ok()?));
fn png(&self, sprite: &str, directory: &str) -> PathBuf {
let (namespace, path) = sprite.split_once(':').unwrap_or(("minecraft", sprite));
self.root
.join(format!("assets/{namespace}/{directory}/{path}.png"))
}
/// The image as ARGB pixels in reading order, with its size.
fn argb(png: &Path) -> Option<(usize, usize, Vec<u32>)> {
let mut decoder = png::Decoder::new(BufReader::new(File::open(png).ok()?));
decoder.set_transformations(png::Transformations::EXPAND | png::Transformations::STRIP_16);
let mut reader = decoder.read_info().ok()?;
let mut bytes = vec![0; reader.output_buffer_size()?];
let info = reader.next_frame(&mut bytes).ok()?;
let channels = info.color_type.samples();
let alpha = |pixel: &[u8]| match info.color_type {
png::ColorType::Rgba => pixel[3],
png::ColorType::GrayscaleAlpha => pixel[1],
_ => 255,
};
let (iw, ih) = (info.width as usize, info.height as usize);
let pixels: Vec<u8> = bytes[..info.buffer_size()]
let pixels = bytes[..info.buffer_size()]
.chunks_exact(channels)
.map(alpha)
.map(|p| {
let (r, g, b, a) = match info.color_type {
png::ColorType::Rgba => (p[0], p[1], p[2], p[3]),
png::ColorType::Rgb => (p[0], p[1], p[2], 255),
png::ColorType::GrayscaleAlpha => (p[0], p[0], p[0], p[1]),
_ => (p[0], p[0], p[0], 255),
};
u32::from_be_bytes([a, r, g, b])
})
.collect();
Some((info.width as usize, info.height as usize, pixels))
}
fn load(&self, sprite: &str) -> Option<SpriteMask> {
let png = self.png(sprite, "textures");
let (iw, ih, argb) = match Self::argb(&png) {
Some(image) => image,
None => {
// PalettedPermutations: the base texture through PaletteMapping.
let p = self.permutations.iter().find_map(|s| s.find(sprite))?;
let (w, h, mut pixels) = Self::argb(&self.png(p.base, "textures"))?;
let (_, _, key) = Self::argb(&self.png(p.palette_key, "textures/palettes"))?;
let (_, _, target) = Self::argb(&self.png(p.palette, "textures/palettes"))?;
apply_palette(&mut pixels, &key, &target)?;
(w, h, pixels)
}
};
let pixels: Vec<u8> = argb.iter().map(|p| (p >> 24) as u8).collect();
let meta = fs::read_to_string(png.with_extension("png.mcmeta")).ok();
let animation = meta
.and_then(|m| serde_json::from_str::<Value>(&m).ok())
@@ -215,6 +239,10 @@ fn main() -> Result<(), Box<dyn std::error::Error>> {
let library = ModelLibrary::with_item_generator(cuboids)?;
let sprites = Sprites {
root: root.clone(),
permutations: PalettedPermutations::from_atlas(
&fs::read_to_string(root.join("assets/minecraft/atlases/items.json"))
.unwrap_or_default(),
),
cache: RefCell::default(),
};
let grass = {
+4 -2
View File
@@ -342,9 +342,11 @@ fn optional_transformation(m: &Map<String, Value>) -> Result<Option<Mat4>> {
get(m, "transformation").map(transformation).transpose()
}
/// `Mth.floor(float)` as `ARGB.as8BitChannel` uses it.
/// `ARGB.as8BitChannel`: `Mth.floor(value * 255.0F)`.
fn channel(v: f32) -> i32 {
let i = v as i32;
if (i as f32) > v { i - 1 } else { i }
let scaled = v * 255.0;
let i = scaled as i32;
if (i as f32) > scaled { i - 1 } else { i }
}
// ---------------------------------------------------------------------------
@@ -207,9 +207,7 @@ impl SpecialModel {
}
SpecialKind::PlayerHead => "player_head",
SpecialKind::ShulkerBox { texture, openness } => {
if texture != "minecraft:shulker" {
put("texture", json!(texture));
}
put("texture", json!(texture));
if *openness != 0.0 {
put("openness", json!(openness));
}
+1
View File
@@ -9,6 +9,7 @@ pub mod item_model;
mod json;
pub mod math;
pub mod model;
pub mod palette;
pub mod resolve;
pub use json::DecodeError;
+260 -38
View File
@@ -1,31 +1,91 @@
//! Column-major `Matrix4f` arithmetic with the operand order of JOML's
//! default (non-FMA) code paths, for composing item-model transformations.
//! Column-major `Matrix4f` arithmetic as JOML 1.10.9 computes it, for composing
//! item-model transformations: each operation takes the path JOML picks from
//! the matrix properties (identity, translation, affine...), and `Math.fma` is
//! `a * b + c` since Minecraft leaves `joml.useMathFma` off.
/// A JOML `Matrix4f`: `m[column][row]`.
/// `Matrix4fc.PROPERTY_*` bits.
const PERSPECTIVE: u8 = 1;
const AFFINE: u8 = 2;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
const ORTHONORMAL: u8 = 16;
/// JOML `Math.fma` without `joml.useMathFma`.
#[inline]
fn fma(a: f32, b: f32, c: f32) -> f32 {
a * b + c
}
/// JOML `Math.absEqualsOne`.
fn abs_equals_one(r: f32) -> bool {
r.to_bits() & 0x7FFF_FFFF == 0x3F80_0000
}
/// A JOML `Matrix4f`: `m[column][row]`, with its property bits.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Mat4(pub [[f32; 4]; 4]);
pub struct Mat4(pub [[f32; 4]; 4], pub u8);
impl Mat4 {
pub const IDENTITY: Self = Self([
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
]);
/// `new Matrix4f()`.
pub const IDENTITY: Self = Self(
[
[1.0, 0.0, 0.0, 0.0],
[0.0, 1.0, 0.0, 0.0],
[0.0, 0.0, 1.0, 0.0],
[0.0, 0.0, 0.0, 1.0],
],
IDENTITY | AFFINE | TRANSLATION | ORTHONORMAL,
);
/// `Matrix4f.translation(x, y, z)`.
pub fn translation(t: [f32; 3]) -> Self {
let mut m = Self::IDENTITY;
m.0[3][..3].copy_from_slice(&t);
m.1 = AFFINE | TRANSLATION | ORTHONORMAL;
m
}
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order.
/// `ExtraCodecs.MATRIX4F`: sixteen values in row-major order, then
/// `determineProperties`.
pub fn from_row_major(values: [f32; 16]) -> Self {
let mut m = Self([[0.0; 4]; 4]);
let mut m = Self([[0.0; 4]; 4], 0);
for (i, v) in values.into_iter().enumerate() {
m.0[i & 3][i >> 2] = v;
}
m.1 = m.determine_properties();
m
}
/// `Matrix4f.determineProperties`.
fn determine_properties(&self) -> u8 {
let m = &self.0;
let mut properties = 0;
if m[0][3] == 0.0 && m[1][3] == 0.0 {
if m[2][3] == 0.0 && m[3][3] == 1.0 {
properties |= AFFINE;
if m[0] == [1.0, 0.0, 0.0, 0.0]
&& m[1] == [0.0, 1.0, 0.0, 0.0]
&& m[2] == [0.0, 0.0, 1.0, 0.0]
{
properties |= TRANSLATION | ORTHONORMAL;
if m[3][0] == 0.0 && m[3][1] == 0.0 && m[3][2] == 0.0 {
properties |= IDENTITY;
}
}
} else if m[0][1] == 0.0
&& m[0][2] == 0.0
&& m[1][0] == 0.0
&& m[1][2] == 0.0
&& m[2][0] == 0.0
&& m[2][1] == 0.0
&& m[3][0] == 0.0
&& m[3][1] == 0.0
&& m[3][3] == 0.0
{
properties |= PERSPECTIVE;
}
}
properties
}
fn has(&self, property: u8) -> bool {
self.1 & property != 0
}
/// The matrix as `[row][column]` entries, the order `getRowColumn` reads.
pub fn rows(&self) -> [[f32; 4]; 4] {
std::array::from_fn(|r| std::array::from_fn(|c| self.0[c][r]))
@@ -33,37 +93,143 @@ impl Mat4 {
/// `this * right` (`Matrix4f.mul`).
pub fn mul(&self, right: &Self) -> Self {
let (m, r) = (&self.0, &right.0);
if self.has(IDENTITY) {
return *right;
}
if right.has(IDENTITY) {
return *self;
}
let mut out = [[0.0; 4]; 4];
if self.has(TRANSLATION) && right.has(AFFINE) {
// mulTranslationAffine
for c in 0..4 {
out[c] = [r[c][0], r[c][1], r[c][2], m[c][3]];
}
for row in 0..3 {
out[3][row] = r[3][row] + m[3][row];
}
return Self(out, AFFINE | right.1 & ORTHONORMAL);
}
if self.has(AFFINE) && right.has(AFFINE) {
// mulAffine
for c in 0..3 {
for row in 0..3 {
out[c][row] = fma(
m[0][row],
r[c][0],
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
);
}
out[c][3] = m[c][3];
}
for row in 0..3 {
out[3][row] = fma(
m[0][row],
r[3][0],
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
);
}
out[3][3] = m[3][3];
return Self(out, AFFINE | self.1 & right.1 & ORTHONORMAL);
}
if self.has(PERSPECTIVE) && right.has(AFFINE) {
// mulPerspectiveAffine
for c in 0..4 {
out[c] = [
m[0][0] * r[c][0],
m[1][1] * r[c][1],
m[2][2] * r[c][2],
m[2][3] * r[c][2],
];
}
out[3][2] = m[2][2] * r[3][2] + m[3][2];
out[3][3] = m[2][3] * r[3][2];
return Self(out, 0);
}
if right.has(AFFINE) {
// mulAffineR
for c in 0..3 {
for row in 0..4 {
out[c][row] = fma(
m[0][row],
r[c][0],
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
);
}
}
for row in 0..4 {
out[3][row] = fma(
m[0][row],
r[3][0],
fma(m[1][row], r[3][1], fma(m[2][row], r[3][2], m[3][row])),
);
}
return Self(
out,
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | ORTHONORMAL),
);
}
// mul0
for c in 0..4 {
for row in 0..4 {
out[c][row] = m[0][row] * r[c][0]
+ m[1][row] * r[c][1]
+ m[2][row] * r[c][2]
+ m[3][row] * r[c][3];
out[c][row] = fma(
m[0][row],
r[c][0],
fma(
m[1][row],
r[c][1],
fma(m[2][row], r[c][2], m[3][row] * r[c][3]),
),
);
}
}
Self(out)
Self(out, 0)
}
/// `Matrix4f.translate(x, y, z)` (`translateGeneric`).
/// `Matrix4f.translate(x, y, z)`.
pub fn translate(&self, t: [f32; 3]) -> Self {
if self.has(IDENTITY) {
return Self::translation(t);
}
// translateGeneric
let mut out = *self;
for row in 0..4 {
out.0[3][row] = self.0[0][row] * t[0]
+ (self.0[1][row] * t[1] + (self.0[2][row] * t[2] + self.0[3][row]));
out.0[3][row] = fma(
self.0[0][row],
t[0],
fma(
self.0[1][row],
t[1],
fma(self.0[2][row], t[2], self.0[3][row]),
),
);
}
out.1 = self.1 & !(PERSPECTIVE | IDENTITY);
out
}
/// `Matrix4f.scale(x, y, z)`: the first three columns are scaled.
/// `Matrix4f.scale(x, y, z)`.
pub fn scale(&self, s: [f32; 3]) -> Self {
let one = s.iter().all(|&v| abs_equals_one(v));
if self.has(IDENTITY) {
// scaling
let mut out = Self::IDENTITY;
for (c, v) in s.into_iter().enumerate() {
out.0[c][c] = v;
}
out.1 = AFFINE | if one { ORTHONORMAL } else { 0 };
return out;
}
// scaleGeneric: the first three columns are scaled.
let mut out = *self;
for c in 0..3 {
for row in 0..4 {
out.0[c][row] = self.0[c][row] * s[c];
}
}
out.1 =
self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION | if one { 0 } else { ORTHONORMAL });
out
}
/// `Matrix4f.rotate(Quaternionfc)` (`rotateGeneric`).
/// `Matrix4f.rotate(Quaternionfc)`.
pub fn rotate(&self, q: [f32; 4]) -> Self {
let [x, y, z, w] = q;
let (w2, x2, y2, z2) = (w * w, x * x, y * y, z * z);
@@ -71,14 +237,43 @@ impl Mat4 {
let (dzw, dxy, dxz, dyw, dyz, dxw) = (zw + zw, xy + xy, xz + xz, yw + yw, yz + yz, xw + xw);
let r = [
[w2 + x2 - z2 - y2, dxy + dzw, dxz - dyw],
[dxy - dzw, y2 - z2 + w2 - x2, dyz + dxw],
[-dzw + dxy, y2 - z2 + w2 - x2, dyz + dxw],
[dyw + dxz, dyz - dxw, z2 - y2 - x2 + w2],
];
let m = &self.0;
let mut out = *self;
for c in 0..3 {
for row in 0..4 {
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
if self.has(IDENTITY) {
// rotation
for c in 0..3 {
out.0[c][..3].copy_from_slice(&r[c]);
}
out.1 = AFFINE | ORTHONORMAL;
return out;
}
out.1 = self.1 & !(PERSPECTIVE | IDENTITY | TRANSLATION);
if self.has(TRANSLATION) {
// rotateTranslation
for c in 0..3 {
out.0[c] = [r[c][0], r[c][1], r[c][2], 0.0];
}
} else if self.has(AFFINE) {
// rotateAffine
for c in 0..3 {
for row in 0..3 {
out.0[c][row] = m[0][row] * r[c][0] + m[1][row] * r[c][1] + m[2][row] * r[c][2];
}
out.0[c][3] = 0.0;
}
} else {
// rotateGeneric
for c in 0..3 {
for row in 0..4 {
out.0[c][row] = fma(
m[0][row],
r[c][0],
fma(m[1][row], r[c][1], m[2][row] * r[c][2]),
);
}
}
}
out
@@ -94,28 +289,50 @@ pub fn normalize_quaternion(q: [f32; 4]) -> [f32; 4] {
let inv = 1.0 / (x * x + (y * y + (z * z + w * w))).sqrt();
[x * inv, y * inv, z * inv, w * inv]
}
/// JOML `Math.sin(float)` without fast math.
fn joml_sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// JOML `Math.cosFromSin(float, float)` without fast math: the magnitude from
/// the sine, the sign from the angle's quadrant (exact zeros at right angles).
fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let tau = (std::f64::consts::PI * 2.0) as f32;
let a = angle + std::f32::consts::FRAC_PI_2;
let mut b = a - (a / tau) as i32 as f32 * tau;
if b < 0.0 {
b += tau;
}
if b >= std::f32::consts::PI { -cos } else { cos }
}
/// `Quaternionf.rotationXYZ` from angles in radians.
pub fn quaternion_xyz(x: f32, y: f32, z: f32) -> [f32; 4] {
let half = |a: f32| -> (f32, f32) {
let h = a * 0.5;
((f64::from(h).sin()) as f32, (f64::from(h).cos()) as f32)
let s = joml_sin(h);
(s, cos_from_sin(s, h))
};
let ((sx, cx), (sy, cy), (sz, cz)) = (half(x), half(y), half(z));
let (cx_cy, sx_cy, cx_sy, sx_sy) = (cx * cy, sx * cy, cx * sy, sx * sy);
let (cycz, sysz, sycz, cysz) = (cy * cz, sy * sz, sy * cz, cy * sz);
[
sx_cy * cz + cx_sy * sz,
cx_sy * cz - sx_cy * sz,
cx_cy * sz + sx_sy * cz,
cx_cy * cz - sx_sy * sz,
sx * cycz + cx * sysz,
cx * sycz - sx * cysz,
cx * cysz + sx * sycz,
cx * cycz - sx * sysz,
]
}
/// `new Quaternionf(new AxisAngle4f(angle, axis))`.
/// `new Quaternionf(new AxisAngle4f(angle, axis))`: the angle is wrapped into
/// [0, 2π) and the axis is used as given.
pub fn quaternion_from_axis_angle(angle: f32, axis: [f32; 3]) -> [f32; 4] {
let half = angle * 0.5;
let (sin, cos) = (half.sin(), half.cos());
let norm = 1.0 / (axis[0] * axis[0] + axis[1] * axis[1] + axis[2] * axis[2]).sqrt();
let s = sin * norm;
[axis[0] * s, axis[1] * s, axis[2] * s, cos]
let tau = (std::f64::consts::PI * 2.0) as f32;
let angle = if angle < 0.0 {
tau + angle % tau
} else {
angle
} % tau;
let sin = joml_sin(angle * 0.5);
let cos = cos_from_sin(sin, angle * 0.5);
[axis[0] * sin, axis[1] * sin, axis[2] * sin, cos]
}
#[cfg(test)]
@@ -137,4 +354,9 @@ mod tests {
let m = Mat4::IDENTITY.rotate(q);
assert!((m.0[0][1] - 1.0).abs() < 1e-6 && m.0[0][0].abs() < 1e-6);
}
#[test]
fn half_turn_has_exact_zeros_like_joml() {
let half_turn = 180.0 * (std::f64::consts::PI / 180.0) as f32;
assert_eq!(quaternion_xyz(0.0, half_turn, 0.0), [0.0, 1.0, 0.0, 0.0]);
}
}
+144
View File
@@ -0,0 +1,144 @@
//! `PalettedPermutations` atlas sources: sprites such as
//! `trims/items/boots_trim_quartz` are the base texture with its colours
//! swapped through a palette pair (`PaletteMapping`), not files of their own.
use serde_json::Value;
use std::collections::BTreeMap;
/// One `minecraft:paletted_permutations` source of an atlas definition.
#[derive(Clone, Debug)]
pub struct PalettedPermutations {
/// Base sprite names (`minecraft:trims/items/boots_trim`).
pub textures: Vec<String>,
/// The key palette (`minecraft:trim_base`).
pub palette_key: String,
/// Suffix to target palette (`quartz` → `minecraft:trim/quartz`).
pub permutations: BTreeMap<String, String>,
pub separator: String,
}
/// A permuted sprite: its base texture and the palette pair to apply.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Permutation<'a> {
pub base: &'a str,
pub palette_key: &'a str,
pub palette: &'a str,
}
fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
impl PalettedPermutations {
/// The `paletted_permutations` sources of an atlas JSON (`atlases/items.json`).
pub fn from_atlas(atlas: &str) -> Vec<Self> {
let Ok(json) = serde_json::from_str::<Value>(atlas) else {
return Vec::new();
};
let sources = json
.get("sources")
.and_then(Value::as_array)
.cloned()
.unwrap_or_default();
sources
.iter()
.filter(|s| {
s.get("type")
.and_then(Value::as_str)
.map(qualified)
.as_deref()
== Some("minecraft:paletted_permutations")
})
.filter_map(|s| {
Some(Self {
textures: s
.get("textures")?
.as_array()?
.iter()
.filter_map(Value::as_str)
.map(qualified)
.collect(),
palette_key: qualified(s.get("palette_key")?.as_str()?),
permutations: s
.get("permutations")?
.as_object()?
.iter()
.filter_map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
.collect(),
separator: s
.get("separator")
.and_then(Value::as_str)
.unwrap_or("_")
.to_owned(),
})
})
.collect()
}
/// The permutation that produces `sprite` (`withSuffix(separator + key)`).
pub fn find(&self, sprite: &str) -> Option<Permutation<'_>> {
let sprite = qualified(sprite);
self.textures.iter().find_map(|base| {
let suffix = sprite
.strip_prefix(base.as_str())?
.strip_prefix(self.separator.as_str())?;
Some(Permutation {
base,
palette_key: &self.palette_key,
palette: self.permutations.get(suffix)?,
})
})
}
}
/// `PaletteMapping.create(key, target).apply` over ARGB pixels; the palettes
/// are the pixels of their images in reading order (`Palette.from`).
pub fn apply_palette(pixels: &mut [u32], key: &[u32], target: &[u32]) -> Option<()> {
if key.len() != target.len() {
return None;
}
let mut map = BTreeMap::new();
for (&k, &t) in key.iter().zip(target) {
if k >> 24 != 0 {
map.insert(k | 0xFF00_0000, t);
}
}
for pixel in pixels {
let alpha = *pixel >> 24;
if alpha == 0 {
continue;
}
let rgb = *pixel | 0xFF00_0000;
let target = map.get(&rgb).copied().unwrap_or(rgb);
*pixel = (alpha * (target >> 24) / 255) << 24 | target & 0x00FF_FFFF;
}
Some(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn finds_trim_permutations_and_maps_colours() {
let atlas = r#"{"sources":[{"type":"minecraft:paletted_permutations","palette_key":"minecraft:trim_base",
"permutations":{"quartz":"minecraft:trim/quartz"},"textures":["minecraft:trims/items/boots_trim"]}]}"#;
let sources = PalettedPermutations::from_atlas(atlas);
let p = sources[0]
.find("minecraft:trims/items/boots_trim_quartz")
.unwrap();
assert_eq!(
(p.base, p.palette),
("minecraft:trims/items/boots_trim", "minecraft:trim/quartz")
);
assert!(
sources[0]
.find("minecraft:trims/items/boots_trim_gold")
.is_none()
);
let mut pixels = [0xFF11_2233, 0x8011_2233, 0x0011_2233, 0xFF44_5566];
apply_palette(&mut pixels, &[0xFF11_2233], &[0xFFAA_BBCC]).unwrap();
assert_eq!(pixels, [0xFFAA_BBCC, 0x80AA_BBCC, 0x0011_2233, 0xFF44_5566]);
}
}
+4
View File
@@ -23,5 +23,9 @@ cpal = "0.16"
symphonia = { version = "0.5", default-features = false, features = ["ogg", "vorbis"] }
miniz_oxide = "0.8"
[features]
# Records the call sites of mob random draws (`mob_ai_probe` traces).
rng-trace = ["horizon-core/rng-trace"]
[lints]
workspace = true
@@ -0,0 +1,185 @@
//! Replays `tools/test_ambient_sound_parity.py` scripts through Horizon's
//! ambient sound handlers over its `SoundEngine`:
//! `ambient_fixture ROOT INPUT OUTPUT`.
#[path = "audio_common/mod.rs"]
mod common;
use common::{Fake, d, f, flush};
use horizon_client::ambience::{Ambience, AmbientContext, AmbientSounds, Mood, bubble_column_in};
use horizon_client::sound_engine::{Bound, SoundEngine, TickContext, Ticking};
use horizon_client::sound_events::{SoundRegistry, normalize};
use horizon_core::random::RandomSource;
use std::io::Write;
/// The oracle's scripted brightness.
fn light(mode: i32, x: i32, y: i32, z: i32) -> (u8, u8) {
let h = x.wrapping_mul(73856093) ^ y.wrapping_mul(19349663) ^ z.wrapping_mul(83492791);
let u = h as u32;
match mode {
0 => (15, 0),
1 => (0, 0),
2 => (0, ((u >> 4) & 15) as u8),
_ => (
if h & 3 == 0 { ((u >> 8) & 15) as u8 } else { 0 },
((u >> 12) & 15) as u8,
),
}
}
fn sounds(a: &[&str]) -> AmbientSounds {
AmbientSounds {
r#loop: (a[1] != "-").then(|| normalize(a[1])),
mood: (a[2] != "-").then(|| {
let m: Vec<&str> = a[2].split(',').collect();
Mood {
sound: normalize(m[0]),
tick_delay: m[1].parse().expect("delay"),
block_search_extent: m[2].parse().expect("extent"),
offset: d(m[3]),
}
}),
additions: a[3..]
.iter()
.map(|s| {
let p: Vec<&str> = s.split(',').collect();
(normalize(p[0]), d(p[1]))
})
.collect(),
}
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
engine.listening = true;
let mut backend = Fake::default();
let mut ambience = Ambience::new(0);
let mut current = AmbientSounds::default();
let (mut position, mut eye, mut eye_in_water, mut spectator, mut removed, mut mode) =
([0.0f64; 3], 0.0f32, false, false, false, 0);
let mut height = 1.8f32;
let mut columns: Vec<([i32; 3], bool)> = Vec::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
writeln!(out, "{line}").expect("write");
let a: Vec<&str> = line.split(' ').collect();
match a[0] {
"start" => {
engine.stop_all(&mut backend);
ambience.clear(&mut Bound {
engine: &mut engine,
backend: &mut backend,
});
ambience = Ambience::new(a[1].parse().expect("seed"));
current = AmbientSounds::default();
(position, eye, eye_in_water, spectator, removed, mode) =
([0.0; 3], 0.0, false, false, false, 0);
height = 1.8;
columns.clear();
}
"sounds" => current = sounds(&a),
"player" => {
position = [d(a[1]), d(a[2]), d(a[3])];
eye = f(a[4]);
eye_in_water = a[5] == "1";
spectator = a[6] == "1";
removed = a[7] == "1";
mode = a[8].parse().expect("mode");
height = f(a[9]);
columns = a[10..]
.iter()
.map(|c| {
let p: Vec<&str> = c.split(',').collect();
let n = |i: usize| p[i].parse::<i32>().expect("column");
([n(0), n(1), n(2)], p[3] == "1")
})
.collect();
}
"volume" => {
let source: usize = a[1].parse().expect("source");
engine.source_volumes[source] = f(a[2]);
engine.refresh_category_volume(source, &mut backend);
}
"tick" => {
let light = |b: [i32; 3]| light(mode, b[0], b[1], b[2]);
let ctx = AmbientContext {
sounds: &current,
position,
eye_y: position[1] + f64::from(eye),
underwater: eye_in_water,
spectator,
light: &light,
bubble_column: bubble_column_in(
[
position[0] - f64::from(0.3f32),
position[1],
position[2] - f64::from(0.3f32),
position[0] + f64::from(0.3f32),
position[1] + f64::from(height),
position[2] + f64::from(0.3f32),
],
|b| columns.iter().find(|c| c.0 == b).map(|c| c.1),
),
};
ambience.tick(
&ctx,
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
let tick = TickContext {
player_underwater: ambience.was_underwater(),
player_removed: removed,
};
engine.tick(false, &tick, &mut backend);
}
_ => panic!("{line}"),
}
writeln!(
out,
" rand={} mood={:08x} underwater={} delay={}",
ambience.random.clone().next_long(),
ambience.moodiness.to_bits(),
ambience.was_underwater(),
ambience.underwater_delay()
)
.expect("write");
for (event, id) in ambience.loops() {
let i = engine.instance(*id).expect("loop");
let Ticking::BiomeLoop { fade, direction } = i.ticking else {
panic!("loop kind");
};
writeln!(
out,
" loop {event} fade={fade} direction={direction} stopped={} active={}",
i.stopped,
engine.is_active(*id)
)
.expect("write");
}
let mut playing: Vec<String> = engine
.active_instances()
.into_iter()
.map(|id| {
let i = engine.instance(id).expect("instance");
format!(
" playing {} volume={:08x} looping={} relative={} at={:016x},{:016x},{:016x} stopped={}",
i.identifier,
i.volume.to_bits(),
i.looping,
i.relative,
i.position[0].to_bits(),
i.position[1].to_bits(),
i.position[2].to_bits(),
i.tickable() && i.stopped
)
})
.collect();
playing.sort();
for p in playing {
writeln!(out, "{p}").expect("write");
}
flush(&mut engine, &mut out);
}
}
@@ -0,0 +1,85 @@
//! Audio environment attributes from Horizon's `MusicTables` and
//! `AmbientTables` for `tools/test_audio_attributes_parity.py`:
//! `audio_attributes_fixture ROOT INPUT OUTPUT`.
use horizon_client::ambience::AmbientTables;
use horizon_client::music::{Music, MusicTables};
use horizon_client::sound_events::normalize;
use std::io::Write;
fn music(m: Option<&Music>) -> String {
m.map_or("-".to_owned(), |m| {
format!(
"{},{},{},{}",
normalize(&m.sound),
m.min_delay,
m.max_delay,
m.replace_current_music
)
})
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let root = std::path::Path::new(&args[1]);
let tables = MusicTables::load(root);
let ambient = AmbientTables::load(root);
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for (name, m) in [
("MENU", Music::menu()),
("CREDITS", Music::credits()),
("END_BOSS", Music::end_boss()),
("CREATIVE", Music::creative()),
("END", Music::end()),
("UNDER_WATER", Music::under_water()),
("GAME", Music::game()),
] {
writeln!(out, "{name} {}", music(Some(&m))).expect("write");
}
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let (dimension, biome) = (f[0], f[1]);
let bg = tables.background(dimension, biome);
let mut s = format!(
"{line} default={} creative={} underwater={}",
music(bg.default.as_ref()),
music(bg.creative.as_ref()),
music(bg.underwater.as_ref())
);
for creative in [false, true] {
for underwater in [false, true] {
s.push_str(&format!(
" select={}",
music(bg.select(creative, underwater))
));
}
}
s.push_str(&format!(
" volume={:08x}",
tables.volume(dimension, biome).to_bits()
));
let a = ambient.get(dimension, biome);
s.push_str(&format!(
" loop={}",
a.r#loop.as_deref().map_or("-".to_owned(), normalize)
));
s.push_str(&format!(
" mood={}",
a.mood.as_ref().map_or("-".to_owned(), |m| format!(
"{},{},{},{:016x}",
normalize(&m.sound),
m.tick_delay,
m.block_search_extent,
m.offset.to_bits()
))
));
for (sound, chance) in &a.additions {
s.push_str(&format!(
" addition={},{:016x}",
normalize(sound),
chance.to_bits()
));
}
writeln!(out, "{s}").expect("write");
}
}
@@ -0,0 +1,176 @@
//! Shared by the audio fixtures: a backend recording what an OpenAL source
//! would hold, and the per-step dump the Java oracles print.
#![allow(dead_code)]
use horizon_client::sound_engine::{Backend, ChannelId, InstanceId, PlayResult, SoundEngine};
use horizon_client::sound_events::Sound;
use std::collections::HashMap;
use std::io::Write;
const INITIAL: u32 = 0x1011;
const PLAYING: u32 = 0x1012;
const PAUSED: u32 = 0x1013;
const STOPPED: u32 = 0x1014;
/// An OpenAL source's state as `alGetSource*` reports it.
#[derive(Clone)]
pub struct Source {
pub state: u32,
pub gain: f32,
pub pitch: f32,
pub position: [f32; 3],
pub relative: bool,
pub looping: bool,
pub model: u32,
pub max: f32,
pub rolloff: f32,
pub reference: f32,
}
impl Default for Source {
fn default() -> Self {
Self {
state: INITIAL,
gain: 1.0,
pitch: 1.0,
position: [0.0; 3],
relative: false,
looping: false,
model: 0xD002,
max: f32::MAX,
rolloff: 1.0,
reference: 1.0,
}
}
}
/// Buffers and streams are ready at once (silence in the oracle); stopping an
/// initial source leaves it initial, as in OpenAL Soft.
#[derive(Default)]
pub struct Fake {
pub sources: HashMap<ChannelId, Source>,
next: ChannelId,
}
impl Backend for Fake {
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
self.next += 1;
self.sources.insert(self.next, Source::default());
Some(self.next)
}
fn destroy(&mut self, channel: ChannelId) {
self.sources.remove(&channel);
}
fn set_pitch(&mut self, c: ChannelId, pitch: f32) {
self.sources.get_mut(&c).expect("source").pitch = pitch;
}
fn set_volume(&mut self, c: ChannelId, volume: f32) {
self.sources.get_mut(&c).expect("source").gain = volume;
}
fn set_attenuation(&mut self, c: ChannelId, linear: Option<f32>) {
let s = self.sources.get_mut(&c).expect("source");
match linear {
Some(max) => {
s.model = 0xD003;
s.max = max;
s.rolloff = 1.0;
s.reference = 0.0;
}
None => s.model = 0,
}
}
fn set_looping(&mut self, c: ChannelId, looping: bool) {
self.sources.get_mut(&c).expect("source").looping = looping;
}
fn set_position(&mut self, c: ChannelId, p: [f64; 3]) {
self.sources.get_mut(&c).expect("source").position =
[p[0] as f32, p[1] as f32, p[2] as f32];
}
fn set_relative(&mut self, c: ChannelId, relative: bool) {
self.sources.get_mut(&c).expect("source").relative = relative;
}
fn start(&mut self, c: ChannelId, _sound: &Sound, _looping: bool) {
self.sources.get_mut(&c).expect("source").state = PLAYING;
}
fn stop(&mut self, c: ChannelId) {
if let Some(s) = self.sources.get_mut(&c)
&& s.state != INITIAL
{
s.state = STOPPED;
}
}
fn pause(&mut self, c: ChannelId) {
let s = self.sources.get_mut(&c).expect("source");
if s.state == PLAYING {
s.state = PAUSED;
}
}
fn unpause(&mut self, c: ChannelId) {
let s = self.sources.get_mut(&c).expect("source");
if s.state == PAUSED {
s.state = PLAYING;
}
}
fn stopped(&mut self, c: ChannelId) -> bool {
self.sources.get(&c).is_none_or(|s| s.state == STOPPED)
}
}
pub fn bits(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
pub fn f(hex: &str) -> f32 {
f32::from_bits(u32::from_str_radix(hex, 16).expect("float"))
}
pub fn d(hex: &str) -> f64 {
f64::from_bits(u64::from_str_radix(hex, 16).expect("double"))
}
/// The shared per-step dump (instances, notifications, pool counts).
pub fn describe(engine: &SoundEngine, backend: &Fake, name: &str, id: InstanceId) -> String {
let mut s = format!(" {name} active={}", engine.is_active(id));
match engine.channel(id).and_then(|c| backend.sources.get(&c)) {
None => s.push_str(" channel=-"),
Some(src) => s.push_str(&format!(
" state={:x} gain={} pitch={} pos={},{},{} rel={} loop={} model={:x} max={} rolloff={} ref={}",
src.state,
bits(src.gain),
bits(src.pitch),
bits(src.position[0]),
bits(src.position[1]),
bits(src.position[2]),
u8::from(src.relative),
u8::from(src.looping),
src.model,
bits(src.max),
bits(src.rolloff),
bits(src.reference)
)),
}
s
}
pub fn flush(engine: &mut SoundEngine, out: &mut impl Write) {
for n in std::mem::take(&mut engine.notifications) {
writeln!(
out,
"notify {} {} {} {} {:016x} {:016x} {:016x}",
n.identifier,
n.subtitle.as_deref().unwrap_or("-"),
bits(n.range),
n.looping,
n.position[0].to_bits(),
n.position[1].to_bits(),
n.position[2].to_bits()
)
.expect("write");
}
let [used, streaming] = engine.used_channels();
writeln!(
out,
" Sounds: {used}/{} + {streaming}/{}",
engine.limits[0], engine.limits[1]
)
.expect("write");
}
pub fn result_name(r: PlayResult) -> &'static str {
match r {
PlayResult::Started => "STARTED",
PlayResult::StartedSilently => "STARTED_SILENTLY",
PlayResult::NotStarted => "NOT_STARTED",
}
}
@@ -0,0 +1,117 @@
#![forbid(unsafe_code)]
//! Runs tools/test_auto_jump_parity.py's cases through the local player's
//! `MotionWorld::update_auto_jump` over the client's resident test scene
//! (actual resources, collision registry and block-collision scan).
use horizon_client::{
assets::Assets,
collisions,
motion::{MotionResources, MotionWorld, PlayerMotion},
world::World,
};
use horizon_core::geometry::Vec3;
use serde_json::Value;
use std::{
fs,
io::{BufRead, BufReader, BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn db(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn main() -> Result<()> {
let args: Vec<_> = std::env::args().skip(1).collect();
let [root, input, output, scene_json] = &args[..] else {
return Err("usage: auto_jump_probe RESOURCES INPUT OUTPUT SCENE_JSON".into());
};
let root = PathBuf::from(root);
let assets = Assets::load(&root)?;
let mut scene = World::test_scene(&assets, 1)?;
let binding = collisions::load(&root, assets.registry)?;
let resources = MotionResources::load(&root, &assets)?;
fs::write(
scene_json,
serde_json::to_vec(&serde_json::json!({
"radius": 1, "resident_chunks": scene.chunks.len(), "model_gallery": assets.model_gallery,
}))?,
)?;
let mut out = BufWriter::new(fs::File::create(output)?);
for (n, line) in BufReader::new(fs::File::open(input)?).lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut restore = Vec::new();
for b in c["blocks"].as_array().unwrap() {
let (x, y, z) = (
b[0].as_i64().unwrap() as i32,
b[1].as_i64().unwrap() as i32,
b[2].as_i64().unwrap() as i32,
);
let mut state = assets
.registry
.default_state(b[3].as_str().unwrap())
.ok_or("missing fixture block")?;
if let Some(properties) = b[4].as_object()
&& !properties.is_empty()
{
let mut definition = assets.registry.state(state).unwrap().clone();
for (name, value) in properties {
definition
.properties
.insert(name.clone(), value.as_str().unwrap().into());
}
state = assets
.registry
.id(&definition)
.ok_or("missing fixture state")?;
}
restore.push((x, y, z, scene.block(x, y, z)));
scene.set(x, y, z, state)?;
}
let p = &c["player"];
let position = Vec3::new(db(&p["pos"][0]), db(&p["pos"][1]), db(&p["pos"][2]));
let mut player = PlayerMotion::new(&resources, position)?;
player.ground.on_ground = p["ground"].as_bool().unwrap();
player.shift_key_down = p["shift"].as_bool().unwrap();
player.yaw = fl(&p["yr"]);
player.pitch = fl(&p["xr"]);
let key = player
.attributes
.key("minecraft:movement_speed")
.ok_or("speed")?;
let handle = player
.attributes
.get_instance(Some(key))
.map_err(|e| format!("{e:?}"))?
.ok_or("speed instance")?;
let speed = f64::from(fl(&p["speed"]));
player
.attributes
.edit(&handle, |a, o| a.set_base_value(speed, o))
.map_err(|e| format!("{e:?}"))?;
player.move_vector = [fl(&p["move"][0]), fl(&p["move"][1])];
player.dimensions.width = fl(&p["width"]);
player.dimensions.height = fl(&p["height"]);
player.status.jump_boost = p["boost"].as_i64().map(|a| a as i32);
player.auto_jump_enabled = p["enabled"].as_bool().unwrap();
player.auto_jump_time = p["time"].as_i64().unwrap() as i32;
MotionWorld {
player: &mut player,
scene: &mut scene,
assets: &assets,
collision_binding: &binding,
resources: &resources,
}
.update_auto_jump(fl(&c["xa"]), fl(&c["za"]))?;
writeln!(out, "jump {}", player.auto_jump_time)?;
for (x, y, z, state) in restore.into_iter().rev() {
scene.set(x, y, z, state)?;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,455 @@
//! Headless transport for the block-entity animation parity gate: replays each
//! scripted case (block, neighbourhood, entities, block events, game times)
//! through `horizon_client::block_entity_anim` and prints the same lines as
//! `VanillaBlockEntityAnimOracle`: the block entity's fields, the level calls
//! its tick made and the render state the renderer derives from `BlockAnim`.
#![forbid(unsafe_code)]
use horizon_client::{
assets::Assets,
block_entity_anim::{
Bell, BellEntity, BlockEntityAnims, ChestLid, Conduit, ConduitTarget, DIRECTIONS,
PotWobble, Shulker, ShulkerStatus, Skull, WobbleStyle, chest_lid_x_rot, direction_index,
},
block_entity_render::BlockEntityRenderer,
blocks::{BlockFacts, Fluid},
items::Items,
};
use horizon_core::random::LegacyRandom;
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::Value;
use std::{
collections::HashMap,
fmt::Write as _,
fs,
io::{BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
fn f(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn d(v: f64) -> String {
format!("{:016x}", v.to_bits())
}
fn bx(b: [f64; 6]) -> String {
b.map(d).join(" ")
}
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
let wanted: Vec<(&str, &str)> = props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.collect();
let default = registry.state(registry.default_state(name)?)?.clone();
(0..registry.len() as u32).find(|&id| {
registry.state(BlockStateId(id)).is_some_and(|s| {
s.name == name
&& s.properties.iter().all(|(k, v)| {
wanted
.iter()
.find(|(wk, _)| wk == k)
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
})
})
})
}
fn prop<'a>(spec: &'a str, key: &str) -> Option<&'a str> {
let props = spec.split_once('[')?.1.trim_end_matches(']');
props
.split(',')
.filter_map(|p| p.split_once('='))
.find(|(k, _)| *k == key)
.map(|(_, v)| v)
}
fn offset(key: &str) -> [i32; 3] {
let mut it = key.split(',').map(|v| v.parse::<i32>().unwrap_or(0));
[
it.next().unwrap_or(0),
it.next().unwrap_or(0),
it.next().unwrap_or(0),
]
}
fn mob(e: &Value) -> BellEntity {
let p = |i: usize| e["pos"][i].as_f64().unwrap_or(0.0);
BellEntity {
position: [p(0), p(1), p(2)],
alive: e["alive"].as_bool().unwrap_or(true) && !e["removed"].as_bool().unwrap_or(false),
raider: e["raider"].as_bool().unwrap_or(false),
}
}
fn lid_text(l: Option<&ChestLid>) -> String {
l.map_or("-".to_owned(), |l| {
format!("{} {} {}", l.should_be_open, f(l.openness), f(l.o_openness))
})
}
/// The case's players: feet positions and whether each is a spectator.
fn players(v: &Value) -> Vec<([f64; 3], bool)> {
v.as_array()
.into_iter()
.flatten()
.map(|p| {
let c = |i: usize| p["pos"][i].as_f64().unwrap_or(0.0);
(
[c(0), c(1), c(2)],
p["spectator"].as_bool().unwrap_or(false),
)
})
.collect()
}
fn status(s: ShulkerStatus) -> &'static str {
match s {
ShulkerStatus::Closed => "CLOSED",
ShulkerStatus::Opening => "OPENING",
ShulkerStatus::Opened => "OPENED",
ShulkerStatus::Closing => "CLOSING",
}
}
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, output] = args.as_slice() else {
return Err("usage: block_entity_anim_fixture RESOURCES INPUTS OUTPUT".into());
};
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let renderer = BlockEntityRenderer::load(root, registry)?;
let tags = Items::tags_only(root)?;
let frames = tags
.block_tags
.get("minecraft:conduit_effect_block")
.cloned()
.unwrap_or_default();
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let case: Value = serde_json::from_str(line)?;
writeln!(out, "case {}", case["name"].as_str().unwrap_or_default())?;
let block = case["block"].as_str().unwrap_or_default().to_owned();
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
let pos = [
int(&case["pos"][0]),
int(&case["pos"][1]),
int(&case["pos"][2]),
];
// The neighbourhood as block states (conduits).
let mut blocks: HashMap<[i32; 3], String> = HashMap::new();
blocks.insert(pos, block.clone());
let place = |blocks: &mut HashMap<[i32; 3], String>, set: &Value| {
for (k, v) in set.as_object().into_iter().flatten() {
let o = offset(k);
blocks.insert(
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]],
v.as_str().unwrap_or_default().to_owned(),
);
}
};
place(&mut blocks, &case["world"]);
let entities: Vec<BellEntity> = case["entities"]
.as_array()
.into_iter()
.flatten()
.map(mob)
.collect();
let target = case.get("target").map(|t| {
let e = mob(t);
ConduitTarget {
position: e.position,
eye_y: e.position[1] + f64::from(t["eye"].as_f64().unwrap_or(0.0) as f32),
width: t["width"].as_f64().unwrap_or(0.0) as f32,
height: t["height"].as_f64().unwrap_or(0.0) as f32,
}
});
let mut random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
let name = block.split('[').next().unwrap_or_default().to_owned();
let mut anims = BlockEntityAnims::default();
let partner = case["partner"].as_str().map(|k| {
let o = offset(k);
[pos[0] + o[0], pos[1] + o[1], pos[2] + o[2]]
});
let mut gamers = players(&case["players"]);
if name == "minecraft:enchanting_table" {
anims.book_random = LegacyRandom::new(case["seed"].as_i64().unwrap_or(0));
}
let facing = prop(&block, "facing")
.and_then(direction_index)
.unwrap_or(1);
let mut text = String::new();
for step in case["steps"].as_array().into_iter().flatten() {
let time = step["time"].as_i64().unwrap_or(0);
let partial = step["partial"].as_f64().unwrap_or(0.0) as f32;
let mut log = String::new();
place(&mut blocks, &step["set"]);
let event = step["event"].as_array().map(|e| (int(&e[0]), int(&e[1])));
text.clear();
if step.get("players").is_some() {
gamers = players(&step["players"]);
}
if name.ends_with("chest") {
let lid = anims.chests.entry(pos).or_default();
if let Some((a, b)) = event {
let handled = lid.trigger_event(a, b);
let _ = write!(log, " event {handled}");
}
if let Some(other) = partner {
let lid = anims.chests.entry(other).or_default();
if let Some(e) = step["partnerEvent"].as_array() {
let handled = lid.trigger_event(int(&e[0]), int(&e[1]));
let _ = write!(log, " partner event {handled}");
}
}
// lidAnimateTick: this chest, then the other half.
if let Some(l) = anims.chests.get_mut(&pos) {
l.tick();
}
if let Some(l) = partner.and_then(|p| anims.chests.get_mut(&p)) {
l.tick();
}
let state_at = |p: [i32; 3]| {
find_state(
registry,
blocks.get(&p).map_or("minecraft:air", String::as_str),
)
.unwrap_or(0)
};
let (open, _) = renderer
.chest_openness_of(pos, state_at(pos), &state_at, &|p| anims.lid(p, partial));
let _ = write!(
text,
"{} partner {} render {} {}",
lid_text(anims.chests.get(&pos)),
partner.map_or("none".to_owned(), |p| lid_text(anims.chests.get(&p))),
f(open),
f(chest_lid_x_rot(open))
);
} else if name == "minecraft:enchanting_table" {
let feet: Vec<[f64; 3]> = gamers
.iter()
.filter(|(_, spectator)| !spectator)
.map(|(p, _)| *p)
.collect();
let BlockEntityAnims {
books, book_random, ..
} = &mut anims;
books.entry(pos).or_default().tick(pos, &feet, book_random);
let b = anims.books[&pos];
let r = anims
.block_anim(pos, time, partial, Default::default())
.book
.render(partial);
let _ = write!(
text,
"{} {} {} {} {} {} {} {} {} {} render {} {} {} {} book {} {} {}",
b.time,
f(b.flip),
f(b.o_flip),
f(b.flip_t),
f(b.flip_a),
f(b.open),
f(b.o_open),
f(b.rot),
f(b.o_rot),
f(b.t_rot),
f(r.flip),
f(r.open),
f(r.time),
f(r.y_rot),
f(r.openness),
f(r.page_flip1),
f(r.page_flip2)
);
} else if name.ends_with("shulker_box") {
let s = anims.shulkers.entry(pos).or_insert_with(Shulker::default);
if let Some((a, b)) = event {
let handled = s.trigger_event(a, b);
let _ = write!(log, " event {handled}");
}
let tick = s.tick(pos, facing);
for _ in 0..tick.neighbor_updates {
log.push_str(" neighbors");
}
if let Some(b) = tick.push {
let _ = write!(log, " push {}", bx(b));
}
let (count, st) = (s.open_count, s.status);
let a = anims.block_anim(pos, time, partial, Default::default());
// ShulkerBoxRenderer: getProgress(partial).
let (progress, old) = a.shulker;
let render = old + partial * (progress - old);
let s = &anims.shulkers[&pos];
let _ = write!(
text,
"{} {} {} {} render {}",
status(st),
count,
f(s.progress),
f(s.progress_old),
f(render)
);
} else if name == "minecraft:bell" {
let b = anims.bells.entry(pos).or_insert_with(Bell::default);
if let Some((e0, e1)) = event {
let handled = b.trigger_event(e0, e1, time, || {
let inflated = [
f64::from(pos[0]) - 48.0,
f64::from(pos[1]) - 48.0,
f64::from(pos[2]) - 48.0,
f64::from(pos[0]) + 1.0 + 48.0,
f64::from(pos[1]) + 1.0 + 48.0,
f64::from(pos[2]) + 1.0 + 48.0,
];
let _ = write!(log, " gather {}", bx(inflated));
(0..entities.len() as u64)
.filter(|&i| {
let p = entities[i as usize].position;
(0..3).all(|a| p[a] >= inflated[a] && p[a] < inflated[a + 3])
})
.collect()
});
let _ = write!(log, " event {handled}");
}
if !step["noTick"].as_bool().unwrap_or(false) {
let tick = b.tick(pos, |h| entities.get(h as usize).copied());
if tick.resonate {
log.push_str(" sound minecraft:block.bell.resonate");
}
if let Some((particles, _)) = tick.ended {
for (p, color) in particles {
let _ = write!(
log,
" particle minecraft:entity_effect {color} {} {} {} {} {} {}",
d(p[0]),
d(p[1]),
d(p[2]),
d(0.0),
d(0.0),
d(0.0)
);
}
}
}
let b = &anims.bells[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
let dir = |d: Option<&str>| d.map_or("null".to_owned(), str::to_owned);
let _ = write!(
text,
"{} {} {} {} {} {} render {} {}",
b.ticks,
b.shaking,
dir(b.click_direction.map(|d| DIRECTIONS[usize::from(d)])),
b.resonating,
b.resonation_ticks,
b.last_ring_timestamp,
f(a.bell_ticks as f32 + partial),
dir(a.bell_shake)
);
} else if name == "minecraft:conduit" {
let c = anims.conduits.entry(pos).or_insert_with(Conduit::default);
let state_of = |p: [i32; 3]| -> Option<u32> {
find_state(
registry,
blocks.get(&p).map_or("minecraft:air", String::as_str),
)
};
let is_water = |p: [i32; 3]| {
state_of(p).is_some_and(|s| facts.get(BlockStateId(s)).fluid == Fluid::Water)
};
let is_frame = |p: [i32; 3]| {
let spec = blocks.get(&p).map_or("minecraft:air", String::as_str);
frames.contains(spec.split('[').next().unwrap_or_default())
};
let particles = c.client_tick(pos, time, is_water, is_frame, target, &mut random);
for (p, v) in particles {
let _ = write!(
log,
" particle minecraft:nautilus {} {} {} {} {} {}",
d(p[0]),
d(p[1]),
d(p[2]),
d(v[0]),
d(v[1]),
d(v[2])
);
}
let c = &anims.conduits[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
// ConduitRenderer.extractRenderState as block_entity_render derives it.
let (ticks, active, hunting, raw) = a.conduit;
let rotation = (raw + if active { partial } else { 0.0 }) * -0.0375;
let _ = write!(
text,
"{} {} {} {} {} render {} {} {} {} {}",
c.tick_count,
c.is_active,
c.is_hunting,
f(c.active_rotation),
c.effect_blocks.len(),
active,
f(rotation),
f(ticks as f32 + partial),
ticks / 66 % 3,
hunting
);
} else if name.ends_with("_head") || name.ends_with("_skull") {
let now = step["state"].as_str().unwrap_or(&block);
let s = anims.skulls.entry(pos).or_insert_with(Skull::default);
s.tick(prop(now, "powered") == Some("true"));
let s = anims.skulls[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
// block_entity_render: count + partial while animating.
let (animating, count) = a.skull;
let render = count as f32 + if animating { partial } else { 0.0 };
let _ = write!(
text,
"{} {} render {}",
s.animating,
s.tick_count,
f(render)
);
} else if name == "minecraft:decorated_pot" {
let w = anims.pots.entry(pos).or_insert_with(PotWobble::default);
if let Some((e0, e1)) = event {
let handled = w.trigger_event(e0, e1, time);
let _ = write!(log, " event {handled}");
}
let w = anims.pots[&pos];
let a = anims.block_anim(pos, time, partial, Default::default());
let (style, progress) = match a.wobble {
Some((true, p)) => ("POSITIVE", p),
Some((false, p)) => ("null", p),
None => ("null", 0.0),
};
let _ = write!(
text,
"{} {} render {} {}",
w.started_at,
match w.style {
Some(WobbleStyle::Positive) => "POSITIVE",
Some(WobbleStyle::Negative) => "NEGATIVE",
None => "null",
},
f(progress),
style
);
} else {
text = format!("unsupported {block}");
}
writeln!(out, "{text} |{log}")?;
}
}
Ok(())
}
@@ -0,0 +1,391 @@
//! Headless transport for the block-entity render parity gate: replays each
//! case (block state, light, partial tick and ticked block-entity fields)
//! through `BlockEntityRenderer` at the block origin and writes the quads
//! (texture, sprite-local UVs, baked colour, light) and sign text calls.
#![forbid(unsafe_code)]
use glam::{DVec3, Mat4, Quat, Vec3};
use horizon_client::{
assets::Assets,
banner::{self, Banners},
block_entity_anim::EnchantingBook,
block_entity_render::{
BlockAnim, BlockEntityRenderer, END_GATEWAY_LAYER, END_PORTAL_LAYER, Scene, TextCall,
},
chunk::{Chunk, Light},
hud::{HudAtlas, Rect},
signs::{DYE_TEXT, SignData, SignText},
world::World,
};
use horizon_world::registry::{BlockRegistry, BlockStateId};
use serde_json::{Value, json};
use std::{
cell::RefCell,
collections::HashMap,
fs,
io::{BufWriter, Write},
path::{Path, PathBuf},
sync::Arc,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
/// Every texture the vanilla oracle used gets its own unit square on a small
/// grid (offsets stay small so f32 UVs keep their precision), so a vertex's
/// texture and sprite-local UV can be recovered.
fn fake_sprites(list: &Path) -> Result<(HashMap<String, Rect>, Vec<String>, usize)> {
let names: Vec<String> = fs::read_to_string(list)?
.lines()
.filter(|l| !l.is_empty())
.map(str::to_owned)
.collect();
let grid = (names.len() as f64).sqrt().ceil().max(1.0) as usize;
let map = names
.iter()
.enumerate()
.map(|(i, n)| {
let (u0, v0) = ((i % grid) as f32 * 2.0, (i / grid) as f32 * 2.0);
(
n.clone(),
Rect {
u0,
v0,
u1: u0 + 1.0,
v1: v0 + 1.0,
w: 16,
h: 16,
},
)
})
.collect();
Ok((map, names, grid))
}
fn find_state(registry: &BlockRegistry, spec: &str) -> Option<u32> {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
let wanted: Vec<(&str, &str)> = props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.collect();
let default = registry.state(registry.default_state(name)?)?.clone();
(0..registry.len() as u32).find(|&id| {
registry.state(BlockStateId(id)).is_some_and(|s| {
s.name == name
&& s.properties.iter().all(|(k, v)| {
wanted
.iter()
.find(|(wk, _)| wk == k)
.map_or(default.properties.get(k) == Some(v), |(_, wv)| wv == v)
})
})
})
}
fn sign_side(v: &Value) -> SignText {
let mut side = SignText::default();
for (line, text) in side
.lines
.iter_mut()
.zip(v["lines"].as_array().into_iter().flatten())
{
*line = text.as_str().unwrap_or_default().to_owned();
}
let color = v["color"].as_str().unwrap_or("black");
side.color = DYE_TEXT.iter().position(|(n, _)| *n == color).unwrap_or(15) as u8;
side.glowing = v["glowing"].as_bool().unwrap_or(false);
side
}
fn intern(s: &str) -> &'static str {
match s {
"north" => "north",
"south" => "south",
"west" => "west",
"east" => "east",
"up" => "up",
_ => "down",
}
}
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, textures, output] = args.as_slice() else {
return Err("usage: block_entity_render_fixture RESOURCES INPUTS TEXTURES OUTPUT".into());
};
let assets = Assets::load(root)?;
let registry = assets.registry;
let renderer = BlockEntityRenderer::load(root, registry)?;
let atlas = HudAtlas::build(root)?;
let banners = Banners::load(root);
let (sprites, names, grid) = fake_sprites(textures)?;
let air = registry.default_state("minecraft:air").ok_or("no air")?;
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let case: Value = serde_json::from_str(line)?;
let block = case["block"].as_str().unwrap_or_default();
if block == "minecraft:beacon" {
// BeaconRenderer: the beam sections from the case, as Horizon's beacon keeps them.
let int = |v: &Value| v.as_i64().unwrap_or(0);
let pos = [
int(&case["pos"][0]),
int(&case["pos"][1]),
int(&case["pos"][2]),
];
let partial = case["partial"].as_f64().unwrap_or(0.0) as f32;
let time = int(&case["gameTime"]).rem_euclid(40) as f32 + partial;
let cam = &case["camera"];
let dx = cam[0].as_f64().unwrap_or(0.0) - (pos[0] as f64 + 0.5);
let dz = cam[2].as_f64().unwrap_or(0.0) - (pos[2] as f64 + 0.5);
let scale = ((dx * dx + dz * dz).sqrt() as f32 / 96.0).max(1.0);
let sections: Vec<(u32, i32)> = case["beam"]
.as_array()
.into_iter()
.flatten()
.map(|s| (int(&s[0]) as u32 & 0xFF_FFFF, int(&s[1]) as i32))
.collect();
let texture = "entity/beacon/beacon_beam";
let rect = sprites.get(texture).copied().ok_or("no beam sprite")?;
let mut verts = Vec::new();
horizon_client::block_entity_render::beacon_beam(
Vec3::ZERO,
&sections,
time,
scale,
rect,
&mut verts,
);
let quads: Vec<Value> = verts
.chunks(4)
.map(|q| {
let v: Vec<Value> = q
.iter()
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - rect.u0, v.uv[1]]))
.collect();
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light, "custom": true})
})
.collect();
writeln!(out, "{}", json!({"quads": quads, "texts": []}))?;
continue;
}
let Some(state) = find_state(registry, block) else {
writeln!(out, "{}", json!({"skip": "unknown block state"}))?;
continue;
};
if !renderer.renders(state) {
writeln!(out, "{}", json!({"skip": "no renderer"}))?;
continue;
}
let int = |v: &Value| v.as_i64().unwrap_or(0) as i32;
let float = |v: &Value| v.as_f64().unwrap_or(0.0) as f32;
let pos = [
int(&case["pos"][0]),
int(&case["pos"][1]),
int(&case["pos"][2]),
];
let packed = (int(&case["light"][0]) << 4 | int(&case["light"][1])) as u8;
let mut world = World::new(air, 0);
for dx in -1..=1 {
for dz in -1..=1 {
let (cx, cz) = ((pos[0] >> 4) + dx, (pos[2] >> 4) + dz);
let mut chunk = Chunk::empty(cx, cz);
for section in &mut chunk.sections {
Arc::make_mut(section).light = Light::Uniform(packed);
}
world.chunks.insert((cx, cz), Arc::new(chunk));
}
}
// Neighbour blocks the case sets (End gateway faces).
for (direction, spec) in case["neighbours"].as_object().into_iter().flatten() {
let d = match direction.as_str() {
"down" => [0, -1, 0],
"up" => [0, 1, 0],
"north" => [0, 0, -1],
"south" => [0, 0, 1],
"west" => [-1, 0, 0],
_ => [1, 0, 0],
};
let neighbour = find_state(registry, spec.as_str().unwrap_or_default())
.ok_or("unknown neighbour state")?;
world.set(
pos[0] + d[0],
pos[1] + d[1],
pos[2] + d[2],
BlockStateId(neighbour),
)?;
}
let book = case
.get("book")
.map_or_else(EnchantingBook::default, |book| {
let [flip, o_flip, open, o_open, rot, o_rot] =
["flip", "oFlip", "open", "oOpen", "rot", "oRot"].map(|k| float(&book[k]));
EnchantingBook {
time: int(&book["time"]),
flip,
o_flip,
open,
o_open,
rot,
o_rot,
..EnchantingBook::default()
}
});
let sign = case.get("sign").map(|s| {
let mut data = SignData {
hanging: block.contains("hanging"),
..SignData::default()
};
data.front = sign_side(&s["front"]);
data.back = sign_side(&s["back"]);
data
});
let layers: Vec<(String, u32)> = case["patterns"]
.as_array()
.into_iter()
.flatten()
.map(|p| {
(
banners.banner_texture(p[0].as_str().unwrap_or_default()),
banner::dye_rgb(p[1].as_str().unwrap_or_default()),
)
})
.collect();
let anim = BlockAnim {
shulker: (float(&case["progress"]), float(&case["progressOld"])),
bell_ticks: int(&case["bellTicks"]),
bell_shake: case["shake"].as_str().map(intern),
conduit: (
int(&case["conduit"]["tickCount"]),
case["conduit"]["active"].as_bool().unwrap_or(false),
case["conduit"]["hunting"].as_bool().unwrap_or(false),
float(&case["conduit"]["activeRotation"]),
),
skull: (
case["skullAnimation"][0].as_bool().unwrap_or(false),
int(&case["skullAnimation"][1]),
),
sherds: [0, 1, 2, 3].map(|i| {
case["sherds"][i]
.as_str()
.filter(|s| !s.is_empty())
.map(str::to_owned)
}),
wobble: case["wobble"]
.as_array()
.map(|w| (w[0].as_str() == Some("POSITIVE"), float(&w[1]))),
book,
gateway: (
case["gateway"]["age"].as_i64().unwrap_or(0),
int(&case["gateway"]["cooldown"]),
),
};
// The case's level height (minimum y, height): the End's by default.
let max_y = match case["level"].as_array() {
Some(l) => int(&l[0]) + int(&l[1]) - 1,
None => 255,
};
let open = float(&case["open"]);
let near = case["sign"]["near"].as_bool().unwrap_or(false);
let camera = DVec3::new(
case["camera"][0].as_f64().unwrap_or(0.0),
case["camera"][1].as_f64().unwrap_or(0.0),
case["camera"][2].as_f64().unwrap_or(0.0),
);
// Signs pick the outline from the camera distance: put the camera where the case says.
let camera = if case.get("sign").is_some() {
let centre = DVec3::new(f64::from(pos[0]), f64::from(pos[1]), f64::from(pos[2])) + 0.5;
centre
+ if near {
DVec3::ZERO
} else {
DVec3::new(20.0, 0.0, 0.0)
}
} else {
camera
};
let (yaw, pitch) = (float(&case["yaw"]), float(&case["pitch"]));
let texts = RefCell::new(Vec::<TextCall>::new());
let scene = Scene {
camera,
orientation: Quat::from_euler(
glam::EulerRot::YXZ,
std::f32::consts::PI - yaw * (std::f32::consts::PI / 180.0),
-pitch * (std::f32::consts::PI / 180.0),
0.0,
),
anim: &|_| anim.clone(),
texts: Some(&texts),
partial: float(&case["partial"]),
game_time: case["gameTime"].as_i64().unwrap_or(0),
max_y,
world: &world,
sprites: &sprites,
nether: false,
lid: &|_| open,
sign: &|_| sign.as_ref(),
banner: &|_| layers.clone(),
glyph: &|c| atlas.glyph_cell(c),
};
let mut verts = Vec::new();
renderer.render_block(&scene, pos, state, Mat4::IDENTITY, packed, &mut verts);
let quads: Vec<Value> = verts
.chunks(4)
.map(|q| {
// Custom geometry: the End portal shader's position-only cube (its
// render type samples end_sky first) and End gateway beams.
if q[0].layer == END_PORTAL_LAYER || q[0].layer == END_GATEWAY_LAYER {
let v: Vec<Value> = q
.iter()
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0], v.uv[1]]))
.collect();
return json!({"texture": "environment/end_sky", "v": v, "color": q[0].color,
"light": q[0].light, "custom": true});
}
if q[0].layer == 6 {
let texture = "entity/end_portal/end_gateway_beam";
let u0 = sprites.get(texture).map_or(0.0, |r| r.u0);
let v: Vec<Value> = q
.iter()
.map(|v| json!([v.pos[0], v.pos[1], v.pos[2], v.uv[0] - u0, v.uv[1]]))
.collect();
return json!({"texture": texture, "v": v, "color": q[0].color,
"light": q[0].light, "custom": true});
}
let (cu, cv) = ((q[0].uv[0] / 2.0).floor(), (q[0].uv[1] / 2.0).floor());
let texture = names
.get(cu as usize + cv as usize * grid)
.cloned()
.unwrap_or_default();
let v: Vec<Value> = q
.iter()
.map(|v| {
json!([
v.pos[0],
v.pos[1],
v.pos[2],
v.uv[0] - cu * 2.0,
v.uv[1] - cv * 2.0
])
})
.collect();
json!({"texture": texture, "v": v, "color": q[0].color, "light": q[0].light})
})
.collect();
let texts: Vec<Value> = texts
.borrow()
.iter()
.filter(|t| !t.text.is_empty())
.map(|t| {
json!({"matrix": t.matrix.to_cols_array(), "x": t.x, "y": t.y, "text": t.text,
"color": t.color, "outline": t.outline,
"light": ((t.light & 0xFF) << 16 | (t.light >> 8 & 0xFF))})
})
.collect();
writeln!(out, "{}", json!({"quads": quads, "texts": texts}))?;
}
Ok(())
}
@@ -0,0 +1,342 @@
#![forbid(unsafe_code)]
//! Runs the JSON-lines scenarios of tools/test_camera_parity.py through the
//! Rust camera, field-of-view, avatar bob and view/hurt bobbing code.
use horizon_client::{
Result,
camera::{
AvatarBob, BobState, Camera, CameraEntity, CameraFluid, CameraType, FovPlayer, HurtBob,
LivingCamera, UseItem, bob_matrix, field_of_view_modifier,
},
};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world};
use serde_json::Value;
use std::{
collections::HashMap,
convert::Infallible,
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn fl(v: &Value) -> f32 {
f32::from_bits(u32::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn db(v: &Value) -> f64 {
f64::from_bits(u64::from_str_radix(v.as_str().unwrap(), 16).unwrap())
}
fn fb(v: f32) -> String {
format!("{:08x}", if v.is_nan() { 0x7fc0_0000 } else { v.to_bits() })
}
fn dbs(v: f64) -> String {
format!(
"{:016x}",
if v.is_nan() {
0x7ff8_0000_0000_0000
} else {
v.to_bits()
}
)
}
fn b(v: &Value) -> bool {
v.as_bool().unwrap()
}
fn vec3(v: &Value) -> [f64; 3] {
[db(&v[0]), db(&v[1]), db(&v[2])]
}
fn fov_player(c: &Value) -> FovPlayer {
FovPlayer {
flying: b(&c["flying"]),
walking_speed: fl(&c["ws"]),
movement_speed: db(&c["speed"]),
using: c["use"].as_array().map(|u| {
let item = match u[0].as_str().unwrap() {
"bow" => UseItem::Bow,
"spyglass" => UseItem::Spyglass,
_ => UseItem::Other,
};
(item, u[1].as_i64().unwrap() as i32)
}),
}
}
/// A level of visual shapes keyed by cell, without fluids.
struct Cells {
shapes: Vec<VoxelShape>,
index: HashMap<BlockPos, usize>,
}
impl RayWorld for Cells {
type Error = Infallible;
type Block = usize;
type Fluid = ();
fn block_state(&mut self, p: BlockPos) -> std::result::Result<usize, Infallible> {
Ok(self.index.get(&p).copied().unwrap_or(0))
}
fn fluid_state(&mut self, _: BlockPos) -> std::result::Result<(), Infallible> {
Ok(())
}
fn block_shape(
&mut self,
s: usize,
_: BlockPos,
_: RayBlockMode,
) -> std::result::Result<VoxelShape, Infallible> {
Ok(self.shapes[s].clone())
}
fn interaction_shape(
&mut self,
_: usize,
_: BlockPos,
) -> std::result::Result<VoxelShape, Infallible> {
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(false)
}
fn fluid_is_empty(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(true)
}
fn fluid_is_water(&mut self, _: ()) -> std::result::Result<bool, Infallible> {
Ok(false)
}
fn fluid_shape(&mut self, _: (), _: BlockPos) -> std::result::Result<VoxelShape, Infallible> {
Ok(VoxelShape::empty())
}
}
fn cells(c: &Value) -> Result<Cells> {
let mut world = Cells {
shapes: vec![VoxelShape::empty()],
index: HashMap::new(),
};
for (key, boxes) in c["cells"].as_object().unwrap() {
let at: Vec<i32> = key.split(' ').map(|v| v.parse().unwrap()).collect();
let mut shape = VoxelShape::empty();
for a in boxes.as_array().unwrap() {
shape = shape.join(
&VoxelShape::from_box(Aabb::new(
Vec3::new(db(&a[0]), db(&a[1]), db(&a[2])),
Vec3::new(db(&a[3]), db(&a[4]), db(&a[5])),
))?,
ShapeOp::OR,
)?;
}
world
.index
.insert(BlockPos::new(at[0], at[1], at[2]), world.shapes.len());
world.shapes.push(shape);
}
Ok(world)
}
fn camera_type(v: &Value) -> CameraType {
[
CameraType::FirstPerson,
CameraType::ThirdPersonBack,
CameraType::ThirdPersonFront,
][v.as_u64().unwrap() as usize]
}
/// The camera entity and, for players, what the field of view reads.
fn entity(s: &Value) -> (CameraEntity, Option<FovPlayer>) {
let player = s["kind"] == "player";
let living = player.then(|| LivingCamera {
scale: db(&s["scale_attr"]) as f32,
camera_distance: db(&s["dist"]),
sleeping: s["sleeping"].as_array().map(|a| {
a[0].as_u64()
.map(|d| [0.0f32, 90.0, 180.0, 270.0][d as usize])
}),
dead: s["dead"].as_i64().map(|t| t as i32),
});
let mount = if player {
s["mount"].as_array().map(|a| (db(&a[0]) as f32, db(&a[1])))
} else {
None
};
(
CameraEntity {
position: vec3(&s["pos"]),
previous: vec3(&s["prev"]),
x_rot: fl(&s["xr"]),
y_rot: fl(&s["yr"]),
x_rot_o: fl(&s["xro"]),
y_rot_o: fl(&s["yro"]),
eye_height: fl(&s["eye"]),
living,
mount,
},
player.then(|| fov_player(s)),
)
}
fn camera(c: &Value, out: &mut impl Write) -> Result<()> {
let mut world = cells(c)?;
let fov_option = c["fov"].as_i64().unwrap() as i32;
let effect = db(&c["effect"]) as f32;
let mut kind = camera_type(&c["type"]);
let mut camera = Camera::default();
let mut current: Option<(CameraEntity, Option<FovPlayer>)> = None;
for op in c["ops"].as_array().unwrap() {
match op[0].as_str().unwrap() {
"entity" => {
current = Some(entity(&op[1]));
writeln!(out, "entity")?;
}
"type" => {
kind = camera_type(&op[1]);
writeln!(out, "type")?;
}
"tick" => {
let target = current
.as_ref()
.and_then(|(_, p)| p.as_ref())
.map_or(1.0, |p| {
field_of_view_modifier(p, kind.is_first_person(), effect)
});
camera.tick(current.as_ref().map(|(e, _)| e.eye_height), target);
writeln!(
out,
"tick {} {} {} {}",
fb(camera.eye_height),
fb(camera.eye_height_old),
fb(camera.fov_modifier),
fb(camera.old_fov_modifier)
)?;
}
"frame" => {
let pt = fl(&op[1]);
let fluid = [
CameraFluid::None,
CameraFluid::Water,
CameraFluid::Lava,
CameraFluid::PowderSnow,
][op[2].as_u64().unwrap() as usize];
let panoramic = b(&op[3]);
let (e, _) = current.as_ref().expect("entity");
camera.align_with_entity(e, pt, kind, |from, to| {
let Ok(hit) = clip_world(
&mut world,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
usize::MAX,
) else {
panic!("ray")
};
(!hit.miss).then(|| {
let l = hit.hit.location;
[l.x, l.y, l.z]
})
});
let dead = e.living.and_then(|l| l.dead);
let fov = camera.calculate_fov(pt, fov_option, effect, dead, fluid, panoramic);
let hud = camera.calculate_hud_fov(pt, effect, dead, fluid);
let p = camera.position;
let q = camera.rotation;
let v = |a: [f32; 3]| format!("{} {} {}", fb(a[0]), fb(a[1]), fb(a[2]));
writeln!(
out,
"frame {} {} {} {} {} {} {} {} {} {} {} {} {} {} {}",
dbs(p[0]),
dbs(p[1]),
dbs(p[2]),
fb(camera.x_rot),
fb(camera.y_rot),
fb(q.x),
fb(q.y),
fb(q.z),
fb(q.w),
v(camera.forwards),
v(camera.up),
v(camera.left),
camera.detached,
fb(fov),
fb(hud)
)?;
}
other => panic!("{other}"),
}
}
Ok(())
}
fn bob(c: &Value, out: &mut impl Write) -> Result<()> {
let state = BobState {
living: (!c["living"].is_null()).then(|| {
let l = &c["living"];
HurtBob {
hurt_time: fl(&l["hurt"]),
hurt_duration: l["duration"].as_i64().unwrap() as i32,
hurt_dir: fl(&l["dir"]),
death_time: (!l["death"].is_null()).then(|| fl(&l["death"])),
}
}),
player: c["player"].as_array().map(|p| (fl(&p[0]), fl(&p[1]))),
};
let m = bob_matrix(&state, db(&c["tilt"]), b(&c["view"]));
let mut line = String::from("bob");
for v in m.m {
line.push(' ');
line.push_str(&fb(v));
}
writeln!(out, "{line} {}", m.properties)?;
Ok(())
}
fn avatar(c: &Value, out: &mut impl Write) -> Result<()> {
let mut state = AvatarBob::default();
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
match name {
"tick" => state.tick(),
"move" => state.add_walked(fl(&op[1]), fl(&op[2])),
"bob" => state.update_bob(b(&op[1]) && !b(&op[2]) && !b(&op[3]), vec3(&op[4])),
"reset" => state.reset_bob(),
"frame" => {
let pt = fl(&op[1]);
writeln!(
out,
"frame {} {}",
fb(state.interpolated_bob(pt)),
fb(state.backwards_interpolated_walk_distance(pt))
)?;
continue;
}
other => panic!("{other}"),
}
writeln!(out, "{name}")?;
}
Ok(())
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
match c["t"].as_str().unwrap() {
"fov" => writeln!(
out,
"{}",
fb(field_of_view_modifier(
&fov_player(&c),
b(&c["fp"]),
fl(&c["scale"])
))
)?,
"camera" => camera(&c, &mut out)?,
"bob" => bob(&c, &mut out)?,
"avatar" => avatar(&c, &mut out)?,
other => panic!("{other}"),
}
}
out.flush()?;
Ok(())
}
@@ -54,7 +54,9 @@ fn parser_id(ty: &ArgType) -> String {
ArgType::ResOrTagKey(_) => "minecraft:resource_or_tag_key".into(),
ArgType::StrEnum(values, _) => match values.first().copied() {
Some("world_surface") => "minecraft:heightmap".into(),
Some("none") if values.len() == 3 && values[1] == "left_right" => "minecraft:template_mirror".into(),
Some("none") if values.len() == 3 && values[1] == "left_right" => {
"minecraft:template_mirror".into()
}
Some("none") if values.len() == 3 => "minecraft:swing_animation".into(),
_ => "minecraft:template_rotation".into(),
},
@@ -194,10 +196,12 @@ fn main() -> horizon_client::Result<()> {
reg.persistent_components = strings(&r["persistent_components"]);
reg.learnable_recipes = strings(&r["learnable_recipes"]);
for (clock, markers) in r["clock_time_markers"].as_object().into_iter().flatten() {
reg.clock_time_markers.insert(clock.clone(), strings(markers));
reg.clock_time_markers
.insert(clock.clone(), strings(markers));
}
for (id, criteria) in r["advancement_criteria"].as_object().into_iter().flatten() {
reg.advancement_criteria.insert(id.clone(), strings(criteria));
reg.advancement_criteria
.insert(id.clone(), strings(criteria));
}
reg.datapacks_enabled = strings(&r["datapacks_selected"]);
let enabled = reg.datapacks_enabled.clone();
@@ -0,0 +1,278 @@
//! Entity death drops for differential tests against vanilla
//! (tools/test_entity_death_loot_parity.py): reads the cases of
//! `VanillaEntityDeathLootOracle` and prints what `mobs::drops::die` drops.
//!
//! usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
mobs::{Mob, MobTypes, Species, cube, drops, gear, raider, render::DYE_NAMES, sulfur_cube},
random_sequences::RandomSequences,
};
use horizon_core::{geometry::Vec3, random::LegacyRandom};
use horizon_world::loot::{LootDamage, LootData, LootEntity, LootStack};
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
/// `id[;enchantment:level...]`.
fn stack(spec: &str) -> LootStack {
let mut parts = spec.split(';');
let mut s = LootStack {
item: parts.next().unwrap_or("minecraft:air").to_owned(),
count: 1,
damage: 0,
enchantments: Vec::new(),
potion: None,
map: false,
ominous_amplifier: None,
};
for e in parts {
let (name, level) = e.rsplit_once(':').unwrap_or((e, "1"));
let name = if name.contains(':') {
name.to_owned()
} else {
format!("minecraft:{name}")
};
s.enchantments.push((name, level.parse().unwrap_or(1)));
}
s
}
fn line(s: &LootStack) -> String {
let mut out = format!("drop {} {}", s.item, s.count);
if s.damage != 0 {
let _ = write!(out, " damage={}", s.damage);
}
for (e, l) in &s.enchantments {
let _ = write!(out, " ench={e}:{l}");
}
if let Some(p) = &s.potion {
let _ = write!(out, " potion={p}");
}
if let Some(a) = s.ominous_amplifier {
let _ = write!(out, " ominous={a}");
}
out
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: entity_death_loot_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let types = MobTypes::load(root)?;
let loot = LootData::load(root)?;
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() || raw.starts_with('#') {
continue;
}
let mut c = BTreeMap::new();
let mut name = "";
for token in raw.split_whitespace() {
match token.split_once('=') {
Some((k, v)) => {
c.insert(k, v);
}
None => name = token,
}
}
let get =
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
let _ = writeln!(out, "== {name}");
let Some(kind) = types.types.get(c["type"]) else {
let _ = writeln!(out, "error unsupported {}", c["type"]);
continue;
};
let kind = Arc::new(kind.clone());
let world_seed: i64 = get("seed", "0").parse()?;
let kills: i64 = get("kills", "1").parse()?;
let rand: i64 = get("rand", "0").parse()?;
let mut sequences = RandomSequences::default();
for k in 0..kills {
let _ = writeln!(out, "kill {k}");
let mut m = Mob::new(1, kind.clone(), Vec3::new(0.5, 64.0, 0.5), 0.0);
// EntityType.create(LOAD) gives no equipment.
for slot in drops::SLOTS {
drops::clear_slot(&mut m, slot);
}
if get("baby", "0") == "1" && drops::can_be_baby(m.species()) {
m.extra.age = -24000;
}
if get("fire", "0") == "1" {
m.fire_ticks = 100;
}
// A cube made with EntityType.create(LOAD) was never sized.
if matches!(m.species(), Species::Slime | Species::MagmaCube) {
m.extra.size = 0;
}
if let Some(size) = c.get("size") {
let size: i32 = size.parse()?;
if m.species() == Species::SulfurCube {
sulfur_cube::set_size(&mut m, size, true);
} else {
cube::set_size(&mut m, size);
}
}
if let Some(color) = c.get("color") {
m.extra.color = DYE_NAMES.iter().position(|d| d == color).unwrap_or(0) as u8;
}
m.extra.sheared = get("sheared", "0") == "1";
if get("leader", "0") == "1" {
raider::state_mut(&mut m).patrol_leader = true;
}
if get("captain", "0") == "1" {
m.extra.head = Some(Arc::from(raider::BANNER));
}
let mut chances = [drops::DEFAULT_DROP_CHANCE; 8];
for (i, slot) in drops::SLOTS.iter().enumerate() {
if let Some(spec) = c.get(slot) {
let s = stack(spec);
let item: Option<Arc<str>> = Some(Arc::from(s.item.as_str()));
let index = match *slot {
"mainhand" => {
m.extra.main_hand = item;
Some(gear::MAIN)
}
"offhand" => {
m.extra.off_hand = item;
Some(gear::OFF)
}
"head" => {
m.extra.head = item;
Some(gear::HEAD)
}
"body" if drops::head_is_body(m.species()) => {
m.extra.head = item;
None
}
"saddle" => {
m.set_saddle(Some(horizon_client::items::ItemStack::new(&s.item, 1)));
None
}
"chest" => {
m.extra.chest = item;
Some(gear::CHEST)
}
"legs" => {
m.extra.legs = item;
Some(gear::LEGS)
}
"feet" => {
m.extra.feet = item;
Some(gear::FEET)
}
_ => None,
};
if let Some(index) = index
&& !s.enchantments.is_empty()
{
let levels = s
.enchantments
.iter()
.map(|(e, l)| (Arc::from(e.as_str()), *l as u32))
.collect();
gear::set(&mut m, index, levels);
}
}
if let Some(chance) = c.get(format!("chance_{slot}").as_str()) {
chances[i] = chance.parse()?;
}
}
m.extra.drop_chances = Some(Box::new(chances));
m.random = LegacyRandom::new(rand + k);
// The killer, the last player to hurt the victim and the damage source.
let killer_id = get("killer", "none");
let weapon = c.get("weapon").map(|w| stack(w));
let armed = |kind: &str| {
let mut e = LootEntity::new(kind);
if let Some(w) = &weapon {
e.equipment.push(("mainhand".into(), w.clone()));
}
e
};
let player = (killer_id == "player" || get("pkill", "0") == "1").then(|| {
if killer_id == "player" {
armed("minecraft:player")
} else {
LootEntity::new("minecraft:player")
}
});
let killer = match killer_id.as_str() {
"player" => player.clone(),
"none" => None,
id => {
let mut e = armed(id);
e.frog_variant = c.get("frog").map(|f| f.to_string());
e.powered = get("charged", "0") == "1";
Some(e)
}
};
let pkill = get("pkill", if killer_id == "player" { "1" } else { "0" }) == "1";
let projectile = |kind: &str| LootEntity {
kind: kind.to_owned(),
..LootEntity::default()
};
let damage =
|kind: &str, source: Option<LootEntity>, direct: Option<LootEntity>| LootDamage {
kind: kind.to_owned(),
source,
direct,
};
let source = match get("source", "attack").as_str() {
"attack" => match &killer {
None => damage("minecraft:generic", None, None),
Some(k) if k.kind == "minecraft:player" => {
damage("minecraft:player_attack", Some(k.clone()), Some(k.clone()))
}
Some(k) => damage("minecraft:mob_attack", Some(k.clone()), Some(k.clone())),
},
"arrow" => damage(
"minecraft:arrow",
killer.clone(),
Some(projectile("minecraft:arrow")),
),
"fireball" => match &killer {
Some(k) => damage(
"minecraft:fireball",
Some(k.clone()),
Some(projectile("minecraft:fireball")),
),
None => damage(
"minecraft:unattributed_fireball",
Some(projectile("minecraft:fireball")),
Some(projectile("minecraft:fireball")),
),
},
"explosion" => match &killer {
Some(k) => damage(
"minecraft:player_explosion",
Some(k.clone()),
Some(k.clone()),
),
None => damage("minecraft:explosion", None, None),
},
"lightning" => damage("minecraft:lightning_bolt", None, None),
"fire" => damage("minecraft:on_fire", None, None),
_ => damage("minecraft:generic", None, None),
};
let death = drops::Death {
loot: &loot,
damage: Some(source),
player: player.filter(|_| pkill),
luck: get("luck", "0").parse()?,
vehicle: c.get("vehicle").map(|v| LootEntity::new(v)),
mob_drops: true,
world_seed,
};
let (items, _) = drops::die(&mut m, &death, &mut sequences);
for d in items {
let _ = match d {
drops::Drop::Item(s) => writeln!(out, "{}", line(&s)),
drops::Drop::Orb(v) => writeln!(out, "orb {v}"),
};
}
}
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,107 @@
#![forbid(unsafe_code)]
//! Runs the JSON-lines scenarios of tools/test_frame_limiter_parity.py through
//! the Rust `FramerateLimiter` with the same scripted virtual clock.
use horizon_client::{
Result,
window::{FramerateLimiter, LimiterClock, end_frame},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
/// The scripted clock of the case: every query costs `call` ns, parking
/// sleeps the request plus the next overshoot (at least 1 ns), spinning
/// costs `spin` ns and interruptions follow their script.
struct Script {
now: i64,
call: i64,
spin: i64,
overshoot: Vec<i64>,
next_overshoot: usize,
interrupts: Vec<bool>,
next_interrupt: usize,
parks: Vec<i64>,
spins: u64,
queries: u64,
}
impl LimiterClock for Script {
fn nanos(&mut self) -> i64 {
self.queries += 1;
let t = self.now;
self.now = self.now.wrapping_add(self.call);
t
}
fn park(&mut self, nanos: i64) {
self.parks.push(nanos);
let o = self.overshoot[self.next_overshoot % self.overshoot.len()];
self.next_overshoot += 1;
self.now = self.now.wrapping_add(nanos.max(0).wrapping_add(o).max(1));
}
fn spin(&mut self) {
self.spins += 1;
self.now = self.now.wrapping_add(self.spin);
}
fn interrupted(&mut self) -> bool {
let i = self.interrupts[self.next_interrupt % self.interrupts.len()];
self.next_interrupt += 1;
i
}
}
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let i = |k: &str| c[k].as_i64().unwrap();
let mut clock = Script {
now: i("now"),
call: i("call"),
spin: i("spin"),
overshoot: c["overshoot"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_i64().unwrap())
.collect(),
next_overshoot: 0,
interrupts: c["interrupts"]
.as_array()
.unwrap()
.iter()
.map(|v| v.as_bool().unwrap())
.collect(),
next_interrupt: 0,
parks: Vec::new(),
spins: 0,
queries: 0,
};
let mut limiter =
FramerateLimiter::with_state(i("last"), i("average"), i("last_limit") as i32);
for frame in c["frames"].as_array().unwrap() {
clock.now = clock.now.wrapping_add(frame[0].as_i64().unwrap());
clock.parks.clear();
clock.spins = 0;
clock.queries = 0;
end_frame(&mut limiter, frame[1].as_i64().unwrap() as i32, &mut clock);
let parks: Vec<String> = clock.parks.iter().map(i64::to_string).collect();
writeln!(
out,
"frame {} {} {} {} queries {} spins {} parks [{}]",
clock.now,
limiter.last_frame_time(),
limiter.average_overshoot_ns(),
limiter.last_framerate_limit(),
clock.queries,
clock.spins,
parks.join(",")
)?;
}
}
out.flush()?;
Ok(())
}
@@ -0,0 +1,270 @@
//! Headless transport for the HUD parity gate: replays each case through
//! `horizon_client::hud_layout` and writes the draw calls in vanilla's
//! GuiGraphicsExtractor terms. Case kinds: `decorations` (crosshair,
//! hotbar, health, mount hearts, experience bar and level) and the
//! overlays handled in `kinds`.
#![forbid(unsafe_code)]
use horizon_client::{
hud::{HudAtlas, HudBatch},
hud_layout::{self, GuiDraw, HealthHud, HealthInputs, HeartKind, HotbarInputs, IDENTITY, Pose},
lang,
text::Text,
};
use serde_json::{Value, json};
use std::{
fs,
io::{BufWriter, Write},
path::PathBuf,
};
type Result<T> = std::result::Result<T, Box<dyn std::error::Error>>;
#[path = "hud_fixture/kinds.rs"]
mod kinds;
fn main() -> Result<()> {
let args: Vec<PathBuf> = std::env::args_os().skip(1).map(PathBuf::from).collect();
let [root, inputs, output] = args.as_slice() else {
return Err("usage: hud_fixture RESOURCES INPUTS OUTPUT".into());
};
let atlas = HudAtlas::build(root)?;
lang::set(lang::Language::load(&[root], lang::DEFAULT));
let mut out = BufWriter::new(fs::File::create(output)?);
for line in fs::read_to_string(inputs)?
.lines()
.filter(|l| !l.is_empty())
{
let c: Value = serde_json::from_str(line)?;
// State the case reads back besides the draws (e.g. `scopeScale`).
let mut state = serde_json::Map::new();
let draws = match c["kind"].as_str().unwrap_or("decorations") {
"decorations" => decorations(&c, &atlas),
kind => kinds::run(kind, &c, &atlas, &mut state),
};
state.insert("draws".into(), json!(encode(&draws)));
writeln!(out, "{}", Value::Object(state))?;
}
Ok(())
}
/// Style runs of styled text: `[text, rgb|null, bold, italic]` per run of
/// equally styled characters, or none for plain text.
fn runs(t: &Text) -> Option<Value> {
let mut out: Vec<(String, Option<u32>, bool, bool)> = Vec::new();
let mut styled = false;
for (ch, s) in t.chars() {
let key = (s.color, s.is_bold(), s.is_italic());
styled |= key != (None, false, false);
match out.last_mut() {
Some(last) if (last.1, last.2, last.3) == key => last.0.push(ch),
_ => out.push((ch.to_string(), key.0, key.1, key.2)),
}
}
styled.then(|| {
json!(
out.into_iter()
.map(|r| json!([r.0, r.1, r.2, r.3]))
.collect::<Vec<_>>()
)
})
}
fn encode(draws: &[GuiDraw]) -> Vec<Value> {
let mut stack: Vec<Pose> = vec![IDENTITY];
let mut out = Vec::new();
for d in draws {
let pose = *stack.last().unwrap();
let mut v = match d {
GuiDraw::Push(ops) => {
let mut m = pose;
for op in ops {
hud_layout::pose_apply(&mut m, *op);
}
stack.push(m);
continue;
}
GuiDraw::Pop => {
stack.pop();
continue;
}
GuiDraw::Sprite {
sprite,
x,
y,
w,
h,
region,
color,
} => {
json!({"type": "sprite", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "region": region, "color": *color as i32})
}
GuiDraw::AtlasSprite {
sprite,
x,
y,
w,
h,
color,
} => {
json!({"type": "atlas", "sprite": sprite, "x": x, "y": y, "w": w, "h": h, "color": *color as i32})
}
GuiDraw::Blit {
texture,
blend,
x,
y,
w,
h,
uv,
src,
tex,
color,
} => json!({
"type": "blit", "texture": format!("textures/{texture}.png"), "pipeline": blend.pipeline(),
"x": x, "y": y, "w": w, "h": h, "uv": uv, "src": src, "tex": tex, "color": *color as i32}),
GuiDraw::Fill {
x0,
y0,
x1,
y1,
color,
} => {
json!({"type": "fill", "x0": x0, "y0": y0, "x1": x1, "y1": y1, "color": *color as i32})
}
GuiDraw::Text {
text,
x,
y,
color,
shadow,
} => {
let mut v = json!({"type": "text", "text": text.plain(), "x": x, "y": y, "color": *color as i32, "shadow": shadow});
if let Some(r) = runs(text) {
v["runs"] = r;
}
v
}
GuiDraw::Item {
slot,
x,
y,
seed,
scale,
pivot,
} => {
// translate(pivot) * scale * translate(-pivot) as a 3x2 matrix.
let m = [
scale[0],
0.0,
0.0,
scale[1],
pivot[0] - pivot[0] * scale[0],
pivot[1] - pivot[1] * scale[1],
];
out.push(json!({"type": "item", "slot": slot, "x": x, "y": y, "seed": seed, "matrix": m}));
continue;
}
};
if pose != IDENTITY {
v["matrix"] = json!(pose);
}
out.push(v);
}
out
}
fn decorations(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
let int = |k: &str, d: i64| c[k].as_i64().unwrap_or(d) as i32;
let float = |k: &str, d: f64| c[k].as_f64().unwrap_or(d) as f32;
let (w, h) = (int("width", 0), int("height", 0));
let mode = c["gameMode"].as_str().unwrap_or("survival");
let survival = mode == "survival" || mode == "adventure";
let indicator = c["attackIndicator"].as_str().unwrap_or("OFF");
let partial = float("partial", 0.0);
let effects: Vec<&str> = c["effects"]
.as_array()
.into_iter()
.flatten()
.filter_map(Value::as_str)
.collect();
let has = |e: &str| effects.contains(&e);
let mut draws = Vec::new();
// Spectators only see it over a spectatable target (none here).
if c["crosshair"].as_bool().unwrap_or(false) && mode != "spectator" {
let attack = (indicator == "CROSSHAIR").then(|| float("attackScale", 1.0));
draws.extend(hud_layout::crosshair(w, h, attack, false));
}
if mode != "spectator" {
let pop = |v: &Value| v.as_f64().map(|p| p as f32 - partial);
let slots: [Option<f32>; 9] = std::array::from_fn(|i| pop(&c["slots"][i]));
let inputs = HotbarInputs {
selected: int("selected", 0),
right_handed: c["rightHanded"].as_bool().unwrap_or(true),
offhand: !c["offhand"].is_null(),
slots,
offhand_pop: pop(&c["offhand"]).unwrap_or(0.0),
attack: (indicator == "HOTBAR").then(|| float("attackScale", 1.0)),
};
draws.extend(hud_layout::hotbar(w, h, &inputs));
}
let vehicle_hearts = if c.get("vehicleMaxHealth").is_some() {
hud_layout::vehicle_max_hearts(float("vehicleMaxHealth", 0.0))
} else {
0
};
if survival {
let millis = 1_000_000_000i64;
let mut hud = HealthHud {
last_health: int("lastHealth", 0),
display_health: int("displayHealth", 0),
last_health_time: millis - i64::from(int("lastHealthAgo", 0)),
health_blink_time: i64::from(int("healthBlinkTime", 0)),
};
let kind = if has("minecraft:poison") {
HeartKind::Poisoned
} else if has("minecraft:wither") {
HeartKind::Withered
} else if c["frozen"].as_bool().unwrap_or(false) {
HeartKind::Frozen
} else {
HeartKind::Normal
};
let inputs = HealthInputs {
tick: int("tick", 0),
millis,
health: float("health", 20.0),
max_health: float("maxHealth", 20.0),
absorption: float("absorption", 0.0),
armor: int("armor", 0),
food: int("food", 20),
saturation: float("saturation", 5.0),
kind,
regeneration: has("minecraft:regeneration"),
hunger: has("minecraft:hunger"),
hardcore: c["hardcore"].as_bool().unwrap_or(false),
damage_cooldown: int("damageCooldown", 0) > 0,
air: int("air", 300),
max_air: int("maxAir", 300),
under_water: c["underWater"].as_bool().unwrap_or(false),
vehicle_hearts,
};
draws.extend(hud.layout(&inputs, w, h));
}
if vehicle_hearts > 0 {
draws.extend(hud_layout::vehicle_health(
w,
h,
vehicle_hearts,
float("vehicleHealth", 0.0),
));
}
let width = |s: &str| HudBatch::text_width(atlas, s) as i32;
let level = if survival { int("xpLevel", 0) } else { 0 };
draws.extend(hud_layout::experience(
w,
h,
float("xpProgress", 0.0),
level,
c["experienceBar"].as_bool().unwrap_or(false),
&width,
));
draws
}
@@ -0,0 +1,326 @@
//! The overlay case kinds of the HUD parity fixture.
use horizon_client::{
brewing,
chat::{Chat, ChatOptions},
component,
hud::HudAtlas,
hud_layout::{self, GuiDraw},
hud_overlays::{
self, BossBar, CameraOverlays, EffectIcon, HeldName, ItemDecorations, Titles, ToolHighlight,
},
scoreboard::{NumberFormat, RenderType, Scoreboard},
text::{self, Text},
};
use serde_json::Value;
fn int(c: &Value, k: &str, d: i64) -> i32 {
c[k].as_i64().unwrap_or(d) as i32
}
fn float(c: &Value, k: &str, d: f64) -> f32 {
c[k].as_f64().unwrap_or(d) as f32
}
/// A text component in its JSON form.
fn comp(v: &Value) -> Text {
component::to_text(v, &mut |s| Text::literal(s))
}
/// `Options.getBackgroundColor(0.0F)` from the case's text background options.
fn background(c: &Value) -> u32 {
hud_layout::background_color(
c["chatOnly"].as_bool().unwrap_or(true),
c["textOpacity"].as_f64().unwrap_or(0.5),
0.0,
)
}
pub fn run(
kind: &str,
c: &Value,
atlas: &HudAtlas,
state: &mut serde_json::Map<String, Value>,
) -> Vec<GuiDraw> {
let (w, h) = (int(c, "width", 0), int(c, "height", 0));
match kind {
"effects" => effects(c, w),
"title" => title(c, w, h, atlas),
"selected" => selected(c, w, h, atlas),
"boss" => boss(c, w, h, atlas),
"jump" => hud_overlays::jump_bar(w, h, int(c, "cooldown", 0), float(c, "jumpScale", 0.0)),
"sidebar" => sidebar(c, w, h, atlas),
"decor" => decor(c, atlas),
"overlays" => overlays(c, w, h, state),
"chat" => chat(c, h, atlas),
_ => Vec::new(),
}
}
fn effects(c: &Value, w: i32) -> Vec<GuiDraw> {
let icons: Vec<EffectIcon> = c["active"]
.as_array()
.into_iter()
.flatten()
.map(|e| {
let id = e["id"]
.as_str()
.unwrap_or("")
.trim_start_matches("minecraft:");
let def = brewing::effect(id).expect("effect");
EffectIcon {
id: id.to_owned(),
beneficial: def.category == brewing::Category::Beneficial,
color: def.color as i32,
ambient: e["ambient"].as_bool().unwrap_or(false),
duration: e["duration"].as_i64().unwrap_or(0) as i32,
show_icon: e["icon"].as_bool().unwrap_or(true),
}
})
.collect();
hud_overlays::effects(w, c["demo"].as_bool().unwrap_or(false), &icons)
}
/// Replays `Hud.setTitle` / `setSubtitle` / `setTimes` / `clearTitles` /
/// `resetTitleTimes` / `setOverlayMessage` and ticks, then extracts the
/// action bar and the title.
fn title(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let mut t = Titles::default();
for op in c["ops"].as_array().into_iter().flatten() {
let n = |i: usize| op[i].as_i64().unwrap_or(0) as i32;
match op[0].as_str().unwrap_or("") {
"title" => t.set_title(comp(&op[1])),
"subtitle" => t.set_subtitle(comp(&op[1])),
"times" => t.set_times(n(1), n(2), n(3)),
"clear" => t.clear(),
"reset" => t.reset_times(),
"overlay" => t.set_overlay(comp(&op[1]), op[2].as_bool().unwrap_or(false)),
"tick" => {
for _ in 0..n(1) {
t.tick();
}
}
other => panic!("unknown title op {other}"),
}
}
let partial = float(c, "partial", 0.0);
let bg = background(c);
let mut out = t.overlay_message(w, h, partial, &atlas.font, bg);
out.extend(t.title(w, h, partial, &atlas.font, bg));
out
}
/// Ticks `toolHighlightTimer` through the selected stacks, then extracts the name.
fn selected(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let mut highlight = ToolHighlight::default();
let time = c["displayTime"].as_f64().unwrap_or(1.0);
for step in c["steps"].as_array().into_iter().flatten() {
let held = (!step.is_null()).then(|| {
let custom = &step["custom"];
HeldName {
item: step["item"].as_str().unwrap_or("").to_owned(),
hover_name: if custom.is_null() {
Text::translate(step["key"].as_str().unwrap_or(""), &[])
} else {
comp(custom)
},
rarity_color: text::named_color(step["rarity"].as_str().unwrap_or("white"))
.unwrap_or(0xFFFFFF),
custom_name: !custom.is_null(),
}
});
highlight.tick(held, time);
}
let mode = c["gameMode"].as_str().unwrap_or("survival");
let can_hurt = mode == "survival" || mode == "adventure";
highlight.draw(w, h, can_hurt, &atlas.font, background(c))
}
fn boss(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let bars: Vec<BossBar> = c["bars"]
.as_array()
.into_iter()
.flatten()
.map(|b| BossBar {
name: comp(&b["name"]),
progress: b["progress"].as_f64().unwrap_or(0.0) as f32,
color: b["color"].as_u64().unwrap_or(0) as usize,
overlay: b["overlay"].as_u64().unwrap_or(0) as usize,
})
.collect();
hud_overlays::boss_overlay(w, h, &bars, &atlas.font)
}
/// A `NumberFormat` from the case: null, `{type: blank}`, `{type: fixed,
/// value}` or `{type: styled, style}`.
fn number_format(v: &Value) -> Option<NumberFormat> {
match v["type"].as_str()? {
"blank" => Some(NumberFormat::Blank),
"fixed" => Some(NumberFormat::Fixed(v["value"].clone())),
"styled" => Some(NumberFormat::Styled(v["style"].clone())),
other => panic!("unknown number format {other}"),
}
}
fn sidebar(c: &Value, w: i32, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let mut board = Scoreboard::default();
let o = &c["objective"];
board.add_objective(
"sidebar",
"dummy",
o["display"].clone(),
RenderType::Integer,
false,
number_format(&o["format"]),
);
for s in c["scores"].as_array().into_iter().flatten() {
let owner = s["owner"].as_str().unwrap_or("");
let score = board.score_mut(owner, "sidebar");
score.value = s["value"].as_i64().unwrap_or(0) as i32;
score.display = (!s["display"].is_null()).then(|| s["display"].clone());
score.number_format = number_format(&s["format"]);
}
for t in c["teams"].as_array().into_iter().flatten() {
let name = t["name"].as_str().unwrap_or("");
let team = board.add_team(name);
team.prefix = t["prefix"].clone();
team.suffix = t["suffix"].clone();
team.color = t["color"].as_str().map(str::to_owned);
for m in t["members"].as_array().into_iter().flatten() {
board.add_to_team(m.as_str().unwrap_or(""), name);
}
}
let opacity = c["textOpacity"].as_f64().unwrap_or(0.5);
let chat_only = c["chatOnly"].as_bool().unwrap_or(true);
let objective = board.objective("sidebar").expect("objective").clone();
hud_overlays::sidebar(
w,
h,
&board,
&objective,
&atlas.font,
hud_layout::background_color(chat_only, opacity, 0.3),
hud_layout::background_color(chat_only, opacity, 0.4),
)
}
/// `itemDecorations` for a stack with a count, damage and an item cooldown
/// `elapsed` ticks into `duration` (ItemCooldowns.getCooldownPercent).
fn decor(c: &Value, atlas: &HudAtlas) -> Vec<GuiDraw> {
let count = int(c, "count", 1);
if count <= 0 {
return Vec::new();
}
let cd = &c["cooldown"];
let cooldown = if cd.is_null() {
0.0
} else {
let (duration, elapsed) = (int(cd, "duration", 0), int(cd, "elapsed", 0));
if duration <= elapsed {
0.0 // ItemCooldowns.tick dropped it
} else {
let remaining = duration as f32 - (elapsed as f32 + float(c, "partial", 0.0));
(remaining / duration as f32).clamp(0.0, 1.0)
}
};
let max_damage = if c["unbreakable"].as_bool().unwrap_or(false) {
0
} else {
int(c, "maxDamage", 0)
};
let d = ItemDecorations {
count: count as u32,
count_text: c["countText"].as_str().map(str::to_owned),
bar: hud_overlays::damage_bar(int(c, "damage", 0), max_damage),
cooldown,
};
hud_overlays::item_decorations(int(c, "x", 0), int(c, "y", 0), &d, &atlas.font)
}
/// `Hud.extractCameraOverlays` then `extractSleepOverlay`; the border is
/// static, so only its warning blocks matter.
fn overlays(c: &Value, w: i32, h: i32, state: &mut serde_json::Map<String, Value>) -> Vec<GuiDraw> {
let partial = float(c, "partial", 0.0);
let warning = match &c["border"] {
Value::Null => 0.0,
b => {
// WorldBorder.getDistanceToBorder with the static extent's clamped box.
let max = 29_999_984.0;
let half = b["size"].as_f64().unwrap_or(0.0) / 2.0;
let (cx, cz) = (
b["centerX"].as_f64().unwrap_or(0.0),
b["centerZ"].as_f64().unwrap_or(0.0),
);
let (min_x, max_x) = ((cx - half).clamp(-max, max), (cx + half).clamp(-max, max));
let (min_z, max_z) = ((cz - half).clamp(-max, max), (cz + half).clamp(-max, max));
let (x, z) = (
f64::from(float(c, "camX", 0.0)),
f64::from(float(c, "camZ", 0.0)),
);
let distance = (x - min_x).min(max_x - x).min(z - min_z).min(max_z - z);
hud_overlays::border_warning(
distance as f32,
int(b, "warningBlocks", 5),
int(b, "warningTime", 15),
0.0,
0.0,
)
}
};
let ticks_frozen = int(c, "ticksFrozen", 0);
let inputs = CameraOverlays {
vignette: c["vignette"]
.as_bool()
.unwrap_or(false)
.then(|| (float(c, "vignetteBrightness", 1.0), warning)),
first_person: c["firstPerson"].as_bool().unwrap_or(false),
scoping: c["scoping"].as_bool().unwrap_or(false),
game_time_delta: partial,
equipment: match c["head"].as_str() {
Some("carved_pumpkin") => vec!["misc/pumpkinblur".to_owned()],
_ => Vec::new(),
},
ticks_frozen,
percent_frozen: ticks_frozen.min(140) as f32 / 140.0,
partial,
portal: [
c["portal"][0].as_f64().unwrap_or(0.0) as f32,
c["portal"][1].as_f64().unwrap_or(0.0) as f32,
],
nausea: float(c, "nausea", 0.0),
screen_effect_scale: float(c, "screenEffectScale", 1.0),
};
let mut scope = float(c, "scopeScale", 0.5);
let mut out = hud_overlays::camera_overlays(w, h, &inputs, &mut scope);
out.extend(hud_overlays::sleep_overlay(w, h, int(c, "sleepTimer", 0)));
state.insert("scopeScale".into(), serde_json::json!(scope));
out
}
/// Messages added at their ticks (`Hud.tickCount`), then the chat in
/// background mode at tick `now`.
fn chat(c: &Value, h: i32, atlas: &HudAtlas) -> Vec<GuiDraw> {
let option = |k: &str| c[k].as_f64().unwrap_or(0.0);
let mut chat = Chat::default();
chat.options = ChatOptions {
opacity: option("chatOpacity"),
background_opacity: option("textOpacity"),
scale: option("chatScale"),
width: option("chatWidth"),
height_focused: option("chatHeightFocused"),
height_unfocused: option("chatHeightUnfocused"),
line_spacing: option("chatLineSpacing"),
};
for m in c["messages"].as_array().into_iter().flatten() {
// GuiMessageTag indicator colours: systemSinglePlayer and chatError.
let indicator = match m["tag"].as_str() {
Some("system") => Some(13684944),
Some("error") => Some(16733525),
_ => None,
};
chat.add(
atlas,
comp(&m["text"]),
indicator,
m["at"].as_u64().unwrap_or(0),
false,
);
}
chat.layout(h, c["now"].as_u64().unwrap_or(0), false)
}
@@ -0,0 +1,601 @@
//! Replays `tools/test_mob_ai_parity.py` scenarios on Horizon's mob AI and
//! prints each mob's AI-visible state after every tick in the format of
//! `VanillaMobAiOracle`.
//!
//! usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT
use horizon_client::{
assets::Assets,
biome::Biomes,
blocks::BlockFacts,
chunk::{Chunk, HEIGHT, MIN_Y},
collisions,
items::Items,
light::{LightTables, light_chunk},
mobs::{
Mob, MobData, MobEvent, MobTypes, MobWorld, PlayerView, Seen, Species, Who,
control::Operation, spawning::LocalDifficulty,
},
world::World,
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::LegacyRandom,
};
use horizon_world::{chunk::BlockState, registry::BlockRegistry, registry::BlockStateId};
use std::{collections::BTreeMap, fmt::Write as _, fs, path::Path, sync::Arc};
struct MobSpec {
kind: String,
pos: [f64; 3],
yaw: f32,
seed: i64,
baby: bool,
flags: Vec<String>,
}
#[derive(Default)]
struct Scenario {
name: String,
ticks: i32,
time: i64,
rain: bool,
thunder: bool,
origin: [i32; 2],
blocks: Vec<Vec<String>>,
player: Option<[f64; 3]>,
creative: bool,
mobs: Vec<MobSpec>,
}
fn parse(text: &str) -> Vec<Scenario> {
let mut out = Vec::new();
let mut cur: Option<Scenario> = None;
for line in text.lines() {
let f: Vec<&str> = line.split_whitespace().collect();
if f.is_empty() || f[0].starts_with('#') {
continue;
}
let s = cur.get_or_insert_with(Scenario::default);
match f[0] {
"scenario" => {
s.name = f[1].to_owned();
s.time = 6000;
for kv in &f[2..] {
let (k, v) = kv.split_once('=').unwrap_or((kv, ""));
match k {
"ticks" => s.ticks = v.parse().unwrap(),
"time" => s.time = v.parse().unwrap(),
"rain" => s.rain = true,
"thunder" => s.thunder = true,
"levelseed" => {}
_ => panic!("unknown scenario option {kv}"),
}
}
}
"origin" => s.origin = [f[1].parse().unwrap(), f[2].parse().unwrap()],
"fill" | "block" => s.blocks.push(f.iter().map(|x| (*x).to_owned()).collect()),
"player" => {
s.player = Some([
f[1].parse().unwrap(),
f[2].parse().unwrap(),
f[3].parse().unwrap(),
]);
s.creative = f.get(4) == Some(&"creative");
}
"mob" => s.mobs.push(MobSpec {
kind: f[1].to_owned(),
pos: [
f[2].parse().unwrap(),
f[3].parse().unwrap(),
f[4].parse().unwrap(),
],
yaw: f[5].parse().unwrap(),
seed: f[6].parse().unwrap(),
baby: f[7..].contains(&"baby"),
flags: f[7..]
.iter()
.filter(|x| **x != "baby")
.map(|x| (*x).to_owned())
.collect(),
}),
"end" => out.push(cur.take().unwrap()),
other => panic!("unknown scenario line {other}"),
}
}
out
}
/// `minecraft:name[key=value,...]`
fn state(registry: &BlockRegistry, text: &str) -> BlockStateId {
let (name, props) = match text.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (text, ""),
};
let mut properties = BTreeMap::new();
for kv in props.split(',').filter(|s| !s.is_empty()) {
let (k, v) = kv.split_once('=').expect("property");
properties.insert(k.to_owned(), v.to_owned());
}
registry
.resolve(&BlockState {
name: name.to_owned(),
properties,
})
.unwrap_or_else(|e| panic!("{text}: {e}"))
}
fn d(v: f64) -> String {
format!("{:x}", v.to_bits())
}
fn f(v: f32) -> String {
format!("{:x}", v.to_bits())
}
fn who(w: Option<Who>) -> String {
match w {
None => "none".into(),
Some(Who::Player) => "player".into(),
Some(Who::Mob(id)) => format!("e{id}"),
}
}
/// Vanilla's goal class for a Horizon goal (most share the name).
fn goal_name(m: &Mob, name: &str) -> String {
let vanilla = match name {
"Float" => "FloatGoal",
"Stroll" => "WaterAvoidingRandomStrollGoal",
"RandomStroll" => "RandomStrollGoal",
"RandomSwimming" => "RandomSwimmingGoal",
"LookAtPlayer" => "LookAtPlayerGoal",
"RandomLookAround" => "RandomLookAroundGoal",
"HurtByTarget" => "HurtByTargetGoal",
"NearestTarget" => "NearestAttackableTargetGoal",
"Panic" => "PanicGoal",
"Breed" => "BreedGoal",
"Tempt" => "TemptGoal",
"FollowParent" => "FollowParentGoal",
"MeleeAttack" => match m.species() {
Species::Zombie | Species::Husk | Species::ZombieVillager => "ZombieAttackGoal",
_ => "MeleeAttackGoal",
},
"MoveThroughVillage" => "MoveThroughVillageGoal",
"RestrictSun" => "RestrictSunGoal",
"FleeSun" => "FleeSunGoal",
"AvoidEntity" => "AvoidEntityGoal",
"BowAttack" => "RangedBowAttackGoal",
"Swell" => "SwellGoal",
"LeapAtTarget" => "LeapAtTargetGoal",
"EatBlock" => "EatBlockGoal",
"MoveTowardsRestriction" => "MoveTowardsRestrictionGoal",
"TradeWithPlayer" => "TradeWithPlayerGoal",
"LookAtTradingPlayer" => "LookAtTradingPlayerGoal",
"WanderToPosition" => "WanderToPositionGoal",
"DrinkItem" => "UseItemGoal",
"BreathAir" => "BreathAirGoal",
"TryFindWater" => "TryFindWaterGoal",
"RangedAttack" => "RangedAttackGoal",
"MoveTowardsTarget" => "MoveTowardsTargetGoal",
"DrownedGoToWater" => "DrownedGoToWaterGoal",
"TakeBlock" => "EndermanTakeBlockGoal",
"LeaveBlock" => "EndermanLeaveBlockGoal",
"FreezeWhenLookedAt" => "EndermanFreezeWhenLookedAt",
"LookForPlayer" => "EndermanLookForPlayerGoal",
"HealRaiders" => "NearestHealableRaiderTargetGoal",
other => return other.to_owned(),
};
vanilla.to_owned()
}
fn goals(m: &Mob, names: Vec<&'static str>) -> String {
if names.is_empty() {
return "-".into();
}
names
.iter()
.map(|n| goal_name(m, n))
.collect::<Vec<_>>()
.join("|")
}
fn seen(m: &Mob) -> Seen {
Seen {
id: m.id,
species: m.species(),
position: m.position,
bounds: m.bounds(),
eye_y: m.eye_y(),
alive: m.alive(),
baby: m.is_baby(),
in_love: if m.species() == Species::Villager {
horizon_client::mobs::villager::can_breed(m)
} else {
m.extra.in_love > 0
},
tame: m.extra.tame,
hostile: m.hostile(),
target: m.ai.target,
health: m.health,
variant: m.extra.variant,
color: m.extra.color,
job: (m.species() == Species::Villager)
.then_some(m.extra.home)
.flatten(),
bed: (m.species() == Species::Villager)
.then_some(m.extra.bed)
.flatten(),
yaw: m.yaw,
velocity: m.velocity,
leash: m.extra.leash,
caravan: horizon_client::mobs::caravan::head_of(m),
delay: m.extra.ints[5],
hurt_by: m.ai.last_hurt_by.map(|w| (w, m.ai.last_hurt_by_time)),
active_raid: horizon_client::mobs::raider::has_active_raid(m),
player_rep: 0,
pushable: m.pushable(),
category: m.category(),
potential_job: horizon_client::mobs::villager_brain::potential_job_site(m),
meeting: (m.species() == Species::Villager)
.then_some(m.extra.meeting)
.flatten(),
profession: m.extra.variant2,
xp: horizon_client::mobs::villager::xp(m),
}
}
/// Whether an event hurts the scenario's player (survival, normal difficulty,
/// no armour, made hurtable again every tick by the oracle).
fn player_hurt(player: Option<&PlayerView>, e: &MobEvent) -> bool {
let Some(p) = player.filter(|p| !p.creative) else {
return false;
};
match e {
MobEvent::AttackPlayer(amount, _) | MobEvent::DamagePlayer { amount, .. } => *amount > 0.0,
// ServerExplosion.hurtEntities: within twice the radius every hit does damage.
MobEvent::Explode(at, radius) => {
let d = Vec3::new(at[0], at[1], at[2])
.distance_squared(p.position)
.sqrt();
let r = 2.0 * f64::from(*radius);
d / r <= 1.0 && d != 0.0
}
_ => false,
}
}
fn print(out: &mut String, t: i32, i: usize, m: &Mob) {
let _ = write!(out, "{t} {i}");
if m.removed {
// Entity.RemovalReason: the end of the death animation, else a discard.
let reason = if m.alive() { "DISCARDED" } else { "KILLED" };
let _ = writeln!(out, " removed {reason}");
return;
}
let p = m.position;
let v = m.velocity;
let _ = write!(
out,
"\tp {},{},{}\tv {},{},{}",
d(p.x),
d(p.y),
d(p.z),
d(v.x),
d(v.y),
d(v.z)
);
let _ = write!(
out,
"\tr {},{},{},{}",
f(m.yaw),
f(m.head_pitch),
f(m.head_yaw),
f(m.body_yaw)
);
let _ = write!(
out,
"\tg {}{}\thp {}",
u8::from(m.on_ground),
u8::from(m.horizontal_collision),
f(m.health)
);
let _ = write!(
out,
"\tgoals {}\ttargets {}\ttarget {}",
goals(m, m.ai.goals.running_names()),
goals(m, m.ai.targets.running_names()),
who(m.ai.target)
);
let _ = write!(out, "\tnav ");
match &m.ai.nav.path {
None => out.push_str("none"),
Some(path) => {
let _ = write!(
out,
"{},{},{} {}/{}{}",
path.target[0],
path.target[1],
path.target[2],
path.next,
path.nodes.len(),
if path.next >= path.nodes.len() {
" done"
} else {
""
}
);
}
}
let mv = &m.ai.mov;
let op = match mv.operation {
Operation::Wait => "WAIT",
Operation::MoveTo => "MOVE_TO",
Operation::Strafe => "STRAFE",
Operation::Jumping => "JUMPING",
};
let _ = write!(
out,
"\tmove {op} {},{},{} {}",
d(mv.wanted[0]),
d(mv.wanted[1]),
d(mv.wanted[2]),
d(mv.speed_modifier)
);
let lk = &m.ai.look;
let _ = write!(
out,
"\tlook {} {},{},{}",
lk.cooldown,
d(lk.wanted[0]),
d(lk.wanted[1]),
d(lk.wanted[2])
);
let _ = write!(
out,
"\tin {},{},{},{}",
f(m.ai.xxa),
f(m.ai.zza),
f(m.ai.speed),
u8::from(m.ai.jumping)
);
let _ = write!(out, "\trng {:x}", m.random.state());
if let Some((acts, mem)) = horizon_client::mobs::villager_brain::describe(m) {
let _ = write!(out, "\tact {acts}\tmem {mem}");
}
out.push('\n');
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
if args.len() != 3 {
return Err("usage: mob_ai_probe RESOURCES SCENARIOS OUTPUT".into());
}
let root = Path::new(&args[0]);
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let types = MobTypes::load(root)?;
let biomes = Biomes::load(root)?;
let items = Items::load(root, registry, &facts)?;
let tables = LightTables::load(root, registry, &facts)?;
let collision = collisions::load(root, registry)?;
let air = registry.default_state("minecraft:air").ok_or("no air")?;
let layers = [
state(registry, "minecraft:bedrock"),
state(registry, "minecraft:dirt"),
state(registry, "minecraft:dirt"),
state(registry, "minecraft:grass_block"),
];
let plains = biomes.id("minecraft:plains");
let mut out = String::new();
for s in parse(&fs::read_to_string(&args[1])?) {
let _ = writeln!(out, "== {}", s.name);
let mut world = World::new(air, 0);
let (ocx, ocz) = (s.origin[0] >> 4, s.origin[1] >> 4);
const R: i32 = 4;
for cz in ocz - R..=ocz + R {
for cx in ocx - R..=ocx + R {
let mut chunk = Chunk::empty(cx, cz);
for qy in 0..HEIGHT / 4 {
for qz in 0..4 {
for qx in 0..4 {
chunk.set_biome_cell(qx, qy, qz, plains);
}
}
}
world.chunks.insert((cx, cz), Arc::new(chunk));
for z in 0..16 {
for x in 0..16 {
for (y, layer) in layers.iter().enumerate() {
world.set(cx * 16 + x, MIN_Y + y as i32, cz * 16 + z, *layer)?;
}
}
}
}
}
for b in &s.blocks {
let n = |i: usize| b[i].parse::<i32>().unwrap();
if b[0] == "block" {
world.set(n(1), n(2), n(3), state(registry, &b[4]))?;
} else {
let st = state(registry, &b[7]);
for x in n(1).min(n(4))..=n(1).max(n(4)) {
for y in n(2).min(n(5))..=n(2).max(n(5)) {
for z in n(3).min(n(6))..=n(3).max(n(6)) {
world.set(x, y, z, st)?;
}
}
}
}
}
let keys: Vec<(i32, i32)> = world.chunks.keys().copied().collect();
let lit: Vec<((i32, i32), Chunk)> = keys
.iter()
.map(|&(x, z)| {
let region: [[Option<&Chunk>; 3]; 3] = std::array::from_fn(|dz| {
std::array::from_fn(|dx| {
world
.chunks
.get(&(x + dx as i32 - 1, z + dz as i32 - 1))
.map(|c| &**c)
})
});
let mut c = (*world.chunks[&(x, z)]).clone();
c.sections = light_chunk(&region, &tables, true);
c.lit = true;
((x, z), c)
})
.collect();
for (k, c) in lit {
world.chunks.insert(k, Arc::new(c));
}
world.day_time = s.time;
world.game_time = 1000;
let mut mobs: Vec<Mob> = Vec::new();
for (i, spec) in s.mobs.iter().enumerate() {
let kind = types
.types
.get(&spec.kind)
.ok_or_else(|| format!("unknown mob {}", spec.kind))?;
let mut m = Mob::new(
100_000 + i as u32,
Arc::new(kind.clone()),
Vec3::new(spec.pos[0], spec.pos[1], spec.pos[2]),
spec.yaw,
);
if spec.baby {
m.extra.age = -24000;
}
for flag in &spec.flags {
match flag.as_str() {
"persistent" => m.persistent = true,
"noai" => m.no_ai = true,
other => panic!("unknown mob flag {other}"),
}
}
m.random = LegacyRandom::new(spec.seed);
if m.species() == Species::Villager {
// The oracle seeds the random after the brain is built, then
// delays the sensors again and seeds the gates' shuffles.
horizon_client::mobs::villager_brain::init(
&mut m,
world.game_time,
s.time,
spec.seed,
);
}
mobs.push(m);
}
let player = s.player.map(|p| {
let position = Vec3::new(p[0], p[1], p[2]);
PlayerView {
position,
bounds: Aabb::new(
Vec3::new(p[0] - 0.3, p[1], p[2] - 0.3),
Vec3::new(p[0] + 0.3, p[1] + 1.8, p[2] + 0.3),
),
eye: [p[0], p[1] + f64::from(1.62_f32), p[2]],
alive: true,
creative: s.creative,
sneaking: false,
look: [0.0, 0.0, 1.0],
main_hand: None,
off_hand: None,
head: None,
armor_cover: 0.0,
spectator: false,
gold_armor: false,
hurt_by: None,
last_hurt: None,
velocity: Vec3::ZERO,
yaw: 0.0,
hero: false,
uuid: [0, 0x1234, 0, 0x5678],
}
});
let mut scratch = LegacyRandom::new(0);
for (i, m) in mobs.iter().enumerate() {
print(&mut out, 0, i, m);
}
for t in 1..=s.ticks {
world.game_time += 1;
let snapshot: Vec<Seen> = mobs.iter().filter(|m| !m.removed).map(seen).collect();
let w = MobWorld {
world: &world,
registry,
facts: &facts,
collision: &collision,
data: &data,
player: player.clone(),
others: &snapshot,
daylight: (s.time.rem_euclid(24000)) < 12000,
game_time: world.game_time,
day_time: world.day_time,
raining: s.rain,
thundering: s.thunder,
nether: false,
block_tags: &items.block_tags,
biomes: &biomes,
mob_griefing: true,
difficulty: LocalDifficulty::new(2, world.day_time, 0, 0.0),
end: false,
world_seed: 0,
// FlatLevelSource.getSeaLevel.
sea_level: -63,
};
let mut events = Vec::new();
#[allow(unused_mut)] // filled only with the rng-trace feature
let mut traces: Vec<String> = Vec::new();
let mut pushes = Vec::new();
for (i, m) in mobs.iter_mut().enumerate() {
if m.removed {
continue;
}
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
#[cfg(feature = "rng-trace")]
horizon_core::random::trace::set(true);
let alive = m.tick(&w, &mut scratch, &mut events);
#[cfg(feature = "rng-trace")]
{
horizon_core::random::trace::set(false);
for line in horizon_core::random::trace::take() {
traces.push(format!("{t} trace {i} {line}"));
}
}
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
let _ = i;
if !alive {
m.removed = true;
}
}
for m in &mut mobs {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
}
for (i, m) in mobs.iter().enumerate() {
print(&mut out, t, i, m);
}
for e in &events {
if let MobEvent::Sound(name, _, volume, pitch) = e {
let _ = writeln!(
out,
"{t} sound minecraft:{name} {} {}",
f(*volume),
f(*pitch)
);
} else if let MobEvent::SoundId(name, _, volume, pitch) = e {
let _ = writeln!(out, "{t} sound {name} {} {}", f(*volume), f(*pitch));
} else if player_hurt(player.as_ref(), e) {
// The oracle's survival player, healed and made hurtable every tick,
// takes the damage (LivingEntity.playHurtSound).
let _ = writeln!(
out,
"{t} sound minecraft:entity.player.hurt 3f800000 3f800000"
);
}
}
for line in traces {
let _ = writeln!(out, "{line}");
}
}
}
fs::write(&args[2], out)?;
Ok(())
}
@@ -0,0 +1,108 @@
//! Replays `tools/test_music_parity.py` scripts through Horizon's
//! `MusicManager` over its `SoundEngine`: `music_fixture ROOT INPUT OUTPUT`.
#[path = "audio_common/mod.rs"]
mod common;
use common::{Fake, describe, f, flush};
use horizon_client::music::{Music, MusicFrequency, MusicManager};
use horizon_client::sound_engine::{Bound, MUSIC, SoundEngine, TickContext};
use horizon_client::sound_events::SoundRegistry;
use std::io::Write;
fn music(spec: &str) -> Option<Music> {
if spec == "-" {
return None;
}
let p: Vec<&str> = spec.split(',').collect();
Some(Music::new(
&horizon_client::sound_events::normalize(p[0]),
p[1].parse().expect("min"),
p[2].parse().expect("max"),
p[3] == "1",
))
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
engine.listening = true;
let mut backend = Fake::default();
let mut manager = MusicManager::new(0);
let (mut situation, mut volume) = (None::<Music>, 1.0f32);
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
writeln!(out, "{line}").expect("write");
let a: Vec<&str> = line.split(' ').collect();
match a[0] {
"start" => {
if let Some(id) = manager.current_instance() {
engine.release(id);
}
manager = MusicManager::new(a[1].parse().expect("seed"));
manager.frequency = MusicFrequency::parse(a[2]);
engine.stop_all(&mut backend);
}
"situation" => {
situation = music(a[1]);
volume = f(a[2]);
}
"tick" => {
let mut sounds = Bound {
engine: &mut engine,
backend: &mut backend,
};
manager.tick(situation.as_ref(), volume, a[1] == "1", &mut sounds);
engine.tick(a[2] == "1", &TickContext::default(), &mut backend);
}
"finish" => {
if let Some(c) = manager.current_instance().and_then(|id| engine.channel(id)) {
horizon_client::sound_engine::Backend::stop(&mut backend, c);
}
}
"stop" => manager.stop_playing(
situation.as_ref(),
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
),
"stopmusic" => {
let m = music(a[1]).expect("music");
manager.stop_playing_music(
&m,
situation.as_ref(),
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
}
"freq" => manager.set_frequency(MusicFrequency::parse(a[1]), situation.as_ref()),
"toast" => manager.show_now_playing_toast_if_needed(),
"volume" => {
let source: usize = a[1].parse().expect("source");
engine.source_volumes[source] = f(a[2]);
engine.refresh_category_volume(source, &mut backend);
}
"stopall" => engine.stop_all(&mut backend),
_ => panic!("{line}"),
}
writeln!(
out,
" current={} delay={} gain={:08x} toastShown={} musicGain={:08x}",
manager.current(&engine).unwrap_or("-"),
manager.next_song_delay(),
manager.current_gain().to_bits(),
manager.toast_shown(),
engine.category_gain(MUSIC).to_bits()
)
.expect("write");
for show in std::mem::take(&mut manager.toast_requests) {
writeln!(out, " toast {}", if show { "show" } else { "hide" }).expect("write");
}
if let Some(id) = manager.current_instance() {
writeln!(out, "{}", describe(&engine, &backend, "music", id)).expect("write");
}
flush(&mut engine, &mut out);
}
}
@@ -0,0 +1,688 @@
//! Natural spawning decisions for differential tests against vanilla
//! (tools/test_natural_spawn_parity.py): reads the box-world cases of
//! `VanillaNaturalSpawnOracle` and prints what `mobs::spawn_selection` does in
//! the same order and format.
//!
//! The oracle runs `finalizeSpawn` with a fixed random (nextInt 0, nextFloat
//! 0.75); `neutral_box` is the box that leaves a mob with: polar bears,
//! ocelots and rabbits after the first of their group are babies (chance
//! 1.0), foxes and axolotls from the third, sulfur cubes have size 2, salmon
//! are small, slimes and magma cubes keep size 1, tropical fish school.
//!
//! usage: natural_spawn_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
biome::{Biomes, SpawnEntry},
blocks::BlockFacts,
collisions::{self, LookupCollisions, no_collision_empty},
items::Items,
mobs::{
MobTypes, Species,
data::MobData,
spawn_rules::{Dimension, SpawnLevel},
spawn_selection::{
self, Category, Counted, Event, GenEvent, Heightmap, NaturalLevel, SpawnCost,
SpawnPlayer, SpawnRequest, SpawnState, Spawned, StructureAt, StructureSpawns,
spawn_box,
},
species::{self, Baby},
},
};
use horizon_core::{
geometry::Aabb,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{
block_collision::BlockCollisionRegistry, chunk::BlockState, registry::BlockStateId,
};
use serde_json::Value;
use std::{
cell::RefCell,
collections::{BTreeMap, HashMap, HashSet},
fmt::Write as _,
fs,
path::Path,
};
#[derive(Clone, Copy)]
struct Bx([i32; 6]);
impl Bx {
fn parse(s: &str) -> Self {
let v: Vec<i32> = s.split(',').map(|n| n.parse().unwrap_or(0)).collect();
Self([v[0], v[1], v[2], v[3], v[4], v[5]])
}
fn has(&self, x: i32, y: i32, z: i32) -> bool {
let b = self.0;
x >= b[0] && x <= b[3] && y >= b[1] && y <= b[4] && z >= b[2] && z <= b[5]
}
}
struct Start {
id: String,
pieces: Vec<Bx>,
/// The start's bounding box (pieces encapsulated, inflated by 12 for
/// terrain adaptation).
bounds: Bx,
}
struct Shared<'a> {
facts: &'a BlockFacts,
data: &'a MobData,
block_tags: &'a HashMap<String, HashSet<String>>,
types: &'a MobTypes,
biomes: &'a Biomes,
structures: &'a StructureSpawns,
collision: &'a BlockCollisionRegistry<'a>,
air: BlockStateId,
}
struct World<'a> {
s: &'a Shared<'a>,
blocks: Vec<(Bx, BlockStateId)>,
lights: Vec<(Bx, i32, i32)>,
biomes: Vec<(Bx, String)>,
biome: String,
sky: i32,
light: i32,
darken: i32,
thunder: bool,
sea: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
dimension_id: String,
slime: f32,
starts: Vec<Start>,
players: Vec<([f64; 3], String)>,
respawn: Option<[i32; 3]>,
unloaded: HashSet<(i32, i32)>,
spawn_mobs: bool,
/// Boxes of living entities (case entities, then spawned mobs).
entities: RefCell<Vec<Aabb>>,
}
impl World<'_> {
fn biome_at(&self, x: i32, y: i32, z: i32) -> &str {
self.biomes
.iter()
.rev()
.find(|(b, _)| b.has(x, y, z))
.map_or(&self.biome, |(_, n)| n)
}
fn biome_info(&self, x: i32, y: i32, z: i32) -> Option<&horizon_client::biome::BiomeInfo> {
let i = *self.s.biomes.index.get(self.biome_at(x, y, z))?;
Some(self.s.biomes.get(i))
}
fn lights_at(&self, x: i32, y: i32, z: i32) -> (i32, i32) {
self.lights
.iter()
.rev()
.find(|(b, ..)| b.has(x, y, z))
.map_or((self.sky, self.light), |(_, s, b)| (*s, *b))
}
}
impl SpawnLevel for World<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.blocks
.iter()
.rev()
.find(|(b, _)| b.has(x, y, z))
.map_or(self.s.air, |(_, s)| *s)
}
fn facts(&self) -> &BlockFacts {
self.s.facts
}
fn mob_data(&self) -> &MobData {
self.s.data
}
fn block_tag(&self, tag: &str, block: &str) -> bool {
self.s
.block_tags
.get(tag)
.is_some_and(|s| s.contains(block))
}
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
self.s
.data
.biome_tags
.get(tag)
.is_some_and(|s| s.contains(self.biome_at(x, y, z)))
}
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.lights_at(x, y, z).0
}
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.lights_at(x, y, z).1
}
fn sky_darken(&self) -> i32 {
self.darken
}
fn thundering(&self) -> bool {
self.thunder
}
fn sea_level(&self) -> i32 {
self.sea
}
fn world_surface(&self, x: i32, z: i32) -> i32 {
self.heightmap(Heightmap::WorldSurface, x, z)
}
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
self.slime
}
fn world_seed(&self) -> i64 {
self.seed
}
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
self.players.iter().any(|(p, mode)| {
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
(mode == "survival" || mode == "adventure") && d < distance * distance
})
}
}
impl NaturalLevel for World<'_> {
fn min_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
-64
} else {
0
}
}
fn max_y(&self) -> i32 {
if self.dimension == Dimension::Overworld {
319
} else {
255
}
}
fn ambient_light(&self) -> f32 {
match self.dimension {
Dimension::Overworld => 0.0,
Dimension::Nether => 0.1,
Dimension::End => 0.25,
}
}
fn has_ceiling(&self) -> bool {
self.dimension == Dimension::Nether
}
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
let t = self.s.types.types.get(entity)?;
Some((
t.species,
t.width as f32,
t.height as f32,
Category::from_name(&t.category),
))
}
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
self.biome_info(x, y, z).map(|b| &b.spawns)
}
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
self.biome_info(x, y, z)?.spawn_costs.get(entity).copied()
}
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
self.biome_info(x, y, z)
.map_or(0.1, |b| b.creature_probability)
}
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
let (cx, cz) = (x >> 4, z >> 4);
let mut out: Vec<StructureAt> = Vec::new();
for start in &self.starts {
let b = start.bounds.0;
// Referenced: the start's box reaches the chunk's columns.
if b[3] < cx * 16 || b[0] > cx * 16 + 15 || b[5] < cz * 16 || b[2] > cz * 16 + 15 {
continue;
}
let in_piece = start.pieces.iter().any(|p| p.has(x, y, z));
let in_bounds = start.bounds.has(x, y, z);
match out.iter_mut().find(|s| s.id == start.id) {
Some(s) => {
s.in_piece |= in_piece;
s.in_bounds |= in_bounds;
}
None => out.push(StructureAt {
id: start.id.clone(),
in_piece,
in_bounds,
}),
}
}
out
}
fn structure_spawns(&self) -> &StructureSpawns {
self.s.structures
}
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
let mut best: Option<f64> = None;
for (p, mode) in &self.players {
if mode == "spectator" {
continue;
}
let d = [p[0] - x, p[1] - y, p[2] - z];
let d = d[0] * d[0] + d[1] * d[1] + d[2] * d[2];
if best.is_none_or(|b| d < b) {
best = Some(d);
}
}
best
}
fn respawn(&self) -> Option<[i32; 3]> {
self.respawn
}
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
!self.unloaded.contains(&(cx, cz))
}
fn no_collision(&self, bounds: Aabb) -> bool {
let lookup = |x: i32, y: i32, z: i32| self.state(x, y, z);
let mut world = LookupCollisions {
block: &lookup,
binding: self.s.collision,
};
no_collision_empty(&mut world, bounds)
}
fn unobstructed(&self, bounds: Aabb) -> bool {
!self.entities.borrow().iter().any(|b| b.intersects(bounds))
}
fn spawn_mobs(&self) -> bool {
self.spawn_mobs
}
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
(self.min_y()..=self.max_y())
.rev()
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
.map_or(self.min_y(), |y| y + 1)
}
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
self.s.data.state(self.state(x, y, z)).flags & horizon_client::mobs::data::LAND != 0
}
}
/// The box a mob is left with by `finalizeSpawn` under the oracle's fixed random.
fn neutral_box(l: &World, r: &SpawnRequest) -> Aabb {
let t = &l.s.types.types[r.entity];
let (mut w, mut h) = (t.width as f32, t.height as f32);
let baby = match r.species {
Species::PolarBear | Species::Ocelot | Species::Rabbit => r.group_index > 0,
Species::Fox | Species::Axolotl => r.group_index >= 2,
_ => false,
};
if baby {
match species::def(r.species).baby {
Baby::Abs(bw, bh, _) => (w, h) = (bw as f32, bh as f32),
Baby::Scale(s, _) => (w, h) = (w * s as f32, h * s as f32),
Baby::None => {}
}
}
match r.species {
Species::SulfurCube => (w, h) = (w * 2.0, h * 2.0),
Species::Salmon => (w, h) = (w * 0.5, h * 0.5),
_ => {}
}
let [x, y, z] = r.position;
spawn_box(x, y, z, w, h)
}
fn parse_state(spec: &str) -> BlockState {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
BlockState {
name: name.to_owned(),
properties: props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect(),
}
}
fn bits(v: f64) -> String {
format!("{:x}", v.to_bits())
}
/// The yaw as the oracle prints it: a cube mob grown by `finalizeSpawn`
/// (sulfur cubes, size 2 under the fixed random) takes its constructor's
/// head rotation from the entity's unseeded random.
fn yaw_text(entity: &str, v: f32) -> String {
if entity == "minecraft:sulfur_cube" {
"head".to_owned()
} else {
format!("{:x}", v.to_bits())
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: natural_spawn_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let types = MobTypes::load(root)?;
let biomes = Biomes::load(root)?;
let structures = StructureSpawns::load(root);
let collision = collisions::load(root, registry)?;
let shared = Shared {
facts: &facts,
data: &data,
block_tags: &items.block_tags,
types: &types,
biomes: &biomes,
structures: &structures,
collision: &collision,
air: facts.air,
};
let mut adaptation: HashMap<String, bool> = HashMap::new();
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() {
continue;
}
let mut c = BTreeMap::new();
let mut name = "";
for token in raw.split_whitespace() {
match token.split_once('=') {
Some((k, v)) => {
c.insert(k, v);
}
None => name = token,
}
}
let get =
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
let indexed = |prefix: &str| -> Vec<&str> {
(0..)
.map_while(|i| c.get(format!("{prefix}{i}").as_str()).copied())
.collect()
};
let mut blocks = Vec::new();
for k in indexed("k") {
let (b, s) = k.split_once('/').ok_or("bad block box")?;
blocks.push((Bx::parse(b), registry.resolve(&parse_state(s))?));
}
let lights = indexed("l")
.into_iter()
.filter_map(|l| {
let (b, v) = l.split_once('/')?;
let (s, k) = v.split_once(',')?;
Some((Bx::parse(b), s.parse().ok()?, k.parse().ok()?))
})
.collect();
let biome_boxes = indexed("b")
.into_iter()
.filter_map(|l| {
let (b, n) = l.split_once('/')?;
Some((Bx::parse(b), n.to_owned()))
})
.collect();
let mut starts = Vec::new();
for s in indexed("s") {
let (id, pieces) = s.split_once('/').ok_or("bad structure")?;
let pieces: Vec<Bx> = pieces.split(';').map(Bx::parse).collect();
let mut b = pieces[0].0;
for p in &pieces[1..] {
for i in 0..3 {
b[i] = b[i].min(p.0[i]);
b[i + 3] = b[i + 3].max(p.0[i + 3]);
}
}
let adapts = *adaptation.entry(id.to_owned()).or_insert_with(|| {
let path = root.join(format!(
"data/minecraft/worldgen/structure/{}.json",
id.trim_start_matches("minecraft:")
));
fs::read(path)
.ok()
.and_then(|b| serde_json::from_slice::<Value>(&b).ok())
.and_then(|v| v["terrain_adaptation"].as_str().map(|a| a != "none"))
.unwrap_or(false)
});
if adapts {
for i in 0..3 {
b[i] -= 12;
b[i + 3] += 12;
}
}
starts.push(Start {
id: id.to_owned(),
pieces,
bounds: Bx(b),
});
}
let players = indexed("p")
.into_iter()
.map(|p| {
let mut parts = p.split('/');
let v: Vec<f64> = parts
.next()
.unwrap_or("")
.split(',')
.map(|n| n.parse().unwrap_or(0.0))
.collect();
(
[v[0], v[1], v[2]],
parts.next().unwrap_or("survival").to_owned(),
)
})
.collect::<Vec<_>>();
let dimension_id = get("dim", "minecraft:overworld");
let dimension = match dimension_id.as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
};
let respawn = {
let r = get("respawn", "0,-1000,0/minecraft:overworld");
let (p, d) = r.split_once('/').ok_or("bad respawn")?;
let v: Vec<i32> = p.split(',').map(|n| n.parse().unwrap_or(0)).collect();
(d == dimension_id).then_some([v[0], v[1], v[2]])
};
let unloaded = c
.get("unloaded")
.map(|u| {
u.split(';')
.filter_map(|p| {
let (x, z) = p.split_once(',')?;
Some((x.parse().ok()?, z.parse().ok()?))
})
.collect()
})
.unwrap_or_default();
// Case entities: (type, position, persistent).
let mut case_entities = Vec::new();
for e in indexed("e") {
let parts: Vec<&str> = e.split('/').collect();
let v: Vec<f64> = parts[1]
.split(',')
.map(|n| n.parse().unwrap_or(0.0))
.collect();
// EntityType.create makes no monsters in peaceful.
if get("difficulty", "normal") == "peaceful"
&& types.types.get(parts[0]).is_some_and(|t| {
!horizon_client::mobs::spawn_rules::allowed_in_peaceful(t.species)
})
{
continue;
}
case_entities.push((
parts[0].to_owned(),
[v[0], v[1], v[2]],
parts.get(2) == Some(&"p"),
));
}
let world = World {
s: &shared,
blocks,
lights,
biomes: biome_boxes,
biome: get("biome", "minecraft:plains"),
sky: get("sky", "15").parse()?,
light: get("light", "0").parse()?,
darken: get("darken", "0").parse()?,
thunder: get("thunder", "0") == "1",
sea: get("sea", "63").parse()?,
seed: get("wseed", "0").parse()?,
peaceful: get("difficulty", "normal") == "peaceful",
dimension,
dimension_id: dimension_id.clone(),
slime: get("slime", "0.0").parse()?,
starts,
players,
respawn,
unloaded,
spawn_mobs: get("rules", "1") != "0",
entities: RefCell::new(
case_entities
.iter()
.filter_map(|(t, p, _)| {
let t = types.types.get(t)?;
Some(spawn_box(p[0], p[1], p[2], t.width as f32, t.height as f32))
})
.collect(),
),
};
let _ = &world.dimension_id;
let chunk = {
let v: Vec<i32> = get("chunk", "0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
(v[0], v[1])
};
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
let mut log: Vec<String> = Vec::new();
let mut spawn = |l: &World, r: &SpawnRequest| -> Option<Spawned> {
let bounds = neutral_box(l, r);
l.entities.borrow_mut().push(bounds);
Some(Spawned {
bounds,
school: true,
})
};
let mode = get("mode", "chunk");
if mode == "gen" {
let mut gen_random = LegacyRandom::new(get("gseed", "0").parse()?);
// The level random of the spawn predicates: no creature's predicate
// draws from it (the oracle's answers fixed values).
let mut rules = LegacyRandom::new(0);
let mut spawns = Vec::new();
let source = [chunk.0 * 16, world.max_y(), chunk.1 * 16];
spawn_selection::spawn_mobs_for_chunk_generation(
&world,
source,
chunk,
&mut gen_random,
&mut rules,
&mut spawn,
&mut |e| match e {
GenEvent::Column(x, z) => log.push(format!("col {x} {z}")),
GenEvent::Spawn(t, p, yaw) => spawns.push(format!(
"spawn {t} {} {} {} {}",
bits(p[0]),
bits(p[1]),
bits(p[2]),
yaw_text(&t, yaw)
)),
},
);
log.extend(spawns);
log.push(format!(
"next {}",
gen_random.next_int_bound(1000).unwrap_or(0)
));
} else {
let counted: Vec<Counted> = case_entities
.iter()
.filter_map(|(t, p, persistent)| {
let kind = types.types.get(t)?;
Some(Counted {
entity: t,
category: Category::from_name(&kind.category),
block: [
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
],
is_mob: true,
persistent: *persistent,
})
})
.collect();
let players = world
.players
.iter()
.map(|(p, mode)| SpawnPlayer {
position: *p,
spectator: mode == "spectator",
})
.collect();
let mut state = SpawnState::new(
&world,
get("chunkcount", "289").parse()?,
players,
&counted,
|cx, cz| !world.unloaded.contains(&(cx, cz)),
);
let mut trace = |e: Event| {
log.push(match e {
Event::Categories(list) => format!(
"cats {}",
list.iter().map(|c| c.name()).collect::<Vec<_>>().join(",")
),
Event::Local(c, ok) => format!("local {} {ok}", c.name()),
Event::Start(c, p) => format!("start {} {} {} {}", c.name(), p[0], p[1], p[2]),
Event::Try(x, z) => format!("try {x} {z}"),
Event::Test(t, p, ok) => format!("test {t} {} {} {} {ok}", p[0], p[1], p[2]),
Event::Spawn(t, p, yaw) => format!(
"spawn {t} {} {} {} {}",
bits(p[0]),
bits(p[1]),
bits(p[2]),
yaw_text(&t, yaw)
),
})
};
if mode == "pos" {
let category = Category::from_name(&get("cat", "monster"));
let v: Vec<i32> = get("start", "0,0,0")
.split(',')
.map(|n| n.parse().unwrap_or(0))
.collect();
spawn_selection::spawn_category_for_position(
&world,
&mut random,
&mut state,
category,
chunk,
[v[0], v[1], v[2]],
&mut spawn,
&mut trace,
);
} else {
let categories = state
.filtered_categories(get("enemies", "1") == "1", get("persistent", "1") == "1");
trace(Event::Categories(categories.clone()));
spawn_selection::spawn_for_chunk(
&world,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut trace,
);
}
log.push(format!("next {}", random.next_int_bound(1000).unwrap_or(0)));
}
let _ = writeln!(out, "{name} {}", log.join(" ; "));
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,47 @@
//! `getSituationalMusic`/`getMusicVolume` through Horizon's `Situation` for
//! `tools/test_situational_music_parity.py`:
//! `situational_music_fixture ROOT INPUT OUTPUT`.
use horizon_client::music::{BackgroundMusic, Music, MusicTables, Situation};
use horizon_client::sound_events::normalize;
use std::io::Write;
fn main() {
let args: Vec<String> = std::env::args().collect();
let tables = MusicTables::load(std::path::Path::new(&args[1]));
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let in_world = f[1] == "1";
let (background, probe_volume) = if in_world {
(tables.background(f[2], f[3]), tables.volume(f[2], f[3]))
} else {
(BackgroundMusic::default(), 1.0)
};
let situation = Situation {
screen_music: (f[0] == "win").then(Music::credits),
in_world,
in_end: f[2] == "minecraft:the_end",
boss_music: f[4].contains("music"),
background: &background,
probe_volume,
creative: f[5] == "1" && f[6] == "1",
underwater: f[7] == "1",
};
let music = situation.music().map_or("-".to_owned(), |m| {
format!(
"{},{},{},{}",
normalize(&m.sound),
m.min_delay,
m.max_delay,
m.replace_current_music
)
});
writeln!(
out,
"{line} music={music} volume={:08x}",
situation.volume().to_bits()
)
.expect("write");
}
}
@@ -0,0 +1,108 @@
//! Replays `tools/test_sound_engine_parity.py` scripts through Horizon's
//! `SoundEngine` with a backend recording what an OpenAL source would hold:
//! `sound_engine_fixture ROOT INPUT OUTPUT`.
#[path = "audio_common/mod.rs"]
mod common;
use common::{Fake, d, describe, f, flush, result_name};
use horizon_client::sound_engine::{
Attenuation, Backend, InstanceId, SoundEngine, SoundInstance, TickContext,
};
use horizon_client::sound_events::SoundRegistry;
use horizon_core::random::LegacyRandom;
use std::collections::BTreeMap;
use std::io::Write;
fn main() {
let args: Vec<String> = std::env::args().collect();
let mut engine = SoundEngine::new(SoundRegistry::load(std::path::Path::new(&args[1])));
engine.listening = true;
let mut backend = Fake::default();
let mut ctx = TickContext::default();
let mut instances: BTreeMap<String, InstanceId> = BTreeMap::new();
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
for line in input.lines() {
writeln!(out, "{line}").expect("write");
let a: Vec<&str> = line.split(' ').collect();
let source = |i: usize| a[i].parse::<usize>().expect("source");
match a[0] {
"play" => {
let event = horizon_client::sound_events::normalize(a[3]);
let random = LegacyRandom::new(a[14].parse().expect("seed"));
let instance = match a[2] {
"simple" => SoundInstance::new(
&event,
source(4),
f(a[5]),
f(a[6]),
random,
a[10] == "1",
a[11].parse().expect("delay"),
if a[12] == "linear" {
Attenuation::Linear
} else {
Attenuation::None
},
[d(a[7]), d(a[8]), d(a[9])],
a[13] == "1",
),
"loop" => SoundInstance::biome_loop(&event, random),
"uloop" => SoundInstance::under_liquid_loop(&event, random),
"usub" => SoundInstance::under_liquid_sub(&event, random),
_ => panic!("{line}"),
};
if let Some(old) = instances.remove(a[1]) {
engine.release(old);
}
let (id, result) = engine.play_new(instance, true, &mut backend);
instances.insert(a[1].to_owned(), id);
writeln!(out, " result {}", result_name(result)).expect("write");
}
"replay" => {
let result = engine.play(instances[a[1]], &mut backend);
writeln!(out, " result {}", result_name(result)).expect("write");
}
"queue" => engine.queue_ticking(instances[a[1]]),
"delayed" => engine.play_delayed(instances[a[1]], a[2].parse().expect("delay")),
"fade" => {
let i = engine.instance_mut(instances[a[1]]).expect("instance");
if a[2] == "in" {
i.fade_in();
} else {
i.fade_out();
}
}
"stop" => engine.stop(instances[a[1]], &mut backend),
"stopmatch" => {
let sound = (a[1] != "-").then(|| horizon_client::sound_events::normalize(a[1]));
let src = (a[2] != "-").then(|| source(2));
engine.stop_matching(sound.as_deref(), src, &mut backend);
}
"finish" => {
if let Some(c) = engine.channel(instances[a[1]]) {
backend.stop(c);
}
}
"gain" => engine.update_category_volume(source(1), f(a[2]), &mut backend),
"volume" => engine.source_volumes[source(1)] = f(a[2]),
"refresh" => engine.refresh_category_volume(source(1), &mut backend),
"pause" => engine.pause_all_except(&[1, 10], &mut backend),
"resume" => engine.resume(&mut backend),
"player" => {
ctx.player_underwater = a[1] == "1";
ctx.player_removed = a[2] == "1";
}
"tick" => engine.tick(a[1] == "1", &ctx, &mut backend),
"forget" => {
if let Some(id) = instances.remove(a[1]) {
engine.release(id);
}
}
_ => panic!("{line}"),
}
for (name, id) in &instances {
writeln!(out, "{}", describe(&engine, &backend, name, *id)).expect("write");
}
flush(&mut engine, &mut out);
}
}
@@ -0,0 +1,78 @@
//! Resolves sound instances through Horizon's sound event registry for
//! `tools/test_sound_event_parity.py`: `sound_event_fixture ROOT INPUT OUTPUT [NAMES]`.
use horizon_client::sound_engine::{Attenuation, MASTER, SoundEngine, SoundInstance};
use horizon_client::sound_events::{Sentinel, SoundRegistry};
use horizon_core::random::{LegacyRandom, RandomSource};
use std::io::Write;
fn bits(v: f32) -> String {
format!("{:08x}", v.to_bits())
}
fn main() {
let args: Vec<String> = std::env::args().collect();
let registry = SoundRegistry::load(std::path::Path::new(&args[1]));
let mut engine = SoundEngine::new(registry);
let input = std::fs::read_to_string(&args[2]).expect("input");
let mut out = std::io::BufWriter::new(std::fs::File::create(&args[3]).expect("output"));
let mut names: Vec<String> = engine.registry.names().cloned().collect();
names.sort();
for name in names {
writeln!(out, "registered {name}").expect("write");
}
for line in input.lines() {
let f: Vec<&str> = line.split(' ').collect();
let seed: i64 = f[1].parse().expect("seed");
let volume = f32::from_bits(u32::from_str_radix(f[2], 16).expect("volume"));
let pitch = f32::from_bits(u32::from_str_radix(f[3], 16).expect("pitch"));
let event = horizon_client::sound_events::normalize(f[0]);
let instance = SoundInstance::new(
&event,
MASTER,
volume,
pitch,
LegacyRandom::new(seed),
false,
0,
Attenuation::Linear,
[0.0; 3],
false,
);
let id = engine.add(instance, true);
engine.resolve(id);
let weight = engine.registry.weight(&event);
let i = engine.instance_mut(id).expect("instance");
let mut s = line.to_owned();
match &i.event {
None => s.push_str(" event=none"),
Some(subtitle) => s.push_str(&format!(
" event weight={weight} subtitle={}",
subtitle.as_deref().unwrap_or("-")
)),
}
let v = i.get_volume();
let p = i.get_pitch();
let sound = i.sound.clone().expect("resolved");
let (namespace, path) = sound.path();
let sentinel = match sound.sentinel {
Sentinel::None => "-",
Sentinel::Empty => "empty",
Sentinel::IntentionallyEmpty => "intentionally_empty",
};
s.push_str(&format!(
" sound={} path={namespace}:{path} sentinel={sentinel} weight={} type={} stream={} preload={} attenuation={} volume={} pitch={} range={} next={}",
sound.location,
sound.weight,
if sound.event { "SOUND_EVENT" } else { "FILE" },
sound.stream,
sound.preload,
sound.attenuation_distance,
bits(v),
bits(p),
bits(sound.attenuation_distance(v)),
i.random.next_long()
));
writeln!(out, "{s}").expect("write");
engine.release(id);
}
}
@@ -0,0 +1,233 @@
//! Spawn placement and spawn predicate answers for differential tests against
//! vanilla (tools/test_spawn_rules_parity.py): reads the column-world cases of
//! `VanillaSpawnRulesOracle` and prints `mobs::spawn_rules`' answers.
//!
//! usage: spawn_rules_fixture RESOURCES INPUT OUTPUT
use horizon_client::{
assets::Assets,
blocks::BlockFacts,
items::Items,
mobs::{
MobTypes,
data::MobData,
spawn_rules::{self, Dimension, Reason, SpawnLevel},
},
};
use horizon_core::random::{LegacyRandom, RandomSource};
use horizon_world::{chunk::BlockState, registry::BlockStateId};
use std::{
collections::{BTreeMap, HashMap, HashSet},
fmt::Write as _,
fs,
path::Path,
};
struct Column<'a> {
facts: &'a BlockFacts,
data: &'a MobData,
block_tags: &'a HashMap<String, HashSet<String>>,
air: BlockStateId,
blocks: HashMap<i32, BlockStateId>,
x0: i32,
z0: i32,
sky: i32,
light: i32,
darken: i32,
thunder: bool,
sea: i32,
surface: i32,
seed: i64,
peaceful: bool,
dimension: Dimension,
biome: String,
slime: f32,
/// A player position and whether it is survival or adventure.
player: Option<([f64; 3], bool)>,
}
impl SpawnLevel for Column<'_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
if x != self.x0 || z != self.z0 {
return self.air;
}
self.blocks.get(&y).copied().unwrap_or(self.air)
}
fn facts(&self) -> &BlockFacts {
self.facts
}
fn mob_data(&self) -> &MobData {
self.data
}
fn block_tag(&self, tag: &str, block: &str) -> bool {
self.block_tags.get(tag).is_some_and(|s| s.contains(block))
}
fn biome_tag(&self, tag: &str, _: i32, _: i32, _: i32) -> bool {
self.data
.biome_tags
.get(tag)
.is_some_and(|s| s.contains(&self.biome))
}
fn sky_light(&self, _: i32, _: i32, _: i32) -> i32 {
self.sky
}
fn block_light(&self, _: i32, _: i32, _: i32) -> i32 {
self.light
}
fn sky_darken(&self) -> i32 {
self.darken
}
fn thundering(&self) -> bool {
self.thunder
}
fn sea_level(&self) -> i32 {
self.sea
}
fn world_surface(&self, _: i32, _: i32) -> i32 {
self.surface
}
fn peaceful(&self) -> bool {
self.peaceful
}
fn dimension(&self) -> Dimension {
self.dimension
}
fn surface_slime_chance(&self, _: i32, _: i32, _: i32) -> f32 {
self.slime
}
fn world_seed(&self) -> i64 {
self.seed
}
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
self.player.is_some_and(|(p, targetable)| {
let d = (p[0] - x).powi(2) + (p[1] - y).powi(2) + (p[2] - z).powi(2);
targetable && d < distance * distance
})
}
}
/// `name[key=value,...]`.
fn parse_state(spec: &str) -> BlockState {
let (name, props) = match spec.split_once('[') {
Some((n, rest)) => (n, rest.trim_end_matches(']')),
None => (spec, ""),
};
let properties: BTreeMap<String, String> = props
.split(',')
.filter(|p| !p.is_empty())
.filter_map(|p| p.split_once('='))
.map(|(k, v)| (k.to_owned(), v.to_owned()))
.collect();
BlockState {
name: name.to_owned(),
properties,
}
}
fn reason(name: &str) -> Reason {
match name {
"chunk_generation" => Reason::ChunkGeneration,
"spawner" => Reason::Spawner,
"trial_spawner" => Reason::TrialSpawner,
"reinforcement" => Reason::Reinforcement,
"patrol" => Reason::Patrol,
"structure" => Reason::Structure,
"event" => Reason::Event,
"command" => Reason::Command,
_ => Reason::Natural,
}
}
fn main() -> horizon_client::Result<()> {
let args: Vec<String> = std::env::args().collect();
let root = Path::new(
args.get(1)
.ok_or("usage: spawn_rules_fixture RESOURCES INPUT OUTPUT")?,
);
let input = fs::read_to_string(args.get(2).ok_or("missing INPUT")?)?;
let assets = Assets::load(root)?;
let registry = assets.registry;
let facts = BlockFacts::load(root, registry)?;
let data = MobData::load(root, &facts);
let items = Items::tags_only(root)?;
let types = MobTypes::load(root)?;
let mut out = String::new();
for raw in input.lines() {
if raw.trim().is_empty() {
continue;
}
let mut c = BTreeMap::new();
let mut name = "";
for token in raw.split_whitespace() {
match token.split_once('=') {
Some((k, v)) => {
c.insert(k, v);
}
None => name = token,
}
}
let get =
|k: &str, d: &'static str| -> String { c.get(k).copied().unwrap_or(d).to_owned() };
let Some(kind) = types.types.get(c["type"]) else {
let _ = writeln!(out, "{name} error unsupported {}", c["type"]);
continue;
};
let y: i32 = get("y", "64").parse()?;
let (x0, z0): (i32, i32) = (get("x", "8").parse()?, get("z", "8").parse()?);
let mut blocks = HashMap::new();
for (k, v) in &c {
if let Some(offset) = k.strip_prefix("at") {
let offset: i32 = offset.parse()?;
blocks.insert(y + offset, registry.resolve(&parse_state(v))?);
}
}
let player = c.get("player").map(|p| {
let f: Vec<&str> = p.split(',').collect();
let at = |i: usize, base: i32| f64::from(base) + f[i].parse::<f64>().unwrap_or(0.0);
let mode = f.get(3).copied().unwrap_or("survival");
(
[at(0, x0), at(1, y), at(2, z0)],
mode == "survival" || mode == "adventure",
)
});
let level = Column {
facts: &facts,
data: &data,
block_tags: &items.block_tags,
air: facts.air,
blocks,
x0,
z0,
sky: get("sky", "15").parse()?,
light: get("light", "0").parse()?,
darken: get("darken", "0").parse()?,
thunder: get("thunder", "0") == "1",
sea: get("sea", "63").parse()?,
surface: get("surface", "0").parse()?,
seed: get("wseed", "0").parse()?,
peaceful: get("difficulty", "normal") == "peaceful",
dimension: match get("dim", "minecraft:overworld").as_str() {
"minecraft:the_nether" => Dimension::Nether,
"minecraft:the_end" => Dimension::End,
_ => Dimension::Overworld,
},
biome: get("biome", "minecraft:plains"),
slime: get("slime", "0.0").parse()?,
player,
};
let mut random = LegacyRandom::new(get("seed", "0").parse()?);
let s = kind.species;
let ok = spawn_rules::is_spawn_position_ok(s, &level, x0, y, z0);
let rules = spawn_rules::check_spawn_rules(
s,
&level,
reason(&get("reason", "natural")),
&mut random,
x0,
y,
z0,
);
let next = random.next_int_bound(1000).unwrap_or(0);
let _ = writeln!(out, "{name} {ok} {rules} {next}");
}
fs::write(args.get(3).ok_or("missing OUTPUT")?, out)?;
Ok(())
}
@@ -0,0 +1,56 @@
#![forbid(unsafe_code)]
//! Runs tools/test_toggle_keys_parity.py's event sequences through
//! `ToggleKeys`, the sneak/sprint/attack/use toggle mappings.
use horizon_client::{
Result,
keymapping::{KeyAction, ToggleKeys},
};
use serde_json::Value;
use std::{
fs::File,
io::{BufRead, BufReader, BufWriter, Write},
};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().collect();
let input = BufReader::new(File::open(&args[1])?);
let mut out = BufWriter::new(File::create(&args[2])?);
for (n, line) in input.lines().enumerate() {
let c: Value = serde_json::from_str(&line?)?;
writeln!(out, "== {n}")?;
let mut keys = ToggleKeys::default();
let mut options = [false; 4];
for op in c["ops"].as_array().unwrap() {
let name = op[0].as_str().unwrap();
let slot = || op[1].as_u64().unwrap() as usize;
match name {
"option" => options[slot()] = op[2].as_bool().unwrap(),
"key" => {
let action = match op[2].as_i64().unwrap() {
0 => KeyAction::Release,
1 => KeyAction::Press,
_ => KeyAction::Repeat,
};
keys.key(slot(), action, op[3].as_bool().unwrap(), options[slot()]);
}
"button" => keys.button(
slot(),
op[2].as_bool().unwrap(),
op[3].as_bool().unwrap(),
options[slot()],
),
"open" => keys.screen_opened(options),
"close" => keys.screen_closed(options),
"respawn" => keys.respawn(),
other => panic!("{other}"),
}
let mut line = name.to_string();
for k in &keys.0 {
line += &format!(" {} {}", k.is_down, k.pending_clicks());
}
writeln!(out, "{line}")?;
}
}
out.flush()?;
Ok(())
}
+265 -253
View File
@@ -1,7 +1,9 @@
//! The player's ambient sound handlers: `BiomeAmbientSoundsHandler` (biome
//! loops with 40-tick cross-fades, random additions, cave mood) driven by the
//! `minecraft:audio/ambient_sounds` attribute, `UnderwaterAmbientSoundHandler`
//! and the underwater loop (`UnderLiquidAmbientSoundInstance`).
//! and `LocalPlayer.updateIsUnderwater` (enter/exit sounds and the
//! `UnderLiquidAmbientSoundInstance` loop), played through the sound engine.
use crate::sound_engine::{AMBIENT, InstanceId, PLAYERS, SoundEngine, SoundInstance, Sounds};
use horizon_core::random::{LegacyRandom, RandomSource};
use serde_json::Value;
use std::{collections::HashMap, fs, path::Path};
@@ -80,87 +82,83 @@ impl AmbientTables {
.unwrap_or_default()
}
}
/// The sound engine side of the handlers.
pub trait AmbientOutput {
/// A relative tickable instance; None when it could not start.
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64>;
fn set_volume(&mut self, id: u64, volume: f32);
fn stop(&mut self, id: u64);
fn is_active(&self, id: u64) -> bool;
/// A positional one-shot (`SimpleSoundInstance`, linear attenuation).
fn play_at(&mut self, event: &str, at: [f64; 3]);
}
impl AmbientOutput for crate::audio::Audio {
fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
crate::audio::Audio::play_tracked(self, event, volume, looping)
}
fn set_volume(&mut self, id: u64, volume: f32) {
self.set_tracked_volume(id, volume);
}
fn stop(&mut self, id: u64) {
self.stop_tracked(id);
}
fn is_active(&self, id: u64) -> bool {
self.tracked_active(id)
}
fn play_at(&mut self, event: &str, at: [f64; 3]) {
self.play(event, Some(at), 1.0, 1.0);
}
}
/// `BiomeAmbientSoundsHandler.LoopSoundInstance`.
struct LoopSound {
id: Option<u64>,
fade: i32,
direction: i32,
stopped: bool,
}
impl LoopSound {
fn tick(&mut self) -> f32 {
if self.fade < 0 {
self.stopped = true;
}
self.fade += self.direction;
(self.fade as f32 / 40.0).clamp(0.0, 1.0)
}
fn fade_out(&mut self) {
self.fade = self.fade.min(40);
self.direction = -1;
}
fn fade_in(&mut self) {
self.fade = self.fade.max(0);
self.direction = 1;
}
}
/// What the handlers read about the player and level each tick.
pub struct AmbientContext<'a> {
/// `AMBIENT_SOUNDS` at the player's position.
pub sounds: &'a AmbientSounds,
/// Feet and eye position.
/// The player's position (feet) and eye height.
pub position: [f64; 3],
pub eye_y: f64,
/// `isEyeInFluid(WATER)` this tick (`Entity.updateIsUnderwater`).
pub underwater: bool,
/// Raw (sky, block) light at a block.
pub spectator: bool,
/// `Level.getBrightness` (sky, block) at a block.
pub light: &'a dyn Fn([i32; 3]) -> (u8, u8),
/// The bubble column in the player's box (its `drag` property), from
/// `bubble_column_in`.
pub bubble_column: Option<bool>,
}
/// `BubbleColumnAmbientSoundHandler`'s lookup: the first bubble column
/// (x fastest, then y, then z) in the player's bounding box inflated by
/// (0, -0.4F, 0) and deflated by 1.0E-6, as `drag_down`. `state` gives the
/// bubble column at a block (None for anything else).
pub fn bubble_column_in(bb: [f64; 6], state: impl Fn([i32; 3]) -> Option<bool>) -> Option<bool> {
// AABB keeps min <= max on every construction.
let make = |a: [f64; 6]| {
[
a[0].min(a[3]),
a[1].min(a[4]),
a[2].min(a[5]),
a[0].max(a[3]),
a[1].max(a[4]),
a[2].max(a[5]),
]
};
let inflate = |b: [f64; 6], x: f64, y: f64, z: f64| {
make([b[0] - x, b[1] - y, b[2] - z, b[3] + x, b[4] + y, b[5] + z])
};
let b = inflate(make(bb), 0.0, f64::from(-0.4f32), 0.0);
let b = inflate(b, -1.0E-6, -1.0E-6, -1.0E-6);
let (x0, y0, z0) = (
b[0].floor() as i32,
b[1].floor() as i32,
b[2].floor() as i32,
);
let (x1, y1, z1) = (
b[3].floor() as i32,
b[4].floor() as i32,
b[5].floor() as i32,
);
for z in z0..=z1 {
for y in y0..=y1 {
for x in x0..=x1 {
if let Some(drag) = state([x, y, z]) {
return Some(drag);
}
}
}
}
None
}
/// The local player's ambient sound handlers.
pub struct Ambience {
/// The client level's random.
random: LegacyRandom,
loops: Vec<(String, LoopSound)>,
pub random: LegacyRandom,
/// `BiomeAmbientSoundsHandler.loopSounds` (insertion order).
loops: Vec<(String, InstanceId)>,
previous_loop: Option<String>,
pub moodiness: f32,
/// `UnderwaterAmbientSoundHandler.tickDelay`.
underwater_delay: i32,
/// The underwater loop instance and its fade.
underwater_loop: Option<(u64, i32)>,
/// UnderLiquidSubSound instances (stop when the player leaves the water).
sub_sounds: Vec<u64>,
/// `LocalPlayer.wasUnderwater`.
was_underwater: bool,
/// `BubbleColumnAmbientSoundHandler.wasInBubbleColumn` / `firstTick`.
was_in_bubble_column: bool,
first_tick: bool,
}
impl Default for Ambience {
fn default() -> Self {
let seed = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_nanos() as i64);
Self::new(seed)
Self::new(crate::sound_engine::unique_seed())
}
}
impl Ambience {
@@ -171,53 +169,95 @@ impl Ambience {
previous_loop: None,
moodiness: 0.0,
underwater_delay: 0,
underwater_loop: None,
sub_sounds: Vec::new(),
was_underwater: false,
was_in_bubble_column: false,
first_tick: true,
}
}
/// Stop everything (leaving the world).
pub fn clear(&mut self, out: &mut impl AmbientOutput) {
for (_, l) in self.loops.drain(..) {
if let Some(id) = l.id {
out.stop(id);
}
}
if let Some((id, _)) = self.underwater_loop.take() {
out.stop(id);
}
for id in self.sub_sounds.drain(..) {
out.stop(id);
/// `LocalPlayer.isUnderWater`.
pub fn was_underwater(&self) -> bool {
self.was_underwater
}
/// `BiomeAmbientSoundsHandler.loopSounds`.
pub fn loops(&self) -> &[(String, InstanceId)] {
&self.loops
}
/// `UnderwaterAmbientSoundHandler.tickDelay`.
pub fn underwater_delay(&self) -> i32 {
self.underwater_delay
}
/// A new player (leaving or changing worlds): fresh handlers.
pub fn clear(&mut self, sounds: &mut impl Sounds) {
for (_, id) in self.loops.drain(..) {
sounds.release(id);
}
self.previous_loop = None;
self.moodiness = 0.0;
self.underwater_delay = 0;
self.was_underwater = false;
self.was_in_bubble_column = false;
self.first_tick = true;
}
/// `LocalPlayer.updateIsUnderwater` enter/exit sounds and loop, then
/// `UnderwaterAmbientSoundHandler`, `BubbleColumn` (absent) and
/// `BiomeAmbientSoundsHandler` ticks, then the tickable instances.
pub fn tick(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
let feet = ctx.position;
if !self.was_underwater && ctx.underwater {
out.play_at("minecraft:ambient.underwater.enter", feet);
if let Some((id, _)) = self.underwater_loop.take() {
out.stop(id);
}
self.underwater_loop = out
.play_tracked("minecraft:ambient.underwater.loop", 0.0, true)
.map(|id| (id, 0));
}
if self.was_underwater && !ctx.underwater {
out.play_at("minecraft:ambient.underwater.exit", feet);
}
/// `ClientLevel.playLocalSound(x, y, z, sound, source, 1, 1, false)`.
fn play_local(&mut self, event: &str, source: usize, at: [f64; 3], sounds: &mut impl Sounds) {
let seed = self.random.next_long();
let instance = SoundInstance::at(event, source, 1.0, 1.0, LegacyRandom::new(seed), at);
sounds.play_instance(instance, false);
}
/// `LocalPlayer.updateIsUnderwater`, then the `ambientSoundHandlers`
/// (underwater, bubble column, biome) as `LocalPlayer.tick` runs them.
pub fn tick(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
let old = self.was_underwater;
self.was_underwater = ctx.underwater;
self.tick_underwater(ctx, out);
self.tick_biome(ctx, out);
self.tick_instances(ctx, out);
if !ctx.spectator {
if !old && ctx.underwater {
self.play_local(
"minecraft:ambient.underwater.enter",
AMBIENT,
ctx.position,
sounds,
);
let instance = SoundInstance::under_liquid_loop(
"minecraft:ambient.underwater.loop",
SoundEngine::unseeded(),
);
sounds.play_instance(instance, false);
}
if old && !ctx.underwater {
self.play_local(
"minecraft:ambient.underwater.exit",
AMBIENT,
ctx.position,
sounds,
);
}
}
self.tick_underwater(sounds);
self.tick_bubble_column(ctx, sounds);
self.tick_biome(ctx, sounds);
}
fn tick_underwater(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
/// `BubbleColumnAmbientSoundHandler.tick`: entering a column plays its
/// inside sound (`LocalPlayer.playSound`, on PLAYERS).
fn tick_bubble_column(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
if let Some(drag_down) = ctx.bubble_column {
if !self.was_in_bubble_column && !self.first_tick && !ctx.spectator {
let event = if drag_down {
"minecraft:block.bubble_column.whirlpool_inside"
} else {
"minecraft:block.bubble_column.upwards_inside"
};
self.play_local(event, PLAYERS, ctx.position, sounds);
}
self.was_in_bubble_column = true;
} else {
self.was_in_bubble_column = false;
}
self.first_tick = false;
}
/// `UnderwaterAmbientSoundHandler.tick`.
fn tick_underwater(&mut self, sounds: &mut impl Sounds) {
self.underwater_delay -= 1;
if self.underwater_delay <= 0 && ctx.underwater {
if self.underwater_delay <= 0 && self.was_underwater {
let rand = self.random.next_float();
let sound = if rand < 1.0E-4 {
Some("minecraft:ambient.underwater.loop.additions.ultra_rare")
@@ -230,147 +270,121 @@ impl Ambience {
};
if let Some(sound) = sound {
self.underwater_delay = 0;
if let Some(id) = out.play_tracked(sound, 1.0, false) {
self.sub_sounds.push(id);
}
let instance = SoundInstance::under_liquid_sub(sound, SoundEngine::unseeded());
sounds.play_instance(instance, false);
}
}
}
fn tick_biome(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
self.loops.retain(|(_, l)| !l.stopped);
let sounds = ctx.sounds;
if sounds.r#loop != self.previous_loop {
self.previous_loop = sounds.r#loop.clone();
for (_, l) in &mut self.loops {
l.fade_out();
/// `BiomeAmbientSoundsHandler.tick`.
fn tick_biome(&mut self, ctx: &AmbientContext, sounds: &mut impl Sounds) {
let mut kept = Vec::new();
for (event, id) in std::mem::take(&mut self.loops) {
if sounds.engine().instance(id).is_some_and(|i| i.stopped) {
sounds.release(id);
} else {
kept.push((event, id));
}
if let Some(event) = &sounds.r#loop {
if !self.loops.iter().any(|(e, _)| e == event) {
let id = out.play_tracked(event, 0.0, true);
self.loops.push((
event.clone(),
LoopSound {
id,
fade: 0,
direction: 0,
stopped: false,
},
));
}
self.loops = kept;
let current = ctx
.sounds
.r#loop
.as_deref()
.map(crate::sound_events::normalize);
if current != self.previous_loop {
self.previous_loop = current.clone();
for (_, id) in &self.loops {
if let Some(i) = sounds.engine().instance_mut(*id) {
i.fade_out();
}
if let Some((_, l)) = self.loops.iter_mut().find(|(e, _)| e == event) {
l.fade_in();
}
if let Some(event) = current {
let id = match self.loops.iter().find(|(e, _)| *e == event) {
Some((_, id)) => *id,
None => {
let instance = SoundInstance::biome_loop(&event, SoundEngine::unseeded());
let id = sounds.engine().add(instance, true);
sounds.play_id(id);
self.loops.push((event, id));
id
}
};
if let Some(i) = sounds.engine().instance_mut(id) {
i.fade_in();
}
}
}
for (sound, chance) in &sounds.additions {
if self.random.next_double() < *chance
&& let Some(id) = out.play_tracked(sound, 1.0, false)
{
// forAmbientAddition: a one-shot nobody ticks.
let _ = id;
for (sound, chance) in &ctx.sounds.additions {
if self.random.next_double() < *chance {
let event = crate::sound_events::normalize(sound);
let instance = SoundInstance::for_ambient_addition(&event, SoundEngine::unseeded());
sounds.play_instance(instance, false);
}
}
let Some(mood) = &sounds.mood else { return };
let Some(mood) = &ctx.sounds.mood else { return };
let extent = mood.block_search_extent;
let span = extent * 2 + 1;
let mut offset = || self.random.next_int_bound(span).unwrap_or(0) - extent;
let (dx, dy, dz) = (offset(), offset(), offset());
let sample = [
(ctx.position[0] + f64::from(dx)).floor() as i32,
(ctx.eye_y + f64::from(dy)).floor() as i32,
(ctx.position[2] + f64::from(dz)).floor() as i32,
];
let x = ctx.position[0] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
- f64::from(extent);
let y = ctx.eye_y + f64::from(self.random.next_int_bound(span).unwrap_or(0))
- f64::from(extent);
let z = ctx.position[2] + f64::from(self.random.next_int_bound(span).unwrap_or(0))
- f64::from(extent);
let sample = [x.floor() as i32, y.floor() as i32, z.floor() as i32];
let (sky, block) = (ctx.light)(sample);
if sky > 0 {
self.moodiness -= f32::from(sky) / 15.0 * 0.001;
} else {
self.moodiness -= (f32::from(block) - 1.0) / mood.tick_delay as f32;
self.moodiness -= (i32::from(block) - 1) as f32 / mood.tick_delay as f32;
}
if self.moodiness >= 1.0 {
let d = [
f64::from(sample[0]) + 0.5 - ctx.position[0],
f64::from(sample[1]) + 0.5 - ctx.eye_y,
f64::from(sample[2]) + 0.5 - ctx.position[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt();
let sample_x = f64::from(sample[0]) + 0.5;
let sample_y = f64::from(sample[1]) + 0.5;
let sample_z = f64::from(sample[2]) + 0.5;
let dx = sample_x - ctx.position[0];
let dy = sample_y - ctx.eye_y;
let dz = sample_z - ctx.position[2];
let distance = (dx * dx + dy * dy + dz * dz).sqrt();
let source = distance + mood.offset;
out.play_at(
&mood.sound,
[
ctx.position[0] + d[0] / distance * source,
ctx.eye_y + d[1] / distance * source,
ctx.position[2] + d[2] / distance * source,
],
);
let at = [
ctx.position[0] + dx / distance * source,
ctx.eye_y + dy / distance * source,
ctx.position[2] + dz / distance * source,
];
// The mood sound draws from the level random itself.
let event = crate::sound_events::normalize(&mood.sound);
let instance = SoundInstance::for_ambient_mood(&event, self.random.clone(), at);
let id = sounds.engine().add(instance, true);
sounds.play_id(id);
if let Some(i) = sounds.engine().instance(id) {
self.random = i.random.clone();
}
sounds.release(id);
self.moodiness = 0.0;
} else {
self.moodiness = self.moodiness.max(0.0);
}
}
/// The tickable instances' own ticks (SoundEngine.tickInGameSound).
fn tick_instances(&mut self, ctx: &AmbientContext, out: &mut impl AmbientOutput) {
for (_, l) in &mut self.loops {
let volume = l.tick();
if let Some(id) = l.id {
if l.stopped {
out.stop(id);
} else {
out.set_volume(id, volume);
}
}
}
// UnderLiquidAmbientSoundInstance: +1 per tick under water, -2 out of it.
if let Some((id, fade)) = self.underwater_loop.as_mut() {
if *fade >= 0 {
*fade += if ctx.underwater { 1 } else { -2 };
*fade = (*fade).min(40);
out.set_volume(*id, (*fade as f32 / 40.0).clamp(0.0, 1.0));
} else {
out.stop(*id);
self.underwater_loop = None;
}
}
// UnderLiquidSubSound: stops when the player leaves the water.
self.sub_sounds.retain(|&id| {
let keep = ctx.underwater && out.is_active(id);
if !keep {
out.stop(id);
}
keep
});
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sound_engine::{Bound, TickContext, test_backend::Null};
use crate::sound_events::SoundRegistry;
#[derive(Default)]
struct Fake {
next: u64,
live: HashMap<u64, (String, f32)>,
at: Vec<String>,
}
impl AmbientOutput for Fake {
fn play_tracked(&mut self, event: &str, volume: f32, _looping: bool) -> Option<u64> {
self.next += 1;
self.live.insert(self.next, (event.to_owned(), volume));
Some(self.next)
}
fn set_volume(&mut self, id: u64, volume: f32) {
if let Some(v) = self.live.get_mut(&id) {
v.1 = volume;
}
}
fn stop(&mut self, id: u64) {
self.live.remove(&id);
}
fn is_active(&self, id: u64) -> bool {
self.live.contains_key(&id)
}
fn play_at(&mut self, event: &str, _at: [f64; 3]) {
self.at.push(event.to_owned());
}
fn engine() -> SoundEngine {
let mut registry = SoundRegistry::default();
registry.add_file(
"minecraft",
br#"{"crimson": {"sounds": ["c"]}, "warped": {"sounds": ["w"]},
"ambient.cave": {"sounds": ["a", "b"]},
"ambient.underwater.loop": {"sounds": ["u"]},
"ambient.underwater.enter": {"sounds": ["e"]},
"ambient.underwater.exit": {"sounds": ["x"]}}"#,
&|_, _| true,
);
SoundEngine::new(registry)
}
fn ctx<'a>(
sounds: &'a AmbientSounds,
@@ -382,49 +396,59 @@ mod tests {
position: [0.5, 10.0, 0.5],
eye_y: 11.62,
underwater,
spectator: false,
light,
bubble_column: None,
}
}
#[test]
fn loops_cross_fade_over_forty_ticks() {
let (mut engine, mut backend) = (engine(), Null::default());
let mut a = Ambience::new(1);
let mut out = Fake::default();
let dark = |_: [i32; 3]| (15u8, 0u8);
let lit = |_: [i32; 3]| (15u8, 0u8);
let crimson = AmbientSounds {
r#loop: Some("crimson".into()),
r#loop: Some("minecraft:crimson".into()),
..Default::default()
};
for _ in 0..40 {
a.tick(&ctx(&crimson, false, &dark), &mut out);
}
assert_eq!(out.live.values().map(|v| v.1).collect::<Vec<_>>(), [1.0]);
let warped = AmbientSounds {
r#loop: Some("warped".into()),
r#loop: Some("minecraft:warped".into()),
..Default::default()
};
let tick =
|a: &mut Ambience, s: &AmbientSounds, engine: &mut SoundEngine, backend: &mut Null| {
a.tick(
&ctx(s, false, &lit),
&mut Bound {
engine: &mut *engine,
backend: &mut *backend,
},
);
engine.tick(false, &TickContext::default(), backend);
};
for _ in 0..40 {
tick(&mut a, &crimson, &mut engine, &mut backend);
}
for _ in 0..20 {
a.tick(&ctx(&warped, false, &dark), &mut out);
tick(&mut a, &warped, &mut engine, &mut backend);
}
let mut volumes: Vec<_> = out.live.values().map(|v| (v.0.clone(), v.1)).collect();
volumes.sort_by(|x, y| x.0.cmp(&y.0));
assert_eq!(
volumes,
[("crimson".to_owned(), 0.5), ("warped".to_owned(), 0.5)]
);
let volumes: Vec<f32> = a
.loops
.iter()
.map(|(_, id)| engine.instance(*id).unwrap().volume)
.collect();
assert_eq!(volumes, [0.5, 0.5]);
for _ in 0..30 {
a.tick(&ctx(&warped, false, &dark), &mut out);
tick(&mut a, &warped, &mut engine, &mut backend);
}
assert_eq!(
out.live.values().map(|v| v.0.as_str()).collect::<Vec<_>>(),
["warped"]
);
assert_eq!(a.loops.len(), 1);
}
#[test]
fn mood_builds_in_the_dark_and_plays_a_cave_sound() {
let (mut engine, mut backend) = (engine(), Null::default());
engine.listening = true;
let mut a = Ambience::new(2);
let mut out = Fake::default();
let cave = |_: [i32; 3]| (0u8, 0u8);
let sounds = AmbientSounds {
mood: Some(Mood {
@@ -435,31 +459,19 @@ mod tests {
}),
..Default::default()
};
// Block light 0: +1/6000 per tick (float), so the sound plays around tick 6000.
let mut ticks = 0;
while out.at.is_empty() && ticks < 7000 {
a.tick(&ctx(&sounds, false, &cave), &mut out);
while engine.notifications.is_empty() && ticks < 7000 {
a.tick(
&ctx(&sounds, false, &cave),
&mut Bound {
engine: &mut engine,
backend: &mut backend,
},
);
ticks += 1;
}
assert!((5990..=6010).contains(&ticks), "{ticks}");
assert_eq!(out.at, ["minecraft:ambient.cave"]);
assert_eq!(engine.notifications[0].identifier, "minecraft:ambient.cave");
assert_eq!(a.moodiness, 0.0);
}
#[test]
fn entering_water_plays_enter_and_fades_the_loop_in() {
let mut a = Ambience::new(3);
let mut out = Fake::default();
let lit = |_: [i32; 3]| (15u8, 0u8);
let none = AmbientSounds::default();
a.tick(&ctx(&none, true, &lit), &mut out);
assert_eq!(out.at, ["minecraft:ambient.underwater.enter"]);
let id = a.underwater_loop.unwrap().0;
assert_eq!(out.live[&id].1, 1.0 / 40.0);
a.tick(&ctx(&none, false, &lit), &mut out);
assert_eq!(out.at.last().unwrap(), "minecraft:ambient.underwater.exit");
// Fade 1 -> -1 stops on the next tick.
a.tick(&ctx(&none, false, &lit), &mut out);
assert!(!out.live.contains_key(&id));
}
}
+464
View File
@@ -0,0 +1,464 @@
//! Atlas sprite sources (`SpriteSourceList`): the `atlases/*.json`
//! definitions listing which sprites an atlas holds. `directory`, `single`,
//! `filter`, `unstitch` and `paletted_permutations` sources run in order
//! against the resource tree; a later source replaces an earlier sprite of
//! the same id and a filter removes the matching ones.
use crate::packs::pattern::Pattern;
use horizon_assets::palette::apply_palette;
use serde_json::Value;
use std::{
collections::BTreeMap,
fs,
path::{Path, PathBuf},
};
/// One region of an `unstitch` source, in units of the divisors.
#[derive(Clone, Debug, PartialEq)]
pub struct Region {
pub sprite: String,
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
#[derive(Clone, Debug)]
pub enum SpriteSource {
Directory {
source: String,
prefix: String,
},
Single {
resource: String,
sprite: Option<String>,
},
Filter {
namespace: Option<Pattern>,
path: Option<Pattern>,
},
Unstitch {
resource: String,
regions: Vec<Region>,
divisor_x: f64,
divisor_y: f64,
},
PalettedPermutations {
textures: Vec<String>,
palette_key: String,
permutations: BTreeMap<String, String>,
separator: String,
},
}
/// Where a listed sprite's pixels come from.
#[derive(Clone, Debug, PartialEq)]
pub enum SpriteOrigin {
/// A whole texture (`textures/<path>.png`), by texture identifier.
File(String),
/// `Unstitcher.RegionInstance`: part of a texture.
Region {
texture: String,
region: Region,
divisor_x: f64,
divisor_y: f64,
},
/// `PalettedSpriteSupplier`: a texture with its colours remapped.
Paletted {
texture: String,
palette_key: String,
palette: String,
},
}
/// `Identifier.parse` default namespace.
pub fn qualified(id: &str) -> String {
if id.contains(':') {
id.to_owned()
} else {
format!("minecraft:{id}")
}
}
fn string(v: &Value, key: &str) -> Option<String> {
v.get(key)?.as_str().map(str::to_owned)
}
fn number(v: &Value, key: &str, default: f64) -> Option<f64> {
match v.get(key) {
None => Some(default),
Some(n) => n.as_f64(),
}
}
/// `SpriteSources.FILE_CODEC`: the `sources` list of an atlas definition.
/// A malformed source fails the whole file, as the codec does.
pub fn decode(text: &str) -> Option<Vec<SpriteSource>> {
let json: Value = serde_json::from_str(text).ok()?;
json.get("sources")?
.as_array()?
.iter()
.map(decode_source)
.collect()
}
fn decode_source(s: &Value) -> Option<SpriteSource> {
Some(match qualified(s.get("type")?.as_str()?).as_str() {
"minecraft:directory" => SpriteSource::Directory {
source: string(s, "source")?,
prefix: string(s, "prefix")?,
},
"minecraft:single" => SpriteSource::Single {
resource: qualified(&string(s, "resource")?),
sprite: match s.get("sprite") {
None => None,
Some(v) => Some(qualified(v.as_str()?)),
},
},
"minecraft:filter" => {
let p = s.get("pattern")?;
let pattern = |key: &str| -> Option<Option<Pattern>> {
match p.get(key) {
None => Some(None),
Some(v) => Pattern::new(v.as_str()?).map(Some),
}
};
SpriteSource::Filter {
namespace: pattern("namespace")?,
path: pattern("path")?,
}
}
"minecraft:unstitch" => {
let regions = s
.get("regions")?
.as_array()?
.iter()
.map(|r| {
Some(Region {
sprite: qualified(&string(r, "sprite")?),
x: r.get("x")?.as_f64()?,
y: r.get("y")?.as_f64()?,
width: r.get("width")?.as_f64()?,
height: r.get("height")?.as_f64()?,
})
})
.collect::<Option<Vec<_>>>()?;
if regions.is_empty() {
return None;
}
SpriteSource::Unstitch {
resource: qualified(&string(s, "resource")?),
regions,
divisor_x: number(s, "divisor_x", 1.0)?,
divisor_y: number(s, "divisor_y", 1.0)?,
}
}
"minecraft:paletted_permutations" => SpriteSource::PalettedPermutations {
textures: s
.get("textures")?
.as_array()?
.iter()
.map(|t| t.as_str().map(qualified))
.collect::<Option<_>>()?,
palette_key: qualified(&string(s, "palette_key")?),
permutations: s
.get("permutations")?
.as_object()?
.iter()
.map(|(k, v)| Some((k.clone(), qualified(v.as_str()?))))
.collect::<Option<_>>()?,
separator: match s.get("separator") {
None => "_".to_owned(),
Some(v) => v.as_str()?.to_owned(),
},
},
_ => return None,
})
}
/// The texture files of a resource tree: `namespace:path` for every
/// `assets/<namespace>/textures/<path>.png`.
pub fn list_textures(root: &Path) -> Vec<String> {
let mut out = Vec::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let base = ns.path().join("textures");
let mut stack = vec![base.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = fs::read_dir(&dir) else {
continue;
};
for e in entries.flatten() {
let p = e.path();
if p.is_dir() {
stack.push(p);
} else if p.extension().is_some_and(|x| x == "png")
&& let Ok(rel) = p.strip_prefix(&base)
{
let rel = rel.with_extension("").to_string_lossy().replace('\\', "/");
out.push(format!("{namespace}:{rel}"));
}
}
}
}
out.sort();
out
}
/// The file of a texture identifier.
pub fn texture_path(root: &Path, texture: &str, directory: &str) -> PathBuf {
let (namespace, path) = texture.split_once(':').unwrap_or(("minecraft", texture));
root.join(format!("assets/{namespace}/{directory}/{path}.png"))
}
/// `SpriteSourceList.list`: every sprite id of the atlas and its origin.
/// `textures` are the available texture identifiers ([`list_textures`]).
pub fn list(sources: &[SpriteSource], textures: &[String]) -> BTreeMap<String, SpriteOrigin> {
let mut out = BTreeMap::new();
let exists = |t: &str| textures.binary_search_by(|x| x.as_str().cmp(t)).is_ok();
for source in sources {
match source {
SpriteSource::Directory { source, prefix } => {
// FileToIdConverter("textures/" + source, ".png").
let dir = format!("{source}/");
for t in textures {
let (namespace, path) = t.split_once(':').unwrap_or(("minecraft", t));
if let Some(rest) = path.strip_prefix(&dir) {
out.insert(
format!("{namespace}:{prefix}{rest}"),
SpriteOrigin::File(t.clone()),
);
}
}
}
SpriteSource::Single { resource, sprite } => {
if exists(resource) {
out.insert(
sprite.clone().unwrap_or_else(|| resource.clone()),
SpriteOrigin::File(resource.clone()),
);
}
}
SpriteSource::Filter { namespace, path } => {
out.retain(|id: &String, _| {
let (ns, p) = id.split_once(':').unwrap_or(("minecraft", id));
!(namespace.as_ref().is_none_or(|m| m.is_match(ns))
&& path.as_ref().is_none_or(|m| m.is_match(p)))
});
}
SpriteSource::Unstitch {
resource,
regions,
divisor_x,
divisor_y,
} => {
if exists(resource) {
for region in regions {
out.insert(
region.sprite.clone(),
SpriteOrigin::Region {
texture: resource.clone(),
region: region.clone(),
divisor_x: *divisor_x,
divisor_y: *divisor_y,
},
);
}
}
}
SpriteSource::PalettedPermutations {
textures: bases,
palette_key,
permutations,
separator,
} => {
for base in bases {
if !exists(base) {
continue;
}
for (suffix, palette) in permutations {
out.insert(
format!("{base}{separator}{suffix}"),
SpriteOrigin::Paletted {
texture: base.clone(),
palette_key: palette_key.clone(),
palette: palette.clone(),
},
);
}
}
}
}
}
out
}
/// An RGBA image.
pub type Image = (u32, u32, Vec<u8>);
fn argb(rgba: &[u8]) -> Vec<u32> {
rgba.as_chunks::<4>()
.0
.iter()
.map(|p| u32::from_be_bytes([p[3], p[0], p[1], p[2]]))
.collect()
}
/// The pixels of a generated sprite (`Region` or `Paletted`); `None` for a
/// plain file, which loads as itself.
pub fn generate(
origin: &SpriteOrigin,
load: &dyn Fn(&str, &str) -> Option<Image>,
) -> Option<Image> {
match origin {
SpriteOrigin::File(_) => None,
SpriteOrigin::Region {
texture,
region,
divisor_x,
divisor_y,
} => {
let (w, h, pixels) = load(texture, "textures")?;
let x_scale = f64::from(w) / divisor_x;
let y_scale = f64::from(h) / divisor_y;
let x = (region.x * x_scale).floor() as i64;
let y = (region.y * y_scale).floor() as i64;
let width = (region.width * x_scale).floor() as i64;
let height = (region.height * y_scale).floor() as i64;
if width <= 0 || height <= 0 {
return None;
}
let mut out = vec![0u8; (width * height * 4) as usize];
for row in 0..height {
for col in 0..width {
let (sx, sy) = (x + col, y + row);
if sx < 0 || sy < 0 || sx >= i64::from(w) || sy >= i64::from(h) {
continue;
}
let s = ((sy * i64::from(w) + sx) * 4) as usize;
let d = ((row * width + col) * 4) as usize;
out[d..d + 4].copy_from_slice(&pixels[s..s + 4]);
}
}
Some((width as u32, height as u32, out))
}
SpriteOrigin::Paletted {
texture,
palette_key,
palette,
} => {
let (w, h, pixels) = load(texture, "textures")?;
let (_, _, key) = load(palette_key, "textures/palettes")?;
let (_, _, target) = load(palette, "textures/palettes")?;
let mut mapped = argb(&pixels);
apply_palette(&mut mapped, &argb(&key), &argb(&target))?;
let out = mapped
.iter()
.flat_map(|p| {
let [a, r, g, b] = p.to_be_bytes();
[r, g, b, a]
})
.collect();
Some((w, h, out))
}
}
}
/// Every atlas definition of a resource tree (`atlases/*.json` in every
/// namespace), by atlas id.
pub fn atlases(root: &Path) -> BTreeMap<String, Vec<SpriteSource>> {
let mut out = BTreeMap::new();
let Ok(namespaces) = fs::read_dir(root.join("assets")) else {
return out;
};
for ns in namespaces.flatten() {
let namespace = ns.file_name().to_string_lossy().into_owned();
let Ok(files) = fs::read_dir(ns.path().join("atlases")) else {
continue;
};
for f in files.flatten() {
let path = f.path();
if path.extension().is_none_or(|e| e != "json") {
continue;
}
let name = path.file_stem().unwrap_or_default().to_string_lossy();
if let Some(sources) = fs::read_to_string(&path).ok().and_then(|t| decode(&t)) {
out.insert(format!("{namespace}:{name}"), sources);
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn textures() -> Vec<String> {
let mut t: Vec<String> = [
"minecraft:item/apple",
"minecraft:item/stick",
"minecraft:trims/items/boots_trim",
"minecraft:block/stone",
"minecraft:entity/sheet",
]
.iter()
.map(|s| s.to_string())
.collect();
t.sort();
t
}
#[test]
fn sources_run_in_order() {
let sources = decode(
r#"{"sources":[
{"type":"directory","source":"item","prefix":"item/"},
{"type":"minecraft:single","resource":"block/stone","sprite":"item/stone"},
{"type":"filter","pattern":{"path":"stick"}},
{"type":"unstitch","resource":"entity/sheet","divisor_x":2,"regions":[{"sprite":"a","x":1,"y":0,"width":1,"height":1}]},
{"type":"paletted_permutations","textures":["trims/items/boots_trim"],"palette_key":"trims/color_palettes/trim_palette","permutations":{"quartz":"trims/color_palettes/quartz"}}
]}"#,
)
.unwrap();
let listed = list(&sources, &textures());
let ids: Vec<&str> = listed.keys().map(String::as_str).collect();
assert_eq!(
ids,
[
"minecraft:a",
"minecraft:item/apple",
"minecraft:item/stone",
"minecraft:trims/items/boots_trim_quartz"
]
);
assert_eq!(
listed["minecraft:item/stone"],
SpriteOrigin::File("minecraft:block/stone".into())
);
}
#[test]
fn malformed_sources_fail_the_file() {
assert!(decode(r#"{"sources":[{"type":"directory","source":"item"}]}"#).is_none());
assert!(
decode(r#"{"sources":[{"type":"unstitch","resource":"x","regions":[]}]}"#).is_none()
);
assert!(decode(r#"{"sources":[{"type":"horizon"}]}"#).is_none());
}
#[test]
fn unstitch_cuts_by_divisor() {
let pixels: Vec<u8> = (0..16u8).flat_map(|i| [i, i, i, 255]).collect();
let load = |_: &str, _: &str| Some((4u32, 4u32, pixels.clone()));
let origin = SpriteOrigin::Region {
texture: "t".into(),
region: Region {
sprite: "s".into(),
x: 1.0,
y: 1.0,
width: 1.0,
height: 1.0,
},
divisor_x: 2.0,
divisor_y: 2.0,
};
let (w, h, out) = generate(&origin, &load).unwrap();
assert_eq!((w, h), (2, 2));
assert_eq!(out[0], 10);
assert_eq!(out[4], 11);
assert_eq!(out[8], 14);
}
}
@@ -0,0 +1,106 @@
//! The player's attack strength (`Player.attackStrengthTicker`,
//! `itemSwapTicker`, `getAttackStrengthScale`, `getCurrentItemAttackStrengthDelay`):
//! the charge the attack indicator shows and attacks scale their damage by.
/// `Attributes.ATTACK_SPEED` base value.
pub const BASE_ATTACK_SPEED: f64 = 4.0;
#[derive(Clone, Debug, Default, PartialEq)]
pub struct AttackStrength {
/// `LivingEntity.attackStrengthTicker`.
pub ticker: i32,
/// `LivingEntity.itemSwapTicker`.
pub swap_ticker: i32,
/// `Player.lastItemInMainHand`: the stack as last compared.
last_item: Option<HeldStack>,
}
/// What `ItemStack.matches` / `isSameItem` compare of the main-hand stack:
/// its item id and the rest of the stack (count and components) as a key.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct HeldStack {
pub item: String,
pub rest: String,
}
/// `Player.getCurrentItemAttackStrengthDelay` for an ATTACK_SPEED value.
pub fn delay(attack_speed: f64) -> f32 {
(1.0 / attack_speed * 20.0) as f32
}
impl AttackStrength {
/// The attack-strength part of `Player.tick`: both tickers advance, and a
/// main-hand change to a different item resets them
/// (`!ItemStack.matches` and `!ItemStack.isSameItem`).
pub fn tick(&mut self, main_hand: Option<HeldStack>) {
self.ticker += 1;
self.swap_ticker += 1;
if self.last_item != main_hand {
let same_item = match (&self.last_item, &main_hand) {
(Some(a), Some(b)) => a.item == b.item,
(None, None) => true,
_ => false,
};
if !same_item {
self.reset();
}
self.last_item = main_hand;
}
}
/// `resetAttackStrengthTicker`: swinging at nothing, attacking or
/// stopping a block break.
pub fn reset(&mut self) {
self.ticker = 0;
self.swap_ticker = 0;
}
/// `resetOnlyAttackStrengthTicker` (`Player.onAttack`).
pub fn reset_attack(&mut self) {
self.ticker = 0;
}
/// `getAttackStrengthScale(a)` with the ATTACK_SPEED attribute value.
pub fn scale(&self, a: f32, attack_speed: f64) -> f32 {
((self.ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
}
/// `getItemSwapScale(a)`.
pub fn swap_scale(&self, a: f32, attack_speed: f64) -> f32 {
((self.swap_ticker as f32 + a) / delay(attack_speed)).clamp(0.0, 1.0)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn held(item: &str, rest: &str) -> Option<HeldStack> {
Some(HeldStack {
item: item.into(),
rest: rest.into(),
})
}
#[test]
fn charges_over_the_attack_delay() {
let mut s = AttackStrength::default();
// A sword's 1.6 attacks per second: 12.5 ticks to full strength.
assert_eq!(delay(1.6), 12.5);
for _ in 0..6 {
s.tick(None);
}
assert_eq!(s.scale(0.25, 1.6), 6.25 / 12.5);
for _ in 0..10 {
s.tick(None);
}
assert_eq!(s.scale(0.0, 1.6), 1.0);
}
#[test]
fn switching_items_resets_but_restacking_does_not() {
let mut s = AttackStrength::default();
s.tick(held("minecraft:stone", "64"));
assert_eq!(s.ticker, 0, "picking up an item resets");
s.tick(held("minecraft:stone", "63"));
assert_eq!(
s.ticker, 1,
"the same item with another count keeps charging"
);
s.tick(held("minecraft:diamond_sword", ""));
assert_eq!((s.ticker, s.swap_ticker), (0, 0));
s.tick(held("minecraft:diamond_sword", ""));
s.reset_attack();
assert_eq!((s.ticker, s.swap_ticker), (0, 1));
}
}
+452 -436
View File
@@ -1,9 +1,16 @@
//! Sound playback: `sounds.json` events with weighted variants, Ogg/Vorbis
//! decoding, and a small mixer with vanilla's linear distance attenuation
//! (`SoundInstance.Attenuation.LINEAR`, default range 16 blocks) and stereo panning.
//! Sound playback: vanilla's `SoundEngine` bookkeeping (`sound_engine`) over a
//! small mixer standing in for OpenAL: one channel per source with its gain,
//! pitch, linear distance attenuation (`AL_LINEAR_DISTANCE`, reference 0,
//! rolloff 1), looping, relative flag and source state, Ogg/Vorbis decoding
//! (streams on a worker thread) and stereo panning.
use crate::Result;
use crate::sound_engine::{
Attenuation, Backend, Bound, ChannelId, InstanceId, PlayResult, SoundEngine, SoundInstance,
Sounds, TickContext,
};
use crate::sound_events::{Sound, SoundRegistry};
use cpal::traits::{DeviceTrait, HostTrait, StreamTrait};
use serde_json::Value;
use horizon_core::random::{LegacyRandom, RandomSource};
use std::{
collections::HashMap,
fs,
@@ -11,46 +18,32 @@ use std::{
sync::{Arc, Mutex},
};
#[derive(Clone, Debug)]
struct Variant {
name: String,
volume: f32,
pitch: f32,
weight: u32,
attenuation: f32,
is_event: bool,
/// An OpenAL source's state.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
enum State {
Initial,
Playing,
Paused,
Stopped,
}
struct Voice {
samples: Arc<Vec<f32>>,
/// An OpenAL source.
struct Channel {
samples: Option<(Arc<Vec<f32>>, u32)>,
position: f64,
step: f64,
left: f32,
right: f32,
/// The sound event and `SoundSource` index (what `/stopsound` matches).
event: Arc<str>,
source: usize,
state: State,
gain: f32,
pitch: f32,
/// `AL_LINEAR_DISTANCE` max distance, or no attenuation.
linear: Option<f32>,
looping: bool,
at: [f32; 3],
relative: bool,
}
#[derive(Default)]
struct Mixer {
voices: Vec<Voice>,
/// The streaming music track (`SoundSource.MUSIC`), kept apart from the
/// voice cap and scaled by `music_gain` (MusicManager's fade).
music: Option<Voice>,
music_gain: f32,
/// Tickable sound instances the game keeps a handle to (ambient loops).
tracked: Vec<Tracked>,
}
struct Tracked {
id: u64,
voice: Voice,
looping: bool,
volume: f32,
}
/// The current music track: decoding on a worker until the voice exists.
struct MusicTrack {
event: String,
/// Set by the decoder thread once the voice is in the mixer (or failed).
ready: Arc<std::sync::atomic::AtomicBool>,
channels: HashMap<ChannelId, Channel>,
listener: [f64; 3],
listener_yaw: f32,
}
/// A sound the player could have heard (`SoundEventListener.onPlaySound`):
/// what the subtitle overlay needs.
@@ -58,30 +51,31 @@ struct MusicTrack {
pub struct Heard {
/// The `subtitle` translation key of the sound event.
pub subtitle: String,
/// Where it played (None = relative to the listener, e.g. UI sounds).
/// The instance's position (relative sounds report their raw coordinates).
pub at: Option<[f64; 3]>,
/// `SoundEngine.getRange`: infinite for relative and non-attenuated sounds.
pub range: f32,
pub looping: bool,
}
/// The mixer side of the engine (`Backend`).
pub struct MixerBackend {
mixer: Arc<Mutex<Mixer>>,
root: PathBuf,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
next: ChannelId,
live: bool,
}
pub struct Audio {
_stream: Option<cpal::Stream>,
/// `subtitle` keys of `sounds.json` events.
subtitles: HashMap<String, String>,
pub engine: SoundEngine,
backend: MixerBackend,
/// Record `Heard` sounds (the subtitle overlay is listening).
pub record_heard: bool,
heard: Vec<Heard>,
mixer: Arc<Mutex<Mixer>>,
rate: u32,
root: PathBuf,
events: HashMap<String, Vec<Variant>>,
cache: HashMap<String, Option<(Arc<Vec<f32>>, u32)>>,
seed: u64,
pub listener: [f64; 3],
pub listener_yaw: f32,
pub master: f32,
music: Option<MusicTrack>,
next_tracked: u64,
/// Seeds of positional plays (the client level random's `nextLong`).
random: LegacyRandom,
/// A `SoundSource` index the next sound plays on (`/playsound`).
source_override: Option<usize>,
/// Positional sounds played since the last drain, kept while the world
@@ -130,75 +124,247 @@ fn decode(path: &Path) -> Option<(Vec<f32>, u32)> {
}
Some((mono, rate))
}
impl Audio {
pub fn new(root: &Path) -> Self {
let mut events = HashMap::new();
let mut subtitles = HashMap::new();
if let Ok(text) = fs::read(root.join("assets/minecraft/sounds.json"))
&& let Ok(Value::Object(map)) = serde_json::from_slice::<Value>(&text)
impl MixerBackend {
fn with(&self, channel: ChannelId, f: impl FnOnce(&mut Channel)) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
for (name, def) in map {
let variants = def["sounds"]
.as_array()
.into_iter()
.flatten()
.map(|s| match s {
Value::String(n) => Variant {
name: n.clone(),
volume: 1.0,
pitch: 1.0,
weight: 1,
attenuation: 16.0,
is_event: false,
},
_ => Variant {
name: s["name"].as_str().unwrap_or("").to_owned(),
volume: s["volume"].as_f64().unwrap_or(1.0) as f32,
pitch: s["pitch"].as_f64().unwrap_or(1.0) as f32,
weight: s["weight"].as_u64().unwrap_or(1) as u32,
attenuation: s["attenuation_distance"].as_f64().unwrap_or(16.0) as f32,
is_event: s["type"].as_str() == Some("event"),
},
})
.collect::<Vec<_>>();
if let Some(key) = def["subtitle"].as_str() {
subtitles.insert(format!("minecraft:{name}"), key.to_owned());
}
events.insert(format!("minecraft:{name}"), variants);
}
f(c);
}
let mixer = Arc::new(Mutex::new(Mixer {
music_gain: 1.0,
..Mixer::default()
}));
let (stream, rate) = match Self::open(mixer.clone()) {
Ok(v) => (Some(v.0), v.1),
Err(e) => {
eprintln!("Horizon: audio unavailable: {e}");
(None, 48_000)
}
}
impl Backend for MixerBackend {
fn create(&mut self, _streaming: bool) -> Option<ChannelId> {
self.next += 1;
let id = self.next;
self.mixer.lock().ok()?.channels.insert(
id,
Channel {
samples: None,
position: 0.0,
state: State::Initial,
gain: 1.0,
pitch: 1.0,
linear: None,
looping: false,
at: [0.0; 3],
relative: false,
},
);
Some(id)
}
fn destroy(&mut self, channel: ChannelId) {
if let Ok(mut m) = self.mixer.lock() {
m.channels.remove(&channel);
}
}
fn set_pitch(&mut self, channel: ChannelId, pitch: f32) {
self.with(channel, |c| c.pitch = pitch);
}
fn set_volume(&mut self, channel: ChannelId, volume: f32) {
self.with(channel, |c| c.gain = volume);
}
fn set_attenuation(&mut self, channel: ChannelId, linear: Option<f32>) {
self.with(channel, |c| c.linear = linear);
}
fn set_looping(&mut self, channel: ChannelId, looping: bool) {
self.with(channel, |c| c.looping = looping);
}
fn set_position(&mut self, channel: ChannelId, p: [f64; 3]) {
self.with(channel, |c| c.at = [p[0] as f32, p[1] as f32, p[2] as f32]);
}
fn set_relative(&mut self, channel: ChannelId, relative: bool) {
self.with(channel, |c| c.relative = relative);
}
/// `getCompleteBuffer` (decoded now, cached) or `getStream` (decoded on a
/// worker), then attach and `alSourcePlay`. A sound that cannot load
/// leaves its source initial, as a failed buffer future does in vanilla.
fn start(&mut self, channel: ChannelId, sound: &Sound, looping: bool) {
if !self.live {
return;
}
let (namespace, path) = sound.path();
let file = self.root.join("assets").join(namespace).join(&path);
let attach = move |mixer: &Mutex<Mixer>, samples: Option<(Arc<Vec<f32>>, u32)>| {
if let Some(samples) = samples
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.samples = Some(samples);
c.position = 0.0;
c.state = State::Playing;
}
};
if sound.stream {
// A streamed sound loops by restarting its stream.
let mixer = self.mixer.clone();
std::thread::spawn(move || {
let samples = decode(&file).map(|(s, r)| (Arc::new(s), r));
if looping
&& let Ok(mut m) = mixer.lock()
&& let Some(c) = m.channels.get_mut(&channel)
{
c.looping = true;
}
attach(&mixer, samples);
});
} else {
let samples = self
.cache
.entry(format!("{namespace}:{path}"))
.or_insert_with(|| decode(&file).map(|(s, r)| (Arc::new(s), r)))
.clone();
attach(&self.mixer, samples);
}
}
fn stop(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state != State::Initial {
c.state = State::Stopped;
}
});
}
fn pause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state == State::Playing {
c.state = State::Paused;
}
});
}
fn unpause(&mut self, channel: ChannelId) {
self.with(channel, |c| {
if c.state == State::Paused {
c.state = State::Playing;
}
});
}
fn stopped(&mut self, channel: ChannelId) -> bool {
self.mixer
.lock()
.map(|m| {
m.channels
.get(&channel)
.is_none_or(|c| c.state == State::Stopped)
})
.unwrap_or(true)
}
}
/// The gain OpenAL applies for distance (`AL_LINEAR_DISTANCE` with reference
/// distance 0 and rolloff 1: the distance clamps to the max distance).
fn distance_gain(linear: Option<f32>, distance: f32) -> f32 {
match linear {
None => 1.0,
Some(max) if max > 0.0 => (1.0 - distance.min(max) / max).clamp(0.0, 1.0),
Some(_) => 0.0,
}
}
fn mix(mixer: &mut Mixer, data: &mut [f32], channels: usize, rate: u32) {
data.fill(0.0);
let listener = mixer.listener;
let yaw = mixer.listener_yaw.to_radians();
// Gains and pans per source for this buffer.
let mut voices: Vec<(ChannelId, f32, f32, f64)> = Vec::new();
for (id, c) in &mixer.channels {
let Some((_, source_rate)) = &c.samples else {
continue;
};
if c.state != State::Playing {
continue;
}
let d = if c.relative {
[f64::from(c.at[0]), f64::from(c.at[1]), f64::from(c.at[2])]
} else {
[
f64::from(c.at[0]) - listener[0],
f64::from(c.at[1]) - listener[1],
f64::from(c.at[2]) - listener[2],
]
};
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let gain = c.gain.clamp(0.0, 1.0) * distance_gain(c.linear, distance);
// The listener's right vector for a Minecraft yaw.
let side = if c.relative {
(d[0] as f32) / distance.max(0.001)
} else {
(-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32 / distance.max(0.001)
};
let pan = side.clamp(-1.0, 1.0);
let left = gain * (1.0 - pan.max(0.0) * 0.6);
let right = gain * (1.0 + pan.min(0.0) * 0.6);
let step = f64::from(*source_rate) / f64::from(rate) * f64::from(c.pitch.max(0.0));
voices.push((*id, left, right, step));
}
for (id, left, right, step) in voices {
let Some(c) = mixer.channels.get_mut(&id) else {
continue;
};
let Some((samples, _)) = c.samples.clone() else {
continue;
};
for frame in data.chunks_mut(channels) {
let mut i = c.position as usize;
if i + 1 >= samples.len() {
if c.looping && samples.len() > 1 {
c.position = 0.0;
i = 0;
} else {
c.state = State::Stopped;
break;
}
}
let t = (c.position - i as f64) as f32;
let s = samples[i] * (1.0 - t) + samples[i + 1] * t;
if channels >= 2 {
frame[0] += s * left;
frame[1] += s * right;
} else {
frame[0] += s * (left + right) / 2.0;
}
c.position += step;
}
}
for s in data.iter_mut() {
*s = s.clamp(-1.0, 1.0);
}
}
impl Audio {
pub fn new(root: &Path) -> Self {
let registry = SoundRegistry::load(root);
let mixer = Arc::new(Mutex::new(Mixer::default()));
let (stream, live) = match Self::open(mixer.clone()) {
Ok(v) => (Some(v), true),
Err(e) => {
eprintln!("Horizon: audio unavailable: {e}");
(None, false)
}
};
let mut engine = SoundEngine::new(registry);
engine.loaded = live;
Self {
_stream: stream,
subtitles,
engine,
backend: MixerBackend {
mixer,
root: root.to_owned(),
cache: HashMap::new(),
next: 0,
live,
},
record_heard: false,
heard: Vec::new(),
mixer,
rate,
root: root.to_owned(),
events,
cache: HashMap::new(),
seed: 0x5DEE_CE66,
listener: [0.0; 3],
listener_yaw: 0.0,
master: 1.0,
music: None,
next_tracked: 0,
random: LegacyRandom::new(crate::sound_engine::unique_seed()),
source_override: None,
capture: None,
}
}
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<(cpal::Stream, u32)> {
fn open(mixer: Arc<Mutex<Mixer>>) -> Result<cpal::Stream> {
// Test runs (harnesses, agents) launch the game on a virtual screen;
// keep them off the user's speakers.
if std::env::var_os("HORIZON_NO_AUDIO").is_some() {
return Err("audio output disabled by HORIZON_NO_AUDIO".into());
}
let host = cpal::default_host();
let device = host
.default_output_device()
@@ -209,269 +375,110 @@ impl Audio {
let stream = device.build_output_stream(
&config.into(),
move |data: &mut [f32], _| {
data.fill(0.0);
let Ok(mut mixer) = mixer.lock() else { return };
for frame in data.chunks_mut(channels) {
let (mut l, mut r) = (0.0f32, 0.0f32);
for v in mixer.voices.iter_mut() {
let i = v.position as usize;
if i + 1 >= v.samples.len() {
continue;
}
let t = (v.position - i as f64) as f32;
let s = v.samples[i] * (1.0 - t) + v.samples[i + 1] * t;
l += s * v.left;
r += s * v.right;
v.position += v.step;
}
for t in mixer.tracked.iter_mut() {
let v = &mut t.voice;
let mut i = v.position as usize;
if t.looping && i + 1 >= v.samples.len() {
v.position = 0.0;
i = 0;
}
if i + 1 < v.samples.len() {
let f = (v.position - i as f64) as f32;
let s = (v.samples[i] * (1.0 - f) + v.samples[i + 1] * f) * t.volume;
l += s * v.left;
r += s * v.right;
v.position += v.step;
}
}
let gain = mixer.music_gain;
if let Some(v) = mixer.music.as_mut() {
let i = v.position as usize;
if i + 1 < v.samples.len() {
let t = (v.position - i as f64) as f32;
let s = (v.samples[i] * (1.0 - t) + v.samples[i + 1] * t) * gain;
l += s * v.left;
r += s * v.right;
v.position += v.step;
}
}
if channels >= 2 {
frame[0] = l.clamp(-1.0, 1.0);
frame[1] = r.clamp(-1.0, 1.0);
} else {
frame[0] = ((l + r) / 2.0).clamp(-1.0, 1.0);
}
}
mixer
.voices
.retain(|v| (v.position as usize) + 1 < v.samples.len());
mixer.tracked.retain(|t| {
t.looping || (t.voice.position as usize) + 1 < t.voice.samples.len()
});
if mixer
.music
.as_ref()
.is_some_and(|v| (v.position as usize) + 1 >= v.samples.len())
{
mixer.music = None;
if let Ok(mut m) = mixer.lock() {
mix(&mut m, data, channels, rate);
} else {
data.fill(0.0);
}
},
|e| eprintln!("Horizon: audio stream error: {e}"),
None,
)?;
stream.play()?;
Ok((stream, rate))
Ok(stream)
}
fn random(&mut self) -> u64 {
self.seed ^= self.seed << 13;
self.seed ^= self.seed >> 7;
self.seed ^= self.seed << 17;
self.seed
}
fn resolve(&mut self, event: &str, depth: u32) -> Option<Variant> {
let variants = self.events.get(event)?.clone();
let total: u32 = variants.iter().map(|v| v.weight).sum();
if total == 0 {
return None;
fn bound(&mut self) -> Bound<'_, MixerBackend> {
Bound {
engine: &mut self.engine,
backend: &mut self.backend,
}
let mut pick = (self.random() % u64::from(total)) as u32;
let chosen = variants.into_iter().find(|v| {
if pick < v.weight {
true
} else {
pick -= v.weight;
false
}
/// `SoundManager.updateSource`: the camera position and yaw.
pub fn set_listener(&mut self, position: [f64; 3], yaw: f32) {
self.listener = position;
self.listener_yaw = yaw;
if let Ok(mut m) = self.backend.mixer.lock() {
m.listener = position;
m.listener_yaw = yaw;
}
}
/// Sync the volume options, refreshing the categories that changed
/// (the option sliders' `refreshCategoryVolume`).
fn sync_volumes(&mut self) {
let mut volumes = [self.master; 11];
for (i, v) in volumes.iter_mut().enumerate().skip(1) {
*v = crate::settings::source_volume(i);
}
for source in 0..11 {
if self.engine.source_volumes[source] != volumes[source] {
self.engine.source_volumes[source] = volumes[source];
self.engine
.refresh_category_volume(source, &mut self.backend);
}
})?;
if chosen.is_event && depth < 4 {
let name = if chosen.name.contains(':') {
chosen.name.clone()
} else {
format!("minecraft:{}", chosen.name)
};
let mut inner = self.resolve(&name, depth + 1)?;
inner.volume *= chosen.volume;
inner.pitch *= chosen.pitch;
return Some(inner);
}
Some(chosen)
self.engine.listening = self.record_heard;
}
/// Play a sound event at a world position (None = non-positional UI sound).
/// `SoundManager.tick(paused)`, once per client tick.
pub fn tick(&mut self, paused: bool, ctx: &TickContext) {
self.sync_volumes();
self.engine.tick(paused, ctx, &mut self.backend);
}
/// `SoundManager.pauseAllExcept(MUSIC, UI)`.
pub fn pause(&mut self) {
self.engine.pause_all_except(
&[crate::sound_engine::MUSIC, crate::sound_engine::UI],
&mut self.backend,
);
}
/// `SoundManager.resume`.
pub fn resume(&mut self) {
self.engine.resume(&mut self.backend);
}
/// `SoundManager.stop()`: every sound.
pub fn stop_all(&mut self) {
self.engine.stop_all(&mut self.backend);
}
/// Play a sound event at a world position, as `ClientLevel.playLocalSound`
/// (a `SimpleSoundInstance` seeded from the level random), or relative to
/// the listener without attenuation (`None`).
pub fn play(&mut self, event: &str, at: Option<[f64; 3]>, volume: f32, pitch: f32) {
let seed = self.random.next_long();
self.play_seeded(event, at, volume, pitch, seed);
}
/// `play` with the seed a sound packet carries.
pub fn play_seeded(
&mut self,
event: &str,
at: Option<[f64; 3]>,
volume: f32,
pitch: f32,
seed: i64,
) {
if let (Some(capture), Some(at)) = (self.capture.as_mut(), at) {
capture.push((event.to_owned(), at, volume, pitch));
}
let Some(variant) = self.resolve(event, 0) else {
return;
};
if self.record_heard
&& let Some(key) = self.subtitles.get(event)
{
let range = if at.is_some() {
variant.attenuation * volume.max(1.0)
} else {
f32::INFINITY
};
self.heard.push(Heard {
subtitle: key.clone(),
at,
range,
looping: false,
});
}
if self._stream.is_none() {
return;
}
let file = variant.name.trim_start_matches("minecraft:").to_owned();
let entry = self.cache.entry(file.clone()).or_insert_with(|| {
decode(
&self
.root
.join(format!("assets/minecraft/sounds/{file}.ogg")),
)
.map(|(s, r)| (Arc::new(s), r))
});
let Some((samples, rate)) = entry.clone() else {
return;
};
self.sync_volumes();
let event = crate::sound_events::normalize(event);
let source = self
.source_override
.unwrap_or_else(|| crate::settings::sound_source(event));
let volume = (volume * variant.volume).clamp(0.0, 1.0)
* self.master
* crate::settings::source_volume(source);
let pitch = (pitch * variant.pitch).clamp(0.5, 2.0);
let (mut left, mut right) = (volume, volume);
if let Some(p) = at {
let d = [
p[0] - self.listener[0],
p[1] - self.listener[1],
p[2] - self.listener[2],
];
let distance = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]).sqrt() as f32;
let range = variant.attenuation * volume.max(1.0);
let gain = (1.0 - distance / range).clamp(0.0, 1.0);
if gain <= 0.0 {
return;
}
let yaw = self.listener_yaw.to_radians();
// Right vector of a Minecraft yaw.
let side = (-(yaw.cos()) as f64 * d[0] - (yaw.sin()) as f64 * d[2]) as f32
/ distance.max(0.001);
let pan = side.clamp(-1.0, 1.0);
left = volume * gain * (1.0 - pan.max(0.0) * 0.6);
right = volume * gain * (1.0 + pan.min(0.0) * 0.6);
}
let step = f64::from(rate) / f64::from(self.rate) * f64::from(pitch);
if let Ok(mut m) = self.mixer.lock() {
if m.voices.len() > 64 {
m.voices.remove(0);
}
m.voices.push(Voice {
samples,
position: 0.0,
step,
left,
right,
event: event.into(),
.unwrap_or_else(|| crate::settings::sound_source(&event));
let random = LegacyRandom::new(seed);
let instance = match at {
Some(at) => SoundInstance::at(&event, source, volume, pitch, random, at),
None => SoundInstance::new(
&event,
source,
});
}
}
/// `SoundManager.play` of a music instance (`SimpleSoundInstance.forMusic`:
/// non-positional, streamed). Decoding runs on a worker thread; the track
/// counts as active from now on. Returns false when nothing can play.
pub fn play_music(&mut self, event: &str) -> bool {
self.stop_music();
if self._stream.is_none() {
return false;
}
let Some(variant) = self.resolve(event, 0) else {
return false;
volume,
pitch,
random,
false,
0,
Attenuation::None,
[0.0; 3],
true,
),
};
let path = self.root.join(format!(
"assets/minecraft/sounds/{}.ogg",
variant.name.trim_start_matches("minecraft:")
));
let volume =
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
let step_scale = f64::from(variant.pitch.clamp(0.5, 2.0)) / f64::from(self.rate);
let ready = Arc::new(std::sync::atomic::AtomicBool::new(false));
let (mixer, done) = (self.mixer.clone(), ready.clone());
let music_event = event.to_owned();
std::thread::spawn(move || {
if let Some((samples, rate)) = decode(&path)
&& let Ok(mut m) = mixer.lock()
&& !done.load(std::sync::atomic::Ordering::Acquire)
{
m.music = Some(Voice {
samples: Arc::new(samples),
position: 0.0,
step: f64::from(rate) * step_scale,
left: volume,
right: volume,
event: music_event.as_str().into(),
source: 1,
});
}
done.store(true, std::sync::atomic::Ordering::Release);
});
self.music = Some(MusicTrack {
event: event.to_owned(),
ready,
});
true
}
/// `SoundManager.stop` of the music instance.
pub fn stop_music(&mut self) {
if let Some(track) = self.music.take() {
// A decoder still running sees the flag and drops its result.
track
.ready
.store(true, std::sync::atomic::Ordering::Release);
}
if let Ok(mut m) = self.mixer.lock() {
m.music = None;
}
}
/// `SoundManager.isActive`: decoding or still sounding.
pub fn music_active(&mut self) -> bool {
let Some(track) = &self.music else {
return false;
};
if !track.ready.load(std::sync::atomic::Ordering::Acquire) {
return true;
}
let playing = self.mixer.lock().is_ok_and(|m| m.music.is_some());
if !playing {
self.music = None;
}
playing
}
/// The event of the current music track.
pub fn music_event(&self) -> Option<&str> {
self.music.as_ref().map(|t| t.event.as_str())
}
/// Every sound event id (for command completion).
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.events.keys().cloned().collect();
v.sort();
v
self.play_instance(instance, false);
}
/// Play a sound on a given `SoundSource` index (`/playsound`).
pub fn play_on(
@@ -486,102 +493,111 @@ impl Audio {
self.play(event, at, volume, pitch);
self.source_override = None;
}
/// `SimpleSoundInstance.forJukeboxSong` at a jukebox; the handle stays
/// until `release`.
pub fn play_jukebox_song(&mut self, event: &str, pos: [i32; 3]) -> InstanceId {
let at = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
let event = crate::sound_events::normalize(event);
let instance = SoundInstance::for_jukebox_song(&event, at, SoundEngine::unseeded());
self.play_instance(instance, true).0
}
/// Stop and drop a held instance.
pub fn stop_and_release(&mut self, id: InstanceId) {
self.stop_instance(id);
self.release(id);
}
/// Every sound event id (for command completion).
pub fn event_names(&self) -> Vec<String> {
let mut v: Vec<String> = self.engine.registry.names().cloned().collect();
v.sort();
v
}
/// `ClientboundStopSoundPacket`: stop the playing sounds of an event
/// and/or a source.
pub fn stop_sounds(&mut self, event: Option<&str>, source: Option<usize>) {
let matches = |v: &Voice| {
event.is_none_or(|e| &*v.event == e) && source.is_none_or(|s| v.source == s)
};
if let Ok(mut m) = self.mixer.lock() {
m.voices.retain(|v| !matches(v));
m.tracked.retain(|t| !matches(&t.voice));
if m.music.as_ref().is_some_and(matches) {
m.music = None;
}
}
if self
.music
.as_ref()
.is_some_and(|t| event.is_none_or(|e| t.event == e) && source.is_none_or(|s| s == 1))
{
self.music = None;
}
let event = event.map(crate::sound_events::normalize);
self.engine
.stop_matching(event.as_deref(), source, &mut self.backend);
}
/// The sounds played since the last call, for the subtitle overlay.
pub fn take_heard(&mut self) -> Vec<Heard> {
std::mem::take(&mut self.heard)
}
/// `SoundManager.updateCategoryVolume(MUSIC, gain)`.
pub fn set_music_gain(&mut self, gain: f32) {
if let Ok(mut m) = self.mixer.lock() {
m.music_gain = gain;
}
}
/// Play a relative (non-positional) tickable sound instance the caller
/// keeps a handle to: ambient loops (`looping`) and sub-sounds.
pub fn play_tracked(&mut self, event: &str, volume: f32, looping: bool) -> Option<u64> {
let variant = self.resolve(event, 0)?;
if self.record_heard
&& let Some(key) = self.subtitles.get(event)
{
self.heard.push(Heard {
subtitle: key.clone(),
at: None,
range: f32::INFINITY,
looping,
});
}
self._stream.as_ref()?;
let file = variant.name.trim_start_matches("minecraft:").to_owned();
let root = &self.root;
let (samples, rate) = self
.cache
.entry(file.clone())
.or_insert_with(|| {
decode(&root.join(format!("assets/minecraft/sounds/{file}.ogg")))
.map(|(s, r)| (Arc::new(s), r))
std::mem::take(&mut self.engine.notifications)
.into_iter()
.filter_map(|n| {
Some(Heard {
subtitle: n.subtitle?,
at: Some(n.position),
range: n.range,
looping: n.looping,
})
})
.clone()?;
let gain =
variant.volume.clamp(0.0, 1.0) * self.master * crate::settings::event_volume(event);
let step =
f64::from(rate) / f64::from(self.rate) * f64::from(variant.pitch.clamp(0.5, 2.0));
self.next_tracked += 1;
let id = self.next_tracked;
let mut m = self.mixer.lock().ok()?;
m.tracked.push(Tracked {
id,
voice: Voice {
samples,
position: 0.0,
step,
left: gain,
right: gain,
event: event.into(),
source: crate::settings::sound_source(event),
},
.collect()
}
}
impl Sounds for Audio {
fn engine(&mut self) -> &mut SoundEngine {
&mut self.engine
}
fn play_instance(&mut self, instance: SoundInstance, retain: bool) -> (InstanceId, PlayResult) {
self.sync_volumes();
self.bound().play_instance(instance, retain)
}
fn play_id(&mut self, id: InstanceId) -> PlayResult {
self.sync_volumes();
self.bound().play_id(id)
}
fn stop_instance(&mut self, id: InstanceId) {
self.bound().stop_instance(id);
}
fn update_category_volume(&mut self, source: usize, gain: f32) {
self.bound().update_category_volume(source, gain);
}
}
#[cfg(test)]
mod tests {
use super::*;
fn channel(samples: Vec<f32>, linear: Option<f32>, at: [f32; 3], looping: bool) -> Channel {
Channel {
samples: Some((Arc::new(samples), 48_000)),
position: 0.0,
state: State::Playing,
gain: 1.0,
pitch: 1.0,
linear,
looping,
volume: volume.clamp(0.0, 1.0),
});
Some(id)
}
/// A tracked instance's volume (`AbstractTickableSoundInstance.volume`).
pub fn set_tracked_volume(&mut self, id: u64, volume: f32) {
if let Ok(mut m) = self.mixer.lock()
&& let Some(t) = m.tracked.iter_mut().find(|t| t.id == id)
{
t.volume = volume.clamp(0.0, 1.0);
at,
relative: false,
}
}
pub fn stop_tracked(&mut self, id: u64) {
if let Ok(mut m) = self.mixer.lock() {
m.tracked.retain(|t| t.id != id);
}
#[test]
fn linear_attenuation_reaches_zero_at_the_max_distance() {
assert_eq!(distance_gain(None, 100.0), 1.0);
assert_eq!(distance_gain(Some(16.0), 0.0), 1.0);
assert_eq!(distance_gain(Some(16.0), 8.0), 0.5);
assert_eq!(distance_gain(Some(16.0), 16.0), 0.0);
assert_eq!(distance_gain(Some(16.0), 40.0), 0.0);
}
/// Still sounding (a finished one-shot is gone).
pub fn tracked_active(&self, id: u64) -> bool {
self.mixer
.lock()
.is_ok_and(|m| m.tracked.iter().any(|t| t.id == id))
#[test]
fn sources_play_attenuated_then_stop_or_loop() {
let mut m = Mixer::default();
m.channels
.insert(1, channel(vec![1.0; 4], Some(16.0), [8.0, 0.0, 0.0], false));
m.channels
.insert(2, channel(vec![1.0; 4], None, [0.0; 3], true));
let mut data = [0.0f32; 16];
mix(&mut m, &mut data, 2, 48_000);
assert_eq!(m.channels[&1].state, State::Stopped);
assert_eq!(m.channels[&2].state, State::Playing);
// Mono sum of the two at the first frame: the far one is half as loud.
let first = data[0] + data[1];
assert!(first > 1.4 && first < 3.1, "{first}");
}
}
+235
View File
@@ -0,0 +1,235 @@
//! Auto-jump (`LocalPlayer.updateAutoJump` and `canAutoJump`): after the
//! local player's own move, look ahead along the movement for a step that
//! the player can clear with a jump and is not walled in above; if found, the
//! next `aiStep` presses jump for one tick (`autoJumpTime = 1`).
use horizon_core::{
geometry::{Aabb, Axis, Vec3, VoxelShape, trig},
position::BlockPos,
};
/// `Float.MIN_VALUE`, the "no obstacle" marker.
const NO_OBSTACLE: f32 = f32::from_bits(1);
/// What `updateAutoJump` reads from the player.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct AutoJumper {
pub position: Vec3,
/// `getBoundingBox().maxY`.
pub max_y: f64,
pub x_rot: f32,
pub y_rot: f32,
/// `getSpeed()`.
pub speed: f32,
/// `input.getMoveVector()` (x: strafe, y: forward).
pub move_vector: [f32; 2],
/// `getBbWidth()`, `getBbHeight()`.
pub width: f32,
pub height: f32,
/// The jump boost amplifier, when the effect is active.
pub jump_boost: Option<i32>,
}
/// The level queries, with the player's collision context.
pub trait AutoJumpWorld {
type Error;
/// `getBlockState(p).getCollisionShape(level, p, CollisionContext.of(player))`.
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, Self::Error>;
/// `level.getCollisions(player, bounds)` flattened with `toAabbs`, in order.
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, Self::Error>;
}
/// `LocalPlayer.canAutoJump`. `staying_on_surface` is
/// `isStayingOnGroundSurface()` (the shift key).
#[allow(clippy::too_many_arguments)]
pub fn can_auto_jump(
enabled: bool,
auto_jump_time: i32,
on_ground: bool,
staying_on_surface: bool,
passenger: bool,
move_vector: [f32; 2],
block_jump_factor: f32,
) -> bool {
enabled
&& auto_jump_time <= 0
&& on_ground
&& !staying_on_surface
&& !passenger
&& move_vector[0] * move_vector[0] + move_vector[1] * move_vector[1] > 0.0
&& f64::from(block_jump_factor) >= 1.0
}
/// `Vec3.directionFromRotation(rotX, rotY)`.
pub fn direction_from_rotation(rot_x: f32, rot_y: f32) -> Vec3 {
let rad = trig::DEG_TO_RAD;
let pi = std::f32::consts::PI;
let y_cos = trig::cos(f64::from(-rot_y * rad - pi));
let y_sin = trig::sin(f64::from(-rot_y * rad - pi));
let x_cos = -trig::cos(f64::from(-rot_x * rad));
let x_sin = trig::sin(f64::from(-rot_x * rad));
Vec3::new(
f64::from(y_sin * x_cos),
f64::from(x_sin),
f64::from(y_cos * x_cos),
)
}
/// `AABB.intersects(Vec3, Vec3)`: the box overlaps the segment's bounds.
fn intersects_segment(b: Aabb, from: Vec3, to: Vec3) -> bool {
b.intersects(Aabb::new(from, to))
}
/// `LocalPlayer.updateAutoJump(xa, za)` once `canAutoJump` holds: whether
/// it sets `autoJumpTime = 1`. `xa`/`za` are the float horizontal
/// displacement of the move.
pub fn update_auto_jump<W: AutoJumpWorld>(
world: &mut W,
p: &AutoJumper,
xa: f32,
za: f32,
) -> Result<bool, W::Error> {
let move_begin = p.position;
let move_end = move_begin.add_vector(Vec3::new(f64::from(xa), 0.0, f64::from(za)));
let mut move_diff = Vec3::new(f64::from(xa), 0.0, f64::from(za));
let current_speed = p.speed;
let mut move_dist_sq = move_diff.length_squared() as f32;
if move_dist_sq <= 0.001 {
let input_xa = current_speed * p.move_vector[0];
let input_za = current_speed * p.move_vector[1];
let sin = trig::sin(f64::from(p.y_rot * trig::DEG_TO_RAD));
let cos = trig::cos(f64::from(p.y_rot * trig::DEG_TO_RAD));
move_diff = Vec3::new(
f64::from(input_xa * cos - input_za * sin),
move_diff.y,
f64::from(input_za * cos + input_xa * sin),
);
move_dist_sq = move_diff.length_squared() as f32;
if move_dist_sq <= 0.001 {
return Ok(false);
}
}
// Mth.invSqrt: 1.0F / (float) Math.sqrt(x).
let inverted = 1.0 / move_dist_sq.sqrt();
let move_dir = move_diff.scale(f64::from(inverted));
let facing = direction_from_rotation(p.x_rot, p.y_rot);
let facing_vs_moving = (facing.x * move_dir.x + facing.z * move_dir.z) as f32;
if facing_vs_moving < -0.15 {
return Ok(false);
}
let mut ceiling = BlockPos::containing(Vec3::new(move_begin.x, p.max_y, move_begin.z));
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
ceiling = ceiling.offset(0, 1, 0);
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
let mut jump_height = 1.2f32;
if let Some(amplifier) = p.jump_boost {
jump_height += (amplifier + 1) as f32 * 0.75;
}
let look_ahead = crate::camera::java_max(current_speed * 7.0, 1.0 / inverted);
let seg_begin = move_begin;
let seg_end = move_end.add_vector(move_dir.scale(f64::from(look_ahead)));
let (width, height) = (p.width, p.height);
let test_box = Aabb::new(
seg_begin,
seg_end.add_vector(Vec3::new(0.0, f64::from(height), 0.0)),
)
.inflate(Vec3::new(f64::from(width), 0.0, f64::from(width)));
let lift = Vec3::new(0.0, f64::from(0.51f32), 0.0);
let seg_begin = seg_begin.add_vector(lift);
let seg_end = seg_end.add_vector(lift);
let right_dir = move_dir.cross(Vec3::new(0.0, 1.0, 0.0));
let right_offset = right_dir.scale(f64::from(width * 0.5));
let left_begin = seg_begin.subtract(right_offset);
let left_end = seg_end.subtract(right_offset);
let right_begin = seg_begin.add_vector(right_offset);
let right_end = seg_end.add_vector(right_offset);
let mut obstacle = NO_OBSTACLE;
for b in world.collision_boxes(test_box)? {
if intersects_segment(b, left_begin, left_end)
|| intersects_segment(b, right_begin, right_end)
{
obstacle = b.max_position().y as f32;
let obstacle_pos = BlockPos::containing(b.center());
let mut steps = 1i32;
while (steps as f32) < jump_height {
let above = obstacle_pos.offset(0, steps, 0);
let shape = world.collision_shape(above)?;
if !shape.is_empty() {
obstacle = shape.max(Axis::Y) as f32 + above.y as f32;
if f64::from(obstacle) - p.position.y > f64::from(jump_height) {
return Ok(false);
}
}
if steps > 1 {
ceiling = ceiling.offset(0, 1, 0);
if !world.collision_shape(ceiling)?.is_empty() {
return Ok(false);
}
}
steps += 1;
}
break;
}
}
if obstacle != NO_OBSTACLE {
let y_delta = (f64::from(obstacle) - p.position.y) as f32;
// Negated as in LocalPlayer.updateAutoJump, so a NaN delta still jumps.
#[allow(clippy::neg_cmp_op_on_partial_ord)]
let jumps = !(y_delta <= 0.5) && !(y_delta > jump_height);
if jumps {
return Ok(true);
}
}
Ok(false)
}
#[cfg(test)]
mod tests {
use super::*;
struct Step;
impl AutoJumpWorld for Step {
type Error = ();
fn collision_shape(&mut self, p: BlockPos) -> Result<VoxelShape, ()> {
Ok(if p.x == 1 && p.y == 0 {
VoxelShape::block()
} else {
VoxelShape::empty()
})
}
fn collision_boxes(&mut self, bounds: Aabb) -> Result<Vec<Aabb>, ()> {
let b = Aabb::new(Vec3::new(1.0, 0.0, -5.0), Vec3::new(2.0, 1.0, 5.0));
Ok(if b.intersects(bounds) {
vec![b]
} else {
Vec::new()
})
}
}
#[test]
fn walking_into_a_one_block_step_jumps() {
let p = AutoJumper {
position: Vec3::new(0.6, 0.0, 0.5),
max_y: 1.8,
x_rot: 0.0,
y_rot: -90.0,
speed: 0.1,
move_vector: [0.0, 1.0],
width: 0.6,
height: 1.8,
jump_boost: None,
};
assert!(update_auto_jump(&mut Step, &p, 0.1, 0.0).unwrap());
// Moving away from the step does not.
assert!(!update_auto_jump(&mut Step, &p, -0.1, 0.0).unwrap());
}
#[test]
fn sneaking_or_standing_still_cannot_auto_jump() {
assert!(can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 1.0));
assert!(!can_auto_jump(true, 0, true, true, false, [0.0, 1.0], 1.0));
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 0.0], 1.0));
assert!(!can_auto_jump(true, 0, true, false, false, [0.0, 1.0], 0.5));
}
}
+20 -26
View File
@@ -17,11 +17,16 @@ pub struct SpawnEntry {
pub weight: u32,
pub min: u32,
pub max: u32,
/// A `ConstantInt` count (sampling draws nothing).
pub constant: bool,
}
#[derive(Clone, Debug)]
pub struct BiomeInfo {
/// `natural_mob_spawns` by category (creature, monster, ...).
pub spawns: HashMap<String, Vec<SpawnEntry>>,
/// `natural_mob_spawns` spawn costs by entity type.
pub spawn_costs: HashMap<String, crate::mobs::spawn_selection::SpawnCost>,
/// The `gameplay/creature_world_gen_spawn_probability` attribute.
pub creature_probability: f32,
pub name: String,
pub temperature: f32,
@@ -103,37 +108,26 @@ impl Biomes {
_ => GrassModifier::None,
};
let mut spawns: HashMap<String, Vec<SpawnEntry>> = HashMap::new();
let categories = &attributes["minecraft:gameplay/natural_mob_spawns"]["argument"]["spawns_by_category"];
let natural = &attributes["minecraft:gameplay/natural_mob_spawns"];
// A modifier object ({argument, modifier: overlay}) or the plain value;
// the overlay over the dimension's empty default is the biome's value.
let natural = if natural["argument"].is_object() {
&natural["argument"]
} else {
natural
};
let categories = &natural["spawns_by_category"];
for (category, entries) in categories.as_object().into_iter().flatten() {
let list = entries
.as_array()
.into_iter()
.flatten()
.filter_map(|e| {
let (min, max) = match &e["count"] {
Value::Number(n) => {
let n = n.as_u64().unwrap_or(1) as u32;
(n, n)
}
c => (
c["min_inclusive"].as_u64().unwrap_or(1) as u32,
c["max_inclusive"].as_u64().unwrap_or(1) as u32,
),
};
Some(SpawnEntry {
entity: e["type"].as_str()?.to_owned(),
weight: e["weight"].as_u64().unwrap_or(1) as u32,
min,
max,
})
})
.collect();
let list = crate::mobs::spawn_selection::parse_entries(entries);
spawns.insert(category.clone(), list);
}
list.push(BiomeInfo {
spawns,
creature_probability: v["creature_spawn_probability"].as_f64().unwrap_or(0.1)
as f32,
spawn_costs: crate::mobs::spawn_selection::parse_costs(&natural["spawn_costs"]),
creature_probability:
attributes["minecraft:gameplay/creature_world_gen_spawn_probability"]
.as_f64()
.unwrap_or(0.1) as f32,
temperature,
downfall,
precipitation: v["has_precipitation"].as_bool().unwrap_or(true),
@@ -0,0 +1,905 @@
//! Client-ticked block-entity animation state, as vanilla's block entities
//! keep it: `ChestLidController` (chests, trapped and copper chests, ender
//! chests), `EnchantingTableBlockEntity.bookAnimationTick`,
//! `ShulkerBoxBlockEntity` (open count, animation status, progress),
//! `BellBlockEntity` (ring ticks, shake direction, resonation),
//! `ConduitBlockEntity.clientTick` (tick count, shape check, rotation,
//! hunting, nautilus particles), `SkullBlockEntity.animation` (powered dragon
//! and piglin heads) and `DecoratedPotBlockEntity` wobbles.
//!
//! [`BlockEntityAnims`] holds the state per position and turns it into the
//! renderer's [`BlockAnim`]. Its tick methods take what the level answers
//! (blocks, entities, game time) as closures, so the parity fixture can drive
//! them with scripted inputs (`tools/test_block_entity_anim_parity.py`).
use crate::block_entity_render::BlockAnim;
use horizon_core::{
geometry::trig::{self, Mth},
random::{LegacyRandom, RandomSource},
};
use std::collections::HashMap;
/// `Direction` names by `get3DDataValue`.
pub const DIRECTIONS: [&str; 6] = ["down", "up", "north", "south", "west", "east"];
fn step(direction: u8) -> [i32; 3] {
match direction {
0 => [0, -1, 0],
1 => [0, 1, 0],
2 => [0, 0, -1],
3 => [0, 0, 1],
4 => [-1, 0, 0],
_ => [1, 0, 0],
}
}
/// `Direction.from3DDataValue`: `BY_3D_DATA[Mth.abs(data % 6)]`.
pub fn from_3d_data_value(data: i32) -> u8 {
(data % 6).unsigned_abs() as u8
}
/// The 3D data value of a direction name.
pub fn direction_index(name: &str) -> Option<u8> {
DIRECTIONS.iter().position(|d| *d == name).map(|i| i as u8)
}
/// `ChestLidController`, shared by `ChestBlockEntity` (chests, trapped and
/// copper chests) and `EnderChestBlockEntity`.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct ChestLid {
pub should_be_open: bool,
pub openness: f32,
pub o_openness: f32,
}
impl ChestLid {
/// `triggerEvent(1, openCount)`: the lid opens while anyone has it open.
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
if b0 == 1 {
self.should_be_open = b1 > 0;
true
} else {
false
}
}
/// `tickLid` (the client ticker `lidAnimateTick`).
pub fn tick(&mut self) {
self.o_openness = self.openness;
if !self.should_be_open && self.openness > 0.0 {
self.openness = (self.openness - 0.1).max(0.0);
} else if self.should_be_open && self.openness < 1.0 {
self.openness = (self.openness + 0.1).min(1.0);
}
}
/// `getOpenness(a)`: `Mth.lerp(a, oOpenness, openness)`.
pub fn get_openness(&self, a: f32) -> f32 {
self.o_openness + a * (self.openness - self.o_openness)
}
}
/// `ChestBlock.opennessCombiner`: a double chest opens as far as its more
/// open half (`Math.max(first, second)`, first being the `right` half).
pub fn combined_openness(first: f32, second: Option<f32>) -> f32 {
match second {
Some(second) => java_max(first, second),
None => first,
}
}
/// `Math.max(float, float)`.
fn java_max(a: f32, b: f32) -> f32 {
if a.is_nan() {
a
} else if a == 0.0 && b == 0.0 && a.to_bits() != b.to_bits() {
0.0
} else if a >= b {
a
} else {
b
}
}
/// `ChestRenderer.submit` and `ChestModel.setupAnim`: the lid and lock
/// `xRot` for a render-state openness.
pub fn chest_lid_x_rot(open: f32) -> f32 {
let open = 1.0 - open;
let open = 1.0 - open * open * open;
-(open * std::f32::consts::FRAC_PI_2)
}
/// `EnchantingTableBlockEntity` book fields.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct EnchantingBook {
pub time: i32,
pub flip: f32,
pub o_flip: f32,
pub flip_t: f32,
pub flip_a: f32,
pub open: f32,
pub o_open: f32,
pub rot: f32,
pub o_rot: f32,
pub t_rot: f32,
}
/// The `while (a >= PI) a -= 2PI; while (a < -PI) a += 2PI` loops.
fn wrap_pi(mut a: f32) -> f32 {
use std::f32::consts::PI;
while a >= PI {
a -= 2.0 * PI;
}
while a < -PI {
a += 2.0 * PI;
}
a
}
/// `Mth.floor(float)`.
fn mth_floor(v: f32) -> i32 {
let i = v as i32;
if v < i as f32 { i - 1 } else { i }
}
/// `Mth.frac(float)`.
pub fn mth_frac(v: f32) -> f32 {
v - mth_floor(v) as f32
}
/// `EnchantTableRenderState` (`EnchantTableRenderer.extractRenderState`) and
/// the `BookModel.State` its `submit` builds.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BookRender {
pub flip: f32,
pub open: f32,
pub time: f32,
pub y_rot: f32,
/// `BookModel.State.forAnimation`: openness, pageFlip1, pageFlip2.
pub openness: f32,
pub page_flip1: f32,
pub page_flip2: f32,
}
impl EnchantingBook {
/// `bookAnimationTick`. `players` are the feet positions of the level's
/// non-spectator players (`getNearestPlayer(centre, 3.0, false)`);
/// `random` is the block entity class's shared static `RANDOM`.
pub fn tick(&mut self, pos: [i32; 3], players: &[[f64; 3]], random: &mut impl RandomSource) {
self.o_open = self.open;
self.o_rot = self.rot;
let c = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
let mut best = -1.0;
let mut nearest = None;
for p in players {
let (dx, dy, dz) = (p[0] - c[0], p[1] - c[1], p[2] - c[2]);
let dist = dx * dx + dy * dy + dz * dz;
if dist < 9.0 && (best == -1.0 || dist < best) {
best = dist;
nearest = Some(*p);
}
}
if let Some(p) = nearest {
let xd = p[0] - c[0];
let zd = p[2] - c[2];
self.t_rot = trig::atan2(zd, xd) as f32;
self.open += 0.1;
if self.open < 0.5 || random.next_int_bound(40).unwrap_or(1) == 0 {
let old = self.flip_t;
loop {
let a = random.next_int_bound(4).unwrap_or(0);
let b = random.next_int_bound(4).unwrap_or(0);
self.flip_t += (a - b) as f32;
if old != self.flip_t {
break;
}
}
}
} else {
self.t_rot += 0.02;
self.open -= 0.1;
}
self.rot = wrap_pi(self.rot);
self.t_rot = wrap_pi(self.t_rot);
let dir = wrap_pi(self.t_rot - self.rot);
self.rot += dir * 0.4;
self.open = self.open.clamp(0.0, 1.0);
self.time += 1;
self.o_flip = self.flip;
let diff = ((self.flip_t - self.flip) * 0.4).clamp(-0.2, 0.2);
self.flip_a += (diff - self.flip_a) * 0.9;
self.flip += self.flip_a;
}
/// `EnchantTableRenderer.extractRenderState` and the book state of its
/// `submit`.
pub fn render(&self, a: f32) -> BookRender {
let flip = self.o_flip + a * (self.flip - self.o_flip);
let open = self.o_open + a * (self.open - self.o_open);
let time = self.time as f32 + a;
let y_rot = self.o_rot + wrap_pi(self.rot - self.o_rot) * a;
let ff1 = mth_frac(flip + 0.25) * 1.6 - 0.3;
let ff2 = mth_frac(flip + 0.75) * 1.6 - 0.3;
BookRender {
flip,
open,
time,
y_rot,
openness: ((time * 0.02).msin() * 0.1 + 1.25) * open,
page_flip1: ff1.clamp(0.0, 1.0),
page_flip2: ff2.clamp(0.0, 1.0),
}
}
}
/// `ShulkerBoxBlockEntity.AnimationStatus`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum ShulkerStatus {
#[default]
Closed,
Opening,
Opened,
Closing,
}
/// `ShulkerBoxBlockEntity` lid state.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Shulker {
pub open_count: i32,
pub status: ShulkerStatus,
pub progress: f32,
pub progress_old: f32,
}
/// Side effects of one [`Shulker::tick`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct ShulkerTick {
/// `doNeighborUpdates` calls (shape and neighbour updates around the box).
pub neighbor_updates: u32,
/// `moveCollidedEntities`: the swept lid box (min x, y, z, max x, y, z);
/// entities in it move out along the facing.
pub push: Option<[f64; 6]>,
}
/// `Shulker.getProgressDeltaAabb(size, direction, from, to, position)`.
pub fn progress_delta_aabb(
size: f32,
direction: u8,
from: f32,
to: f32,
position: [f64; 3],
) -> [f64; 6] {
let size_d = f64::from(size);
let mut b = [
-size_d * 0.5,
0.0,
-size_d * 0.5,
size_d * 0.5,
size_d,
size_d * 0.5,
];
let max = f64::from(from.max(to));
let min = f64::from(from.min(to));
let s = step(direction);
for axis in 0..3 {
// expandTowards
let a = f64::from(s[axis]) * max * size_d;
if a < 0.0 {
b[axis] += a;
} else if a > 0.0 {
b[axis + 3] += a;
}
}
for axis in 0..3 {
// contract
let a = -f64::from(s[axis]) * (1.0 + min) * size_d;
if a < 0.0 {
b[axis] -= a;
} else if a > 0.0 {
b[axis + 3] -= a;
}
}
// new AABB keeps min <= max; then move to the position.
let mut out = [0.0; 6];
for axis in 0..3 {
out[axis] = b[axis].min(b[axis + 3]) + position[axis];
out[axis + 3] = b[axis].max(b[axis + 3]) + position[axis];
}
out
}
impl Shulker {
/// `triggerEvent(1, openCount)`.
pub fn trigger_event(&mut self, b0: i32, b1: i32) -> bool {
if b0 != 1 {
return false;
}
self.open_count = b1;
if b1 == 0 {
self.status = ShulkerStatus::Closing;
}
if b1 == 1 {
self.status = ShulkerStatus::Opening;
}
true
}
/// `startOpen`/`stopOpen` for a non-spectator user: the new open count to
/// send as block event 1, and whether this was the first opener (open) or
/// the last closer (close), which play the sound and game event.
pub fn start_open(&mut self) -> (i32, bool) {
if self.open_count < 0 {
self.open_count = 0;
}
self.open_count += 1;
(self.open_count, self.open_count == 1)
}
pub fn stop_open(&mut self) -> (i32, bool) {
self.open_count -= 1;
(self.open_count, self.open_count <= 0)
}
/// `updateAnimation` (`ShulkerBoxBlockEntity.tick`); `facing` is the
/// block's facing as a 3D data value.
pub fn tick(&mut self, pos: [i32; 3], facing: u8) -> ShulkerTick {
let mut out = ShulkerTick::default();
self.progress_old = self.progress;
match self.status {
ShulkerStatus::Closed => self.progress = 0.0,
ShulkerStatus::Opening => {
self.progress += 0.1;
if self.progress_old == 0.0 {
out.neighbor_updates += 1;
}
if self.progress >= 1.0 {
self.status = ShulkerStatus::Opened;
self.progress = 1.0;
out.neighbor_updates += 1;
}
let bottom = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
out.push = Some(progress_delta_aabb(
1.0,
facing,
self.progress_old,
self.progress,
bottom,
));
}
ShulkerStatus::Opened => self.progress = 1.0,
ShulkerStatus::Closing => {
self.progress -= 0.1;
if self.progress_old == 1.0 {
out.neighbor_updates += 1;
}
if self.progress <= 0.0 {
self.status = ShulkerStatus::Closed;
self.progress = 0.0;
out.neighbor_updates += 1;
}
}
}
out
}
/// `getProgress(a)`: `Mth.lerp(a, progressOld, progress)`.
pub fn get_progress(&self, a: f32) -> f32 {
self.progress_old + a * (self.progress - self.progress_old)
}
/// `ShulkerBoxBlock.canOpen`: a closed box only opens when its lid has room
/// (`level.noCollision` of the half-block lid box, deflated by 1e-6).
pub fn lid_box(pos: [i32; 3], facing: u8) -> [f64; 6] {
let bottom = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]),
f64::from(pos[2]) + 0.5,
];
let mut b = progress_delta_aabb(1.0, facing, 0.0, 0.5, bottom);
for axis in 0..3 {
b[axis] += 1.0e-6;
b[axis + 3] -= 1.0e-6;
}
b
}
}
/// What the bell's tick asks about an entity it gathered when rung.
#[derive(Clone, Copy, Debug)]
pub struct BellEntity {
pub position: [f64; 3],
/// `isAlive() && !isRemoved()`.
pub alive: bool,
/// In `EntityTypeTags.RAIDERS`.
pub raider: bool,
}
/// `BellBlockEntity`.
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Bell {
pub ticks: i32,
pub shaking: bool,
/// `clickDirection` as a 3D data value.
pub click_direction: Option<u8>,
pub last_ring_timestamp: i64,
/// `nearbyEntities`: the living entities around the bell when it was last
/// gathered, as caller-defined handles.
pub nearby: Option<Vec<u64>>,
pub resonating: bool,
pub resonation_ticks: i32,
}
/// Side effects of one [`Bell::tick`].
#[derive(Clone, Debug, Default, PartialEq)]
pub struct BellTick {
/// Resonation started: `BELL_RESONATE` (played by the server).
pub resonate: bool,
/// Resonation ended (`ResonationEndAction`): the client's
/// `showBellParticles` entity-effect particles (position and colour) and
/// the server's `makeRaidersGlow` targets.
pub ended: Option<(Vec<([f64; 3], i32)>, Vec<u64>)>,
}
fn closer_to_center_than(pos: [i32; 3], p: [f64; 3], distance: f64) -> bool {
let dx = f64::from(pos[0]) + 0.5 - p[0];
let dy = f64::from(pos[1]) + 0.5 - p[1];
let dz = f64::from(pos[2]) + 0.5 - p[2];
dx * dx + dy * dy + dz * dz < distance * distance
}
impl Bell {
/// `triggerEvent(1, direction)`. `gather` lists the living entities whose
/// position is inside the bell's block box inflated by 48
/// (`updateEntities`, refreshed at most every 60 ticks).
pub fn trigger_event(
&mut self,
b0: i32,
b1: i32,
game_time: i64,
gather: impl FnOnce() -> Vec<u64>,
) -> bool {
if b0 != 1 {
return false;
}
if game_time > self.last_ring_timestamp + 60 || self.nearby.is_none() {
self.last_ring_timestamp = game_time;
self.nearby = Some(gather());
}
self.resonation_ticks = 0;
self.click_direction = Some(from_3d_data_value(b1));
self.ticks = 0;
self.shaking = true;
true
}
/// `BellBlockEntity.tick` with the client's resonation end action.
pub fn tick(&mut self, pos: [i32; 3], entity: impl Fn(u64) -> Option<BellEntity>) -> BellTick {
let mut out = BellTick::default();
if self.shaking {
self.ticks += 1;
}
if self.ticks >= 50 {
self.shaking = false;
self.ticks = 0;
}
let nearby = self.nearby.clone().unwrap_or_default();
let raider_in = |e: &BellEntity, distance: f64| {
e.alive && closer_to_center_than(pos, e.position, distance) && e.raider
};
if self.ticks >= 5
&& self.resonation_ticks == 0
&& nearby
.iter()
.filter_map(|&h| entity(h))
.any(|e| raider_in(&e, 32.0))
{
self.resonating = true;
out.resonate = true;
}
if self.resonating {
if self.resonation_ticks < 40 {
self.resonation_ticks += 1;
} else {
// showBellParticles / makeRaidersGlow.
let mut color = 16_700_985;
let views: Vec<(u64, BellEntity)> = nearby
.iter()
.filter_map(|&h| Some((h, entity(h)?)))
.collect();
let count = views
.iter()
.filter(|(_, e)| closer_to_center_than(pos, e.position, 48.0))
.count() as i32;
let per = ((count - 21) / -2).clamp(3, 15);
let mut particles = Vec::new();
let mut glow = Vec::new();
for (h, e) in &views {
if !raider_in(e, 48.0) {
continue;
}
glow.push(*h);
let (bx, bz) = (f64::from(pos[0]), f64::from(pos[2]));
let dist = ((e.position[0] - bx) * (e.position[0] - bx)
+ (e.position[2] - bz) * (e.position[2] - bz))
.sqrt();
let x3 = bx + f64::from(0.5f32) + 1.0 / dist * (e.position[0] - bx);
let z3 = bz + f64::from(0.5f32) + 1.0 / dist * (e.position[2] - bz);
for _ in 0..per {
color += 5;
particles.push(([x3, f64::from(pos[1]) + 0.5, z3], color));
}
}
out.ended = Some((particles, glow));
self.resonating = false;
}
}
out
}
}
/// The conduit's `destroyTarget` as the client sees it.
#[derive(Clone, Copy, Debug)]
pub struct ConduitTarget {
pub position: [f64; 3],
pub eye_y: f64,
pub width: f32,
pub height: f32,
}
/// `ConduitBlockEntity` (client side).
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Conduit {
pub tick_count: i32,
pub active_rotation: f32,
pub is_active: bool,
pub is_hunting: bool,
pub effect_blocks: Vec<[i32; 3]>,
}
/// One nautilus particle: position and velocity.
pub type Particle = ([f64; 3], [f64; 3]);
impl Conduit {
/// `updateShape`: water all around, then the frame blocks
/// (`BlockTags.CONDUIT_EFFECT_BLOCK`) in the three 5x5 rings.
fn update_shape(
&mut self,
pos: [i32; 3],
is_water: &impl Fn([i32; 3]) -> bool,
is_frame: &impl Fn([i32; 3]) -> bool,
) -> bool {
self.effect_blocks.clear();
for ox in -1..=1 {
for oy in -1..=1 {
for oz in -1..=1 {
if !is_water([pos[0] + ox, pos[1] + oy, pos[2] + oz]) {
return false;
}
}
}
}
for ox in -2i32..=2 {
for oy in -2i32..=2 {
for oz in -2i32..=2 {
let (ax, ay, az) = (ox.abs(), oy.abs(), oz.abs());
if (ax > 1 || ay > 1 || az > 1)
&& (ox == 0 && (ay == 2 || az == 2)
|| oy == 0 && (ax == 2 || az == 2)
|| oz == 0 && (ax == 2 || ay == 2))
{
let p = [pos[0] + ox, pos[1] + oy, pos[2] + oz];
if is_frame(p) {
self.effect_blocks.push(p);
}
}
}
}
}
self.effect_blocks.len() >= 16
}
/// `ConduitBlockEntity.clientTick`: returns the nautilus particles of
/// `animationTick`, drawn from the client level's random.
pub fn client_tick(
&mut self,
pos: [i32; 3],
game_time: i64,
is_water: impl Fn([i32; 3]) -> bool,
is_frame: impl Fn([i32; 3]) -> bool,
target: Option<ConduitTarget>,
random: &mut impl RandomSource,
) -> Vec<Particle> {
self.tick_count += 1;
if game_time % 40 == 0 {
self.is_active = self.update_shape(pos, &is_water, &is_frame);
self.is_hunting = self.effect_blocks.len() >= 42;
}
let particles = self.animation_tick(pos, target, random);
if self.is_active {
self.active_rotation += 1.0;
}
particles
}
fn animation_tick(
&self,
pos: [i32; 3],
target: Option<ConduitTarget>,
random: &mut impl RandomSource,
) -> Vec<Particle> {
let mut out = Vec::new();
let hh = f64::from(((self.tick_count + 35) as f32 * 0.1).msin() / 2.0 + 0.5);
let hh = (hh * hh + hh) * f64::from(0.3f32);
let end = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 1.5 + hh,
f64::from(pos[2]) + 0.5,
];
for p in &self.effect_blocks {
if random.next_int_bound(50).unwrap_or(1) == 0 {
let dx = -0.5 + random.next_float() + (p[0] - pos[0]) as f32;
let dy = -2.0 + random.next_float() + (p[1] - pos[1]) as f32;
let dz = -0.5 + random.next_float() + (p[2] - pos[2]) as f32;
out.push((end, [f64::from(dx), f64::from(dy), f64::from(dz)]));
}
}
if let Some(t) = target {
let randx = (-0.5 + random.next_float()) * (3.0 + t.width);
let randy = -1.0 + random.next_float() * t.height;
let randz = (-0.5 + random.next_float()) * (3.0 + t.width);
out.push((
[t.position[0], t.eye_y, t.position[2]],
[f64::from(randx), f64::from(randy), f64::from(randz)],
));
}
out
}
}
/// `SkullBlockEntity` animation (client ticker of dragon and piglin heads).
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct Skull {
pub animating: bool,
pub tick_count: i32,
}
impl Skull {
/// `SkullBlockEntity.animation`.
pub fn tick(&mut self, powered: bool) {
if powered {
self.animating = true;
self.tick_count += 1;
} else {
self.animating = false;
}
}
/// `getAnimation(a)`.
pub fn get_animation(&self, a: f32) -> f32 {
if self.animating {
self.tick_count as f32 + a
} else {
self.tick_count as f32
}
}
}
/// `DecoratedPotBlockEntity.WobbleStyle`.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum WobbleStyle {
Positive,
Negative,
}
impl WobbleStyle {
pub fn duration(self) -> i32 {
match self {
Self::Positive => 7,
Self::Negative => 10,
}
}
}
/// `DecoratedPotBlockEntity` wobble fields.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct PotWobble {
pub started_at: i64,
pub style: Option<WobbleStyle>,
}
impl PotWobble {
/// `triggerEvent(1, style)`.
pub fn trigger_event(&mut self, event: i32, data: i32, game_time: i64) -> bool {
if event == 1 && (0..2).contains(&data) {
self.started_at = game_time;
self.style = Some(if data == 0 {
WobbleStyle::Positive
} else {
WobbleStyle::Negative
});
true
} else {
false
}
}
/// `DecoratedPotRenderer.extractRenderState`'s `wobbleProgress`.
pub fn progress(&self, game_time: i64, partial: f32) -> f32 {
match self.style {
Some(style) => {
((game_time - self.started_at) as f32 + partial) / style.duration() as f32
}
None => 0.0,
}
}
}
/// The client ticker a block entity runs (`getTicker` on a client level),
/// or [`Ticker::Pot`] for decorated pots, which keep wobble state unticked.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Ticker {
/// `ChestBlockEntity` / `EnderChestBlockEntity.lidAnimateTick`.
Lid,
/// `EnchantingTableBlockEntity.bookAnimationTick`.
Book,
/// `ShulkerBoxBlockEntity.tick`, with the box's facing.
Shulker {
facing: u8,
},
/// `BellBlockEntity.clientTick`.
Bell,
/// `ConduitBlockEntity.clientTick`.
Conduit,
/// `SkullBlockEntity.animation` (dragon and piglin heads only).
Skull {
powered: bool,
},
Pot,
}
/// The [`Ticker`] of a block state, by block name and property lookup.
pub fn ticker(name: &str, prop: impl Fn(&str) -> Option<String>) -> Option<Ticker> {
let name = name.strip_prefix("minecraft:").unwrap_or(name);
let unwaxed = name.strip_prefix("waxed_").unwrap_or(name);
Some(match unwaxed {
"chest"
| "trapped_chest"
| "ender_chest"
| "copper_chest"
| "exposed_copper_chest"
| "weathered_copper_chest"
| "oxidized_copper_chest" => Ticker::Lid,
"enchanting_table" => Ticker::Book,
"bell" => Ticker::Bell,
"conduit" => Ticker::Conduit,
"decorated_pot" => Ticker::Pot,
"dragon_head" | "dragon_wall_head" | "piglin_head" | "piglin_wall_head" => Ticker::Skull {
powered: prop("powered").as_deref() == Some("true"),
},
n if n.ends_with("shulker_box") => Ticker::Shulker {
facing: prop("facing")
.as_deref()
.and_then(direction_index)
.unwrap_or(1),
},
_ => return None,
})
}
/// The animated block entities of a level, by position.
#[derive(Clone, Debug)]
pub struct BlockEntityAnims {
pub chests: HashMap<[i32; 3], ChestLid>,
pub books: HashMap<[i32; 3], EnchantingBook>,
/// `EnchantingTableBlockEntity.RANDOM`, shared by every table.
pub book_random: LegacyRandom,
pub shulkers: HashMap<[i32; 3], Shulker>,
pub bells: HashMap<[i32; 3], Bell>,
pub conduits: HashMap<[i32; 3], Conduit>,
pub skulls: HashMap<[i32; 3], Skull>,
pub pots: HashMap<[i32; 3], PotWobble>,
/// The client level's random (conduit particles).
pub random: LegacyRandom,
/// [`Ticker`] by block state id (see [`BlockEntityAnims::classify`]).
pub kinds: Vec<Option<Ticker>>,
}
impl Default for BlockEntityAnims {
fn default() -> Self {
Self {
chests: HashMap::new(),
books: HashMap::new(),
book_random: LegacyRandom::new(0x626f6f6b),
shulkers: HashMap::new(),
bells: HashMap::new(),
conduits: HashMap::new(),
skulls: HashMap::new(),
pots: HashMap::new(),
random: LegacyRandom::new(0x6c6576656c),
kinds: Vec::new(),
}
}
}
impl BlockEntityAnims {
/// Fills [`Self::kinds`] for every state of the registry.
pub fn classify(&mut self, registry: &horizon_world::registry::BlockRegistry) {
self.kinds = (0..registry.len() as u32)
.map(|id| {
registry
.state(horizon_world::registry::BlockStateId(id))
.and_then(|s| ticker(&s.name, |k| s.properties.get(k).cloned()))
})
.collect();
}
/// The ticker of a block state.
pub fn ticker(&self, state: u32) -> Option<Ticker> {
self.kinds.get(state as usize).copied().flatten()
}
/// Drops the state of block entities that are gone or changed kind:
/// `present` maps the loaded block entities to their tickers.
pub fn retain_present(&mut self, present: &HashMap<[i32; 3], Ticker>) {
let is = |p: &[i32; 3], f: fn(&Ticker) -> bool| present.get(p).is_some_and(f);
self.chests.retain(|p, _| is(p, |t| *t == Ticker::Lid));
self.books.retain(|p, _| is(p, |t| *t == Ticker::Book));
self.shulkers
.retain(|p, _| is(p, |t| matches!(t, Ticker::Shulker { .. })));
self.bells.retain(|p, _| is(p, |t| *t == Ticker::Bell));
self.conduits
.retain(|p, _| is(p, |t| *t == Ticker::Conduit));
self.skulls
.retain(|p, _| is(p, |t| matches!(t, Ticker::Skull { .. })));
self.pots.retain(|p, _| is(p, |t| *t == Ticker::Pot));
}
/// Drops the state of positions whose block entity is gone.
pub fn retain(&mut self, keep: impl Fn([i32; 3]) -> bool) {
self.chests.retain(|p, _| keep(*p));
self.books.retain(|p, _| keep(*p));
self.shulkers.retain(|p, _| keep(*p));
self.bells.retain(|p, _| keep(*p));
self.conduits.retain(|p, _| keep(*p));
self.skulls.retain(|p, _| keep(*p));
self.pots.retain(|p, _| keep(*p));
}
/// `getOpenNess(partial)` of the chest or ender chest at `pos`.
pub fn lid(&self, pos: [i32; 3], partial: f32) -> f32 {
self.chests
.get(&pos)
.map_or(0.0, |l| l.get_openness(partial))
}
/// The renderer's view of the block entity at `pos`; `sherds` are the
/// pot's decorations (back, left, right, front), when it is a pot.
pub fn block_anim(
&self,
pos: [i32; 3],
game_time: i64,
partial: f32,
sherds: [Option<String>; 4],
) -> BlockAnim {
let mut anim = BlockAnim {
sherds,
// End gateway ages and cooldowns are not tracked on the client yet:
// gateways render settled (no beam).
gateway: (i64::MAX, 0),
..BlockAnim::default()
};
if let Some(b) = self.books.get(&pos) {
anim.book = *b;
}
if let Some(s) = self.shulkers.get(&pos) {
anim.shulker = (s.progress, s.progress_old);
}
if let Some(b) = self.bells.get(&pos) {
anim.bell_ticks = b.ticks;
anim.bell_shake = b
.shaking
.then_some(b.click_direction)
.flatten()
.map(|d| DIRECTIONS[usize::from(d)]);
}
if let Some(c) = self.conduits.get(&pos) {
anim.conduit = (c.tick_count, c.is_active, c.is_hunting, c.active_rotation);
}
if let Some(s) = self.skulls.get(&pos) {
anim.skull = (s.animating, s.tick_count);
}
if let Some(w) = self.pots.get(&pos)
&& w.style.is_some()
{
// extractRenderState never copies lastWobbleStyle into the render
// state, so DecoratedPotRenderer.submit always takes the
// non-POSITIVE branch (with the style's own duration).
anim.wobble = Some((false, w.progress(game_time, partial)));
}
anim
}
}
File diff suppressed because it is too large. Load diff
+783
View File
@@ -0,0 +1,783 @@
//! The camera (`Camera.tick`, `tickFov`, `calculateFov`, `alignWithEntity`,
//! `getMaxZoom`, `setRotation`, `move`), view and hurt bobbing
//! (`GameRenderer.bobView`, `bobHurt`), the walk and bob history they read
//! (`ClientAvatarState`), and `AbstractClientPlayer.getFieldOfViewModifier`.
//!
//! The float arithmetic follows vanilla and JOML's default (non-FMA,
//! non-fast-math) code paths operation by operation.
use horizon_core::geometry::trig;
/// `(float) Math.PI`.
const PI: f32 = std::f32::consts::PI;
/// `(float) (Math.PI / 180.0)`.
const DEG_TO_RAD: f32 = trig::DEG_TO_RAD;
/// `Math.min(float, float)`: NaN wins and -0.0 is below 0.0.
pub fn java_min(a: f32, b: f32) -> f32 {
if a.is_nan() || b.is_nan() {
f32::NAN
} else if a == 0.0 && b == 0.0 {
f32::from_bits(a.to_bits() | b.to_bits())
} else if a <= b {
a
} else {
b
}
}
/// `Math.max(float, float)`.
pub fn java_max(a: f32, b: f32) -> f32 {
if a.is_nan() || b.is_nan() {
f32::NAN
} else if a == 0.0 && b == 0.0 {
f32::from_bits(a.to_bits() & b.to_bits())
} else if a >= b {
a
} else {
b
}
}
/// `Mth.lerp(float, float, float)`.
pub fn lerp(alpha: f32, p0: f32, p1: f32) -> f32 {
p0 + alpha * (p1 - p0)
}
/// `Mth.lerp(double, double, double)`.
pub fn lerp_d(alpha: f64, p0: f64, p1: f64) -> f64 {
p0 + alpha * (p1 - p0)
}
/// `Mth.wrapDegrees(float)`.
pub fn wrap_degrees(angle: f32) -> f32 {
let mut normalized = angle % 360.0;
if normalized >= 180.0 {
normalized -= 360.0;
}
if normalized < -180.0 {
normalized += 360.0;
}
normalized
}
/// `Mth.clamp(float, float, float)`.
pub fn clamp(value: f32, min: f32, max: f32) -> f32 {
if value < min {
min
} else {
java_min(value, max)
}
}
/// JOML's float trigonometry (`org.joml.Math` without fast math).
pub mod joml {
use super::PI;
const TAU: f32 = std::f32::consts::TAU;
const HALF_PI: f32 = std::f32::consts::FRAC_PI_2;
/// `Math.sin(float)`: `java.lang.Math.sin` narrowed.
pub fn sin(rad: f32) -> f32 {
f64::from(rad).sin() as f32
}
/// `Math.cosFromSin(float, float)`.
pub fn cos_from_sin(sin: f32, angle: f32) -> f32 {
let cos = (1.0 - sin * sin).sqrt();
let a = angle + HALF_PI;
let mut b = a - (a / TAU) as i32 as f32 * TAU;
if f64::from(b) < 0.0 {
b += TAU;
}
if b >= PI { -cos } else { cos }
}
/// A JOML `Quaternionf` (x, y, z, w).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Quat {
pub x: f32,
pub y: f32,
pub z: f32,
pub w: f32,
}
impl Quat {
pub const IDENTITY: Self = Self {
x: 0.0,
y: 0.0,
z: 0.0,
w: 1.0,
};
/// `Quaternionf.rotationYXZ(angleY, angleX, angleZ)`.
pub fn rotation_yxz(angle_y: f32, angle_x: f32, angle_z: f32) -> Self {
let sx = sin(angle_x * 0.5);
let cx = cos_from_sin(sx, angle_x * 0.5);
let sy = sin(angle_y * 0.5);
let cy = cos_from_sin(sy, angle_y * 0.5);
let sz = sin(angle_z * 0.5);
let cz = cos_from_sin(sz, angle_z * 0.5);
let x = cy * sx;
let y = sy * cx;
let z = sy * sx;
let w = cy * cx;
Self {
x: x * cz + y * sz,
y: y * cz - x * sz,
z: w * sz - z * cz,
w: w * cz + z * sz,
}
}
/// `Quaternionf.transform(x, y, z, dest)` (`Vector3f.rotate`).
pub fn transform(&self, v: [f32; 3]) -> [f32; 3] {
let [x, y, z] = v;
let xx = self.x * self.x;
let yy = self.y * self.y;
let zz = self.z * self.z;
let ww = self.w * self.w;
let xy = self.x * self.y;
let xz = self.x * self.z;
let yz = self.y * self.z;
let xw = self.x * self.w;
let zw = self.z * self.w;
let yw = self.y * self.w;
let k = 1.0 / (xx + yy + zz + ww);
[
(xx - yy - zz + ww) * k * x + (2.0 * (xy - zw) * k * y + 2.0 * (xz + yw) * k * z),
2.0 * (xy + zw) * k * x + ((yy - xx - zz + ww) * k * y + 2.0 * (yz - xw) * k * z),
2.0 * (xz - yw) * k * x + (2.0 * (yz + xw) * k * y + (zz - xx - yy + ww) * k * z),
]
}
}
const PERSPECTIVE: u8 = 1;
const IDENTITY: u8 = 4;
const TRANSLATION: u8 = 8;
/// A JOML `Matrix4f` with its property flags, which select the code
/// paths (and so the rounding) of later operations. `m[column * 4 + row]`.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Mat4 {
pub m: [f32; 16],
pub properties: u8,
}
impl Default for Mat4 {
fn default() -> Self {
Self::IDENTITY
}
}
impl Mat4 {
/// `new Matrix4f()`.
pub const IDENTITY: Self = Self {
m: [
1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0, 0.0, 0.0, 0.0, 0.0, 1.0,
],
properties: 30,
};
fn get(&self, column: usize, row: usize) -> f32 {
self.m[column * 4 + row]
}
fn set(&mut self, column: usize, row: usize, v: f32) {
self.m[column * 4 + row] = v;
}
fn reset_unless_identity(&mut self) {
if self.properties & IDENTITY == 0 {
self.m = Self::IDENTITY.m;
}
}
/// `Matrix4f.translation(x, y, z)`.
pub fn translation(&mut self, x: f32, y: f32, z: f32) {
self.reset_unless_identity();
self.set(3, 0, x);
self.set(3, 1, y);
self.set(3, 2, z);
self.properties = 26;
}
/// `Matrix4f.setTranslation(x, y, z)`.
pub fn set_translation(&mut self, x: f32, y: f32, z: f32) {
self.set(3, 0, x);
self.set(3, 1, y);
self.set(3, 2, z);
self.properties &= !(PERSPECTIVE | IDENTITY);
}
/// `Matrix4f.translate(x, y, z)`.
pub fn translate(&mut self, x: f32, y: f32, z: f32) {
if self.properties & IDENTITY != 0 {
self.translation(x, y, z);
return;
}
for row in 0..4 {
let v = self.get(0, row) * x
+ (self.get(1, row) * y + (self.get(2, row) * z + self.get(3, row)));
self.set(3, row, v);
}
self.properties &= !(PERSPECTIVE | IDENTITY);
}
fn rotation_axis(&mut self, axis: usize, ang: f32) {
let sin = sin(ang);
let cos = cos_from_sin(sin, ang);
self.reset_unless_identity();
match axis {
0 => {
self.set(1, 1, cos);
self.set(1, 2, sin);
self.set(2, 1, -sin);
self.set(2, 2, cos);
}
1 => {
self.set(0, 0, cos);
self.set(0, 2, -sin);
self.set(2, 0, sin);
self.set(2, 2, cos);
}
_ => {
self.set(0, 0, cos);
self.set(0, 1, sin);
self.set(1, 0, -sin);
self.set(1, 1, cos);
}
}
self.properties = 18;
}
/// `Matrix4f.rotateX/rotateY/rotateZ(ang)`, with the identity and
/// pure-translation shortcuts.
fn rotate_axis(&mut self, axis: usize, ang: f32) {
if self.properties & IDENTITY != 0 {
self.rotation_axis(axis, ang);
return;
}
if self.properties & TRANSLATION != 0 {
let (x, y, z) = (self.get(3, 0), self.get(3, 1), self.get(3, 2));
self.rotation_axis(axis, ang);
self.set_translation(x, y, z);
return;
}
let sin = sin(ang);
let cos = cos_from_sin(sin, ang);
let s = *self;
match axis {
0 => {
for row in 0..4 {
let (l1, l2) = (s.get(1, row), s.get(2, row));
self.set(2, row, l1 * -sin + l2 * cos);
self.set(1, row, l1 * cos + l2 * sin);
}
}
1 => {
for row in 0..4 {
let (l0, l2) = (s.get(0, row), s.get(2, row));
self.set(0, row, l0 * cos + l2 * -sin);
self.set(2, row, l0 * sin + l2 * cos);
}
}
_ => {
// rotateTowardsXY(sin, cos).
let (dx, dy) = (sin, cos);
for row in 0..4 {
let (l0, l1) = (s.get(0, row), s.get(1, row));
self.set(0, row, l0 * dy + l1 * dx);
self.set(1, row, l0 * -dx + l1 * dy);
}
}
}
self.properties &= !(PERSPECTIVE | IDENTITY | TRANSLATION);
}
pub fn rotate_x(&mut self, ang: f32) {
self.rotate_axis(0, ang);
}
pub fn rotate_y(&mut self, ang: f32) {
self.rotate_axis(1, ang);
}
pub fn rotate_z(&mut self, ang: f32) {
// rotateTowardsXY also short-cuts on identity, but rotateZ has
// already taken that branch.
self.rotate_axis(2, ang);
}
}
}
use joml::{Mat4, Quat};
/// `CameraType`.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CameraType {
#[default]
FirstPerson,
ThirdPersonBack,
ThirdPersonFront,
}
impl CameraType {
pub fn is_first_person(self) -> bool {
self == Self::FirstPerson
}
pub fn is_mirrored(self) -> bool {
self == Self::ThirdPersonFront
}
/// `CameraType.cycle` (F5).
pub fn cycle(self) -> Self {
match self {
Self::FirstPerson => Self::ThirdPersonBack,
Self::ThirdPersonBack => Self::ThirdPersonFront,
Self::ThirdPersonFront => Self::FirstPerson,
}
}
}
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct FovPlayer {
/// `Abilities.flying`.
pub flying: bool,
/// `Abilities.getWalkingSpeed()`.
pub walking_speed: f32,
/// `getAttributeValue(MOVEMENT_SPEED)`.
pub movement_speed: f64,
/// The item in use and its use ticks, when using one.
pub using: Option<(UseItem, i32)>,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum UseItem {
Bow,
Spyglass,
Other,
}
/// `AbstractClientPlayer.getFieldOfViewModifier(firstPerson, effectScale)`.
pub fn field_of_view_modifier(p: &FovPlayer, first_person: bool, effect_scale: f32) -> f32 {
let mut modifier = 1.0f32;
if p.flying {
modifier *= 1.1;
}
let walking_speed = p.walking_speed;
if walking_speed != 0.0 {
let speed_factor = p.movement_speed as f32 / walking_speed;
modifier *= (speed_factor + 1.0) / 2.0;
}
if let Some((item, ticks)) = p.using {
match item {
UseItem::Bow => {
let scale = java_min(ticks as f32 / 20.0, 1.0);
modifier *= 1.0 - scale * scale * 0.15;
}
// isScoping: using a spyglass.
UseItem::Spyglass if first_person => return 0.1,
_ => {}
}
}
lerp(effect_scale, 1.0, modifier)
}
/// The camera entity as `Camera.alignWithEntity` and the field-of-view
/// death and hurt effects see it.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraEntity {
pub position: [f64; 3],
/// `xo`, `yo`, `zo`.
pub previous: [f64; 3],
pub x_rot: f32,
pub y_rot: f32,
pub x_rot_o: f32,
pub y_rot_o: f32,
/// `Entity.getEyeHeight()`.
pub eye_height: f32,
/// A living entity's `getScale()` and camera distance attribute.
pub living: Option<LivingCamera>,
/// The vehicle's scale and camera distance when it is living.
pub mount: Option<(f32, f64)>,
}
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct LivingCamera {
pub scale: f32,
pub camera_distance: f64,
/// `isSleeping()` with `getBedOrientation()` as its `toYRot()`.
pub sleeping: Option<Option<f32>>,
/// `isDeadOrDying()` and `deathTime`.
pub dead: Option<i32>,
}
impl CameraEntity {
/// `Entity.getXRot(partialTicks)`.
pub fn view_x_rot(&self, partial: f32) -> f32 {
if partial == 1.0 {
self.x_rot
} else {
lerp(partial, self.x_rot_o, self.x_rot)
}
}
/// `LocalPlayer.getViewYRot` (`getYRot(partialTicks)`, a `Mth.rotLerp`).
pub fn view_y_rot(&self, partial: f32) -> f32 {
if partial == 1.0 {
self.y_rot
} else {
self.y_rot_o + partial * wrap_degrees(self.y_rot - self.y_rot_o)
}
}
}
/// The fluid `Camera.getFluidInCamera` reports, as the field of view sees it.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum CameraFluid {
#[default]
None,
Water,
Lava,
PowderSnow,
}
/// `Camera`: the eye-height and field-of-view easing and the view placement.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct Camera {
pub eye_height: f32,
pub eye_height_old: f32,
pub fov_modifier: f32,
pub old_fov_modifier: f32,
pub position: [f64; 3],
pub x_rot: f32,
pub y_rot: f32,
pub rotation: Quat,
pub forwards: [f32; 3],
pub up: [f32; 3],
pub left: [f32; 3],
pub detached: bool,
}
impl Default for Camera {
fn default() -> Self {
Self {
eye_height: 0.0,
eye_height_old: 0.0,
fov_modifier: 0.0,
old_fov_modifier: 0.0,
position: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
rotation: Quat::IDENTITY,
forwards: [0.0, 0.0, -1.0],
up: [0.0, 1.0, 0.0],
left: [-1.0, 0.0, 0.0],
detached: false,
}
}
}
impl Camera {
/// `Camera.tick`: the eye height eases halfway toward the entity's
/// (with a level and an entity), then `tickFov` with the camera entity's
/// field-of-view modifier target (1 when it is not a player).
pub fn tick(&mut self, entity_eye_height: Option<f32>, target_fov_modifier: f32) {
if let Some(eye) = entity_eye_height {
self.eye_height_old = self.eye_height;
self.eye_height += (eye - self.eye_height) * 0.5;
}
self.old_fov_modifier = self.fov_modifier;
self.fov_modifier += (target_fov_modifier - self.fov_modifier) * 0.5;
self.fov_modifier = clamp(self.fov_modifier, 0.1, 1.5);
}
/// `Camera.setRotation`.
pub fn set_rotation(&mut self, y_rot: f32, x_rot: f32) {
self.x_rot = x_rot;
self.y_rot = y_rot;
self.rotation = Quat::rotation_yxz(PI - y_rot * DEG_TO_RAD, -x_rot * DEG_TO_RAD, 0.0);
self.forwards = self.rotation.transform([0.0, 0.0, -1.0]);
self.up = self.rotation.transform([0.0, 1.0, 0.0]);
self.left = self.rotation.transform([-1.0, 0.0, 0.0]);
}
/// `Camera.move(forwards, up, right)`.
pub fn move_by(&mut self, forwards: f32, up: f32, right: f32) {
let offset = self.rotation.transform([right, up, -forwards]);
self.position = [
self.position[0] + f64::from(offset[0]),
self.position[1] + f64::from(offset[1]),
self.position[2] + f64::from(offset[2]),
];
}
/// `Camera.alignWithEntity` (not riding a minecart with the new
/// movement): the entity's view rotation and interpolated eye, then a
/// detached camera backed off along the view (`getMaxZoom`) or a sleeping
/// one turned to the bed. `clip` is `Level.clip` with
/// `ClipContext.Block.VISUAL` and no fluids; it returns the hit location
/// unless the ray misses.
pub fn align_with_entity(
&mut self,
entity: &CameraEntity,
partial: f32,
camera_type: CameraType,
mut clip: impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) {
self.set_rotation(entity.view_y_rot(partial), entity.view_x_rot(partial));
let p = f64::from(partial);
self.position = [
lerp_d(p, entity.previous[0], entity.position[0]),
lerp_d(p, entity.previous[1], entity.position[1])
+ f64::from(lerp(partial, self.eye_height_old, self.eye_height)),
lerp_d(p, entity.previous[2], entity.position[2]),
];
self.detached = !camera_type.is_first_person();
if self.detached {
if camera_type.is_mirrored() {
self.set_rotation(self.y_rot + 180.0, -self.x_rot);
}
let (mut distance, mut scale) = (4.0f32, 1.0f32);
if let Some(living) = &entity.living {
scale = living.scale;
distance = living.camera_distance as f32;
}
let (mut mount_scale, mut mount_distance) = (scale, distance);
if let Some((s, d)) = entity.mount {
mount_scale = s;
mount_distance = d as f32;
}
let zoom = self.max_zoom(
java_max(scale * distance, mount_scale * mount_distance),
&mut clip,
);
self.move_by(-zoom, 0.0, 0.0);
} else if let Some(LivingCamera {
sleeping: Some(bed),
..
}) = entity.living
{
self.set_rotation(bed.map_or(0.0, |yaw| yaw - 180.0), 0.0);
self.move_by(0.0, 0.15, 0.0);
}
}
/// `Camera.getMaxZoom`: eight rays from the corners of a 0.2 cube about
/// the eye, shortening the distance to the nearest visual-shape hit.
pub fn max_zoom(
&self,
mut distance: f32,
clip: &mut impl FnMut([f64; 3], [f64; 3]) -> Option<[f64; 3]>,
) -> f32 {
let at = self.position;
for i in 0..8 {
let offset = [
((i & 1) * 2 - 1) as f32,
((i >> 1 & 1) * 2 - 1) as f32,
((i >> 2 & 1) * 2 - 1) as f32,
];
let from: [f64; 3] = std::array::from_fn(|a| at[a] + f64::from(offset[a] * 0.1));
let back = f64::from(-distance);
let to: [f64; 3] =
std::array::from_fn(|a| from[a] + f64::from(self.forwards[a]) * back);
if let Some(hit) = clip(from, to) {
let d = [hit[0] - at[0], hit[1] - at[1], hit[2] - at[2]];
let dist_sq = (d[0] * d[0] + d[1] * d[1] + d[2] * d[2]) as f32;
if dist_sq < distance * distance {
distance = dist_sq.sqrt();
}
}
}
distance
}
/// `Camera.calculateFov`: the option scaled by the eased modifier, then
/// the death and fluid effects.
pub fn calculate_fov(
&self,
partial: f32,
fov_option: i32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
panoramic: bool,
) -> f32 {
if panoramic {
return 90.0;
}
let fov = fov_option as f32 * lerp(partial, self.old_fov_modifier, self.fov_modifier);
modify_fov_based_on_death_or_fluid(partial, fov, effect_scale, dead, fluid)
}
/// `Camera.calculateHudFov`.
pub fn calculate_hud_fov(
&self,
partial: f32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
) -> f32 {
modify_fov_based_on_death_or_fluid(partial, 70.0, effect_scale, dead, fluid)
}
}
/// `Camera.modifyFovBasedOnDeathOrFluid`. `dead` is the camera entity's
/// `deathTime` while it is a living entity that is dead or dying.
pub fn modify_fov_based_on_death_or_fluid(
partial: f32,
mut fov: f32,
effect_scale: f32,
dead: Option<i32>,
fluid: CameraFluid,
) -> f32 {
if let Some(death_time) = dead {
let duration = java_min(death_time as f32 + partial, 20.0);
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
}
if matches!(fluid, CameraFluid::Lava | CameraFluid::Water) {
fov *= lerp(effect_scale, 1.0, 0.857_142_87);
}
fov
}
/// `ClientAvatarState`: the walked distance and view bob of a player.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct AvatarBob {
pub walk_dist: f32,
pub walk_dist_o: f32,
pub bob: f32,
pub bob_o: f32,
}
impl AvatarBob {
/// `ClientAvatarState.tick` (at the start of `AbstractClientPlayer.tick`).
pub fn tick(&mut self) {
self.walk_dist_o = self.walk_dist;
}
/// `LocalPlayer.move`: `addWalkedDistance(Mth.length(dx, dz) * 0.6F)`
/// with the float horizontal displacement.
pub fn add_walked(&mut self, dx: f32, dz: f32) {
self.walk_dist += (dx * dx + dz * dz).sqrt() * 0.6;
}
/// `AbstractClientPlayer.updateBob`: toward the horizontal speed (at most
/// 0.1) on the ground, toward 0 otherwise.
pub fn update_bob(&mut self, on_ground_alive_not_swimming: bool, velocity: [f64; 3]) {
let target = if on_ground_alive_not_swimming {
java_min(
0.1,
(velocity[0] * velocity[0] + velocity[2] * velocity[2]).sqrt() as f32,
)
} else {
0.0
};
self.bob_o = self.bob;
self.bob += (target - self.bob) * 0.4;
}
/// `ClientAvatarState.resetBob` (`AbstractClientPlayer.rideTick`).
pub fn reset_bob(&mut self) {
self.bob_o = self.bob;
self.bob = 0.0;
}
pub fn interpolated_bob(&self, partial: f32) -> f32 {
lerp(partial, self.bob_o, self.bob)
}
pub fn backwards_interpolated_walk_distance(&self, partial: f32) -> f32 {
let wda = self.walk_dist - self.walk_dist_o;
-(self.walk_dist + wda * partial)
}
}
/// The parts of `CameraRenderState.entityRenderState` the bobbing reads.
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct BobState {
/// A living camera entity: `hurtTime - partial`, `hurtDuration`,
/// `getHurtDir()`, and `deathTime + partial` when dead or dying.
pub living: Option<HurtBob>,
/// A player camera entity: the backwards-interpolated walk distance and
/// the interpolated bob.
pub player: Option<(f32, f32)>,
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub struct HurtBob {
pub hurt_time: f32,
pub hurt_duration: i32,
pub hurt_dir: f32,
pub death_time: Option<f32>,
}
/// `PoseStack.rotateDegrees` on the pose with an `Axis`.
fn rotate_degrees(m: &mut Mat4, axis: usize, degrees: f32) {
let ang = degrees * DEG_TO_RAD;
match axis {
0 => m.rotate_x(ang),
1 => m.rotate_y(ang),
_ => m.rotate_z(ang),
}
}
/// `GameRenderer.bobHurt`.
pub fn bob_hurt(state: &BobState, damage_tilt_strength: f64, pose: &mut Mat4) {
let Some(living) = state.living else { return };
let mut hurt = living.hurt_time;
if let Some(death_time) = living.death_time {
let duration = java_min(death_time, 20.0);
rotate_degrees(pose, 2, 40.0 - 8000.0 / (duration + 200.0));
}
if hurt < 0.0 {
return;
}
hurt /= living.hurt_duration as f32;
hurt = trig::sin(f64::from(hurt * hurt * hurt * hurt * PI));
let rr = living.hurt_dir;
rotate_degrees(pose, 1, -rr);
let tilt = (f64::from(-hurt) * 14.0 * damage_tilt_strength) as f32;
rotate_degrees(pose, 2, tilt);
rotate_degrees(pose, 1, rr);
}
/// `GameRenderer.bobView`.
pub fn bob_view(state: &BobState, pose: &mut Mat4) {
let Some((walk, bob)) = state.player else {
return;
};
pose.translate(
trig::sin(f64::from(walk * PI)) * bob * 0.5,
-(trig::cos(f64::from(walk * PI)) * bob).abs(),
0.0,
);
rotate_degrees(pose, 2, trig::sin(f64::from(walk * PI)) * bob * 3.0);
rotate_degrees(
pose,
0,
(trig::cos(f64::from(walk * PI - 0.2)) * bob).abs() * 5.0,
);
}
/// The bob matrix `GameRenderer.renderLevel` multiplies into the projection
/// (and `renderItemInHand` into the hand's pose).
pub fn bob_matrix(state: &BobState, damage_tilt_strength: f64, bob_view_option: bool) -> Mat4 {
let mut pose = Mat4::IDENTITY;
bob_hurt(state, damage_tilt_strength, &mut pose);
if bob_view_option {
bob_view(state, &mut pose);
}
pose
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn camera_eases_eye_height_and_fov() {
let mut camera = Camera::default();
camera.tick(Some(1.62), 1.0);
assert_eq!(camera.eye_height, 0.81);
assert_eq!(camera.fov_modifier, 0.5);
camera.tick(None, 1.0);
assert_eq!(camera.eye_height, 0.81);
assert_eq!(camera.fov_modifier, 0.75);
}
#[test]
fn rotation_faces_south_at_zero_yaw() {
let mut camera = Camera::default();
camera.set_rotation(0.0, 0.0);
assert!((camera.forwards[2] - 1.0).abs() < 1e-6);
camera.set_rotation(90.0, 0.0);
assert!((camera.forwards[0] + 1.0).abs() < 1e-6);
}
#[test]
fn detached_camera_backs_off_until_blocked() {
let entity = CameraEntity {
position: [0.0; 3],
previous: [0.0; 3],
x_rot: 0.0,
y_rot: 0.0,
x_rot_o: 0.0,
y_rot_o: 0.0,
eye_height: 1.62,
living: None,
mount: None,
};
let mut camera = Camera::default();
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| None);
assert!((camera.position[2] + 4.0).abs() < 1e-6);
camera.align_with_entity(&entity, 1.0, CameraType::ThirdPersonBack, |_, _| {
Some([0.0, 0.0, -1.0])
});
assert!(camera.position[2] > -1.1 && camera.position[2] < -0.9);
}
#[test]
fn fov_modifier_combines_speed_flight_and_bow() {
let mut p = FovPlayer {
flying: false,
walking_speed: 0.1,
movement_speed: 0.13,
using: None,
};
assert_eq!(
field_of_view_modifier(&p, true, 1.0),
(0.13f32 / 0.1 + 1.0) / 2.0
);
assert_eq!(field_of_view_modifier(&p, true, 0.0), 1.0);
p.using = Some((UseItem::Spyglass, 3));
assert_eq!(field_of_view_modifier(&p, true, 1.0), 0.1);
assert_ne!(field_of_view_modifier(&p, false, 1.0), 0.1);
}
}
+124 -110
View File
@@ -5,6 +5,7 @@
use crate::{
command::{Dispatcher, ParseResults, Registries, Suggestion},
hud::{HudAtlas, HudBatch, rgba},
hud_layout::{self, GuiDraw, PoseOp},
text::{self, Style, Text},
ui::{EditBox, char_len},
};
@@ -12,15 +13,16 @@ use crate::{
/// GuiMessageTag.systemSinglePlayer's indicator colour.
pub const SYSTEM_INDICATOR: u32 = 0xD0D0D0;
/// The chat options (doubles, as vanilla keeps them).
#[derive(Clone, Copy, Debug)]
pub struct ChatOptions {
pub opacity: f32,
pub background_opacity: f32,
pub scale: f32,
pub width: f32,
pub height_focused: f32,
pub height_unfocused: f32,
pub line_spacing: f32,
pub opacity: f64,
pub background_opacity: f64,
pub scale: f64,
pub width: f64,
pub height_focused: f64,
pub height_unfocused: f64,
pub line_spacing: f64,
}
impl Default for ChatOptions {
fn default() -> Self {
@@ -37,20 +39,21 @@ impl Default for ChatOptions {
}
impl ChatOptions {
/// ChatComponent.getWidth/getHeight.
pub fn width_px(&self) -> f32 {
(self.width * 280.0 + 40.0).floor()
pub fn width_px(&self) -> i32 {
(self.width * 280.0 + 40.0).floor() as i32
}
pub fn height_px(&self, focused: bool) -> f32 {
pub fn height_px(&self, focused: bool) -> i32 {
((if focused {
self.height_focused
} else {
self.height_unfocused
}) * 160.0
+ 20.0)
.floor()
.floor() as i32
}
pub fn line_height(&self) -> f32 {
(9.0 * (self.line_spacing + 1.0)).floor()
/// `getLineHeight`.
pub fn line_height(&self) -> i32 {
(9.0 * (self.line_spacing + 1.0)) as i32
}
}
#[derive(Clone, Debug)]
@@ -59,12 +62,25 @@ struct Message {
content: Text,
indicator: Option<u32>,
}
/// A wrapped line (`GuiMessage.Line`).
#[derive(Clone, Debug)]
struct Line {
added: u64,
content: Text,
indicator: Option<u32>,
}
/// `ChatComponent.AlphaCalculator.timeBased`: opaque for 180 ticks, then
/// fading out quadratically over 20.
fn time_alpha(ticks: u64, added: u64) -> f32 {
let delta = ticks as i64 - added as i64;
let mut t = delta as f64 / 200.0;
t = 1.0 - t;
t *= 10.0;
t = t.clamp(0.0, 1.0);
t *= t;
t as f32
}
#[derive(Default)]
pub struct Chat {
messages: Vec<Message>,
@@ -80,10 +96,11 @@ pub struct Chat {
}
impl Chat {
pub fn lines_per_page(&self, focused: bool) -> usize {
(self.options.height_px(focused) / self.options.line_height().max(1.0)) as usize
(self.options.height_px(focused) / self.options.line_height().max(1)) as usize
}
fn max_width(&self) -> f32 {
(self.options.width_px() / self.options.scale.max(0.01)).floor()
/// The wrapping width, `Mth.floor(getWidth() / getScale())`.
fn max_width(&self) -> i32 {
(f64::from(self.options.width_px()) / self.options.scale).floor() as i32
}
/// ChatComponent.addMessage: wrap, prepend and trim to 100 lines.
pub fn add(
@@ -108,7 +125,7 @@ impl Chat {
}
fn add_lines(&mut self, atlas: &HudAtlas, message: &Message, focused: bool) {
let width = self.max_width();
for line in atlas.font.split(&message.content, width) {
for line in atlas.font.wrap_components(&message.content, width) {
if focused && self.scroll > 0 {
self.new_since_scroll = true;
self.scroll_by(1, focused);
@@ -164,116 +181,113 @@ impl Chat {
self.scroll = pos as usize;
}
}
/// ChatComponent.extractRenderState.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
if self.lines.is_empty() {
return;
/// `ChatComponent.extractRenderState` (background mode unless `focused`)
/// on a screen `height` GUI pixels tall at `Hud.tickCount` `ticks`.
pub fn layout(&self, height: i32, ticks: u64, focused: bool) -> Vec<GuiDraw> {
let total = self.lines.len();
if total == 0 {
return Vec::new();
}
let o = &self.options;
let scale = o.scale;
if scale <= 0.0 {
return;
}
let max_width = (o.width_px() / scale).ceil();
let chat_bottom = ((batch.height - 40.0) / scale).floor();
let text_opacity = o.opacity * 0.9 + 0.1;
let scale = o.scale as f32;
let max_width = hud_layout::ceil(o.width_px() as f32 / scale);
let chat_bottom = hud_layout::floor((height - 40) as f32 / scale);
let text_opacity = o.opacity as f32 * 0.9 + 0.1;
let background_opacity = o.background_opacity as f32;
let entry_height = o.line_height();
let bottom_to_text = (8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing).round();
let per_page = self.lines_per_page(focused);
let alpha_of = |line: &Line| -> f32 {
let bottom_to_text =
(8.0 * (o.line_spacing + 1.0) - 4.0 * o.line_spacing + 0.5).floor() as i32;
let alpha_of = |line: &Line| {
if focused {
return 1.0;
1.0
} else {
time_alpha(ticks, line.added)
}
let t =
((1.0 - (ticks.saturating_sub(line.added)) as f64 / 200.0) * 10.0).clamp(0.0, 1.0);
(t * t) as f32
};
let visible = self.lines.len().saturating_sub(self.scroll).min(per_page);
let mut count = 0;
// Pass 1: backgrounds; pass 2: text (same order as vanilla).
let s = |v: f32| v * scale;
for i in (0..visible).rev() {
let line = &self.lines[i + self.scroll];
let alpha = alpha_of(line);
if alpha <= 1.0e-5 {
continue;
}
count += 1;
let bottom = chat_bottom - i as f32 * entry_height;
let top = bottom - entry_height;
batch.fill(
atlas,
0.0,
s(top),
s(max_width + 8.0),
s(entry_height),
rgba(0, 0, 0, (alpha * o.background_opacity * 255.0) as u8),
);
let visible = total
.saturating_sub(self.scroll)
.min(self.lines_per_page(focused));
let lines: Vec<(usize, &Line, f32)> = (0..visible)
.rev()
.map(|i| (i, &self.lines[i + self.scroll]))
.map(|(i, line)| (i, line, alpha_of(line)))
.filter(|(_, _, alpha)| *alpha > 1.0e-5)
.collect();
let mut out = vec![GuiDraw::Push(vec![
PoseOp::Scale(scale, scale),
PoseOp::Translate(4.0, 0.0),
])];
for &(i, _, alpha) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
max_width + 4 + 4,
bottom,
hud_layout::as8(alpha * background_opacity) << 24,
));
}
for i in (0..visible).rev() {
let line = &self.lines[i + self.scroll];
let alpha = alpha_of(line);
if alpha <= 1.0e-5 {
continue;
for &(i, line, alpha) in &lines {
let bottom = chat_bottom - i as i32 * entry_height;
let opacity = alpha * text_opacity;
let color = hud_layout::white(opacity);
if color >> 24 != 0 {
out.push(GuiDraw::Text {
text: line.content.clone(),
x: 0,
y: bottom - bottom_to_text,
color,
shadow: true,
});
}
let bottom = chat_bottom - i as f32 * entry_height;
let top = bottom - entry_height;
let text_top = bottom - bottom_to_text;
let a = (alpha * text_opacity * 255.0) as u8;
if let Some(c) = line.indicator {
batch.fill(
atlas,
0.0,
s(top),
s(2.0),
s(entry_height),
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, a),
);
}
if a > 3 {
batch.text_styled(
atlas,
&line.content,
s(4.0),
s(text_top),
rgba(255, 255, 255, a),
true,
);
if let Some(indicator) = line.indicator {
let color = hud_layout::as8(opacity) << 24 | indicator & 0xFF_FFFF;
out.push(hud_layout::fill(
-4,
bottom - entry_height,
-2,
bottom,
color,
));
}
}
// Scroll bar while focused and scrolled content exists.
if focused && count > 0 {
let total = self.lines.len() as f32;
let chat_height = count as f32 * entry_height;
let virtual_height = total * entry_height;
if focused {
let count = lines.len() as i32;
let chat_height = count * entry_height;
let virtual_height = total as i32 * entry_height;
let y = self.scroll as i32 * chat_height / total as i32 - chat_bottom;
let height = chat_height * chat_height / virtual_height;
if virtual_height != chat_height {
let y = self.scroll as f32 * chat_height / total - chat_bottom;
let height = chat_height * chat_height / virtual_height;
let alpha = if y > 0.0 { 170 } else { 96 };
let c = if self.new_since_scroll {
let alpha: u32 = if y > 0 { 170 } else { 96 };
let color = if self.new_since_scroll {
0xCC3333
} else {
0x3333AA
};
let x = max_width + 4.0 + 4.0;
batch.fill(
atlas,
s(x),
s(-y - height),
s(2.0),
s(height),
rgba((c >> 16) as u8, (c >> 8) as u8, c as u8, alpha),
);
batch.fill(
atlas,
s(x + 1.0),
s(-y - height),
s(1.0),
s(height),
rgba(0xCC, 0xCC, 0xCC, alpha),
);
let x = max_width + 4;
out.push(hud_layout::fill(
x,
-y,
x + 2,
-y - height,
alpha << 24 | color,
));
out.push(hud_layout::fill(
x + 2,
-y,
x + 1,
-y - height,
alpha << 24 | 0xCCCCCC,
));
}
}
out.push(GuiDraw::Pop);
out
}
/// Draws [`Chat::layout`] into the HUD batch.
pub fn draw(&self, batch: &mut HudBatch, atlas: &HudAtlas, ticks: u64, focused: bool) {
let draws = self.layout(batch.height as i32, ticks, focused);
hud_layout::paint(batch, atlas, &draws);
}
/// Plain text of the newest messages (for tests and the control socket).
pub fn recent_lines(&self, n: usize) -> Vec<String> {
+105
View File
@@ -78,6 +78,23 @@ impl SceneCollisions<'_, '_> {
)?)
}
}
/// `noCollision(null, box)`: block collisions under the empty collision
/// context, as entity-less queries such as spawn checks use.
pub fn no_collision_empty<W, E>(world: &mut W, bounds: Aabb) -> bool
where
W: BlockCollisionWorld<Error = E> + EntityMovementWorld<Error = E>,
{
no_world_collision(
world,
CollisionCheckRequest {
entity: EntityIdentity(0),
context: CollisionContext::empty(false),
bounds,
candidate_budget: 1_000_000,
},
)
.unwrap_or(false)
}
impl BlockCollisionWorld for SceneCollisions<'_, '_> {
type Error = io::Error;
type Chunk = ChunkPos;
@@ -166,3 +183,91 @@ impl PowderSnowWorld for SceneCollisions<'_, '_> {
Ok(false)
}
}
/// Collision queries over any block lookup (test worlds without chunks):
/// every chunk is present, nothing is suffocating, no entities collide.
pub struct LookupCollisions<'w, 'r> {
pub block: &'w dyn Fn(i32, i32, i32) -> horizon_world::registry::BlockStateId,
pub binding: &'w BlockCollisionRegistry<'r>,
}
impl BlockCollisionWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
type Chunk = ChunkPos;
fn collision_chunk(&mut self, p: ChunkPos) -> io::Result<Option<ChunkPos>> {
Ok(Some(p))
}
fn collision_block(&mut self, _: ChunkPos, p: BlockPos) -> io::Result<CollisionBlock> {
self.binding.collision_block((self.block)(p.x, p.y, p.z))
}
fn is_suffocating(&mut self, _: ChunkPos, _: CollisionBlock, _: BlockPos) -> io::Result<bool> {
Ok(false)
}
fn context_shape(
&mut self,
block: CollisionBlock,
p: BlockPos,
context: &CollisionContext<'_>,
) -> io::Result<VoxelShape> {
self.binding
.collision_shape_with_context(self, block.state, p, context)
.map_err(io::Error::other)
}
}
impl EntityMovementWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn entity_collisions(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Vec<VoxelShape>> {
Ok(Vec::new())
}
fn movement_border(&mut self, _: EntityIdentity, _: Aabb) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
impl DynamicSupportWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn shulker_is_closed(&mut self, _: [i32; 3]) -> io::Result<Option<bool>> {
Ok(None)
}
fn moving_piston_shape(&mut self, _: [i32; 3]) -> io::Result<Option<VoxelShape>> {
Ok(None)
}
}
impl DynamicCollisionWorld for LookupCollisions<'_, '_> {
fn shulker_collision_state(
&mut self,
_: [i32; 3],
) -> io::Result<Option<ShulkerCollisionState>> {
Ok(None)
}
}
impl LiquidCollisionWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn entity_liquid_shape(&mut self, _: EntityIdentity) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
fn fluid(&mut self, p: BlockPos) -> io::Result<FluidState> {
FluidState::from_block(
self.binding
.registry()
.state((self.block)(p.x, p.y, p.z))
.ok_or_else(|| io::Error::other("fluid state outside registry"))?,
)
}
fn entity_can_stand(&mut self, _: EntityIdentity, _: FluidState) -> io::Result<bool> {
Ok(false)
}
}
impl PowderSnowWorld for LookupCollisions<'_, '_> {
type Error = io::Error;
fn powder_snow_entity(&mut self, _: EntityIdentity) -> io::Result<PowderSnowEntity> {
Ok(PowderSnowEntity {
fall_distance: 0.0,
falling_block: false,
})
}
fn powder_snow_walkable_tag(&mut self, _: EntityIdentity) -> io::Result<bool> {
Ok(false)
}
fn feet_are_leather_boots(&mut self, _: EntityIdentity) -> io::Result<bool> {
Ok(false)
}
}
+63 -23
View File
@@ -540,9 +540,9 @@ pub fn parse_identifier(raw: &str) -> Option<String> {
&& ns
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-'));
let path_ok = path
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
(ns_ok && path_ok).then(|| format!("{ns}:{path}"))
}
pub fn normalize(id: &str) -> String {
@@ -690,7 +690,11 @@ pub fn read_double_bounds(reader: &mut Reader) -> CmdResult<DoubleRange> {
}
/// MinMaxBounds.FloatDegrees.fromReader (no swap check).
pub fn read_float_bounds(reader: &mut Reader) -> CmdResult<(Option<f32>, Option<f32>)> {
read_bounds(reader, |s| java_parse_double(s).map(|v| v as f32), "parsing.float.invalid")
read_bounds(
reader,
|s| java_parse_double(s).map(|v| v as f32),
"parsing.float.invalid",
)
}
/// Integer.parseInt over number characters.
pub fn java_parse_int(s: &str) -> Option<i32> {
@@ -886,7 +890,12 @@ fn java_split(s: &str) -> Vec<&str> {
}
/// SharedSuggestionProvider.suggestCoordinates / suggest2DCoordinates: the
/// candidates `ty` parses completely.
fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &ArgType, relevant: bool) {
fn suggest_coordinates(
builder: &mut SuggestionsBuilder,
reg: &Registries,
ty: &ArgType,
relevant: bool,
) {
let remaining = builder.remaining().to_owned();
let three = matches!(ty, ArgType::BlockPos | ArgType::Vec3 { .. });
let coords: [String; 3] = if remaining.starts_with('^') {
@@ -903,7 +912,11 @@ fn suggest_coordinates(builder: &mut SuggestionsBuilder, reg: &Registries, ty: &
let mut result: Vec<String> = Vec::new();
let [x, y, z] = &coords;
if remaining.is_empty() {
let full = if three { format!("{x} {y} {z}") } else { format!("{x} {z}") };
let full = if three {
format!("{x} {y} {z}")
} else {
format!("{x} {z}")
};
if valid(&full) {
result.push(x.clone());
if three {
@@ -965,7 +978,11 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
// StringReader.peek past the end (StringIndexOutOfBoundsException).
let len = reader.string.encode_utf16().count();
let message = format!("Index {len} out of bounds for length {len}");
return Err(CommandError::tr_at("command.exception", &[&message], reader));
return Err(CommandError::tr_at(
"command.exception",
&[&message],
reader,
));
}
match reader.peek() {
'{' => {
@@ -988,7 +1005,10 @@ fn parse_nbt_path(reader: &mut Reader) -> CmdResult<()> {
_ => {
let s = reader.cursor;
while reader.can_read()
&& !matches!(reader.peek(), ' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}')
&& !matches!(
reader.peek(),
' ' | '"' | '\'' | '[' | ']' | '.' | '{' | '}'
)
{
reader.skip();
}
@@ -1049,10 +1069,9 @@ fn particle_options(id: &str, t: &horizon_world::nbt::Tag) -> Result<(), String>
{
// withAlternative(BlockState.CODEC, the block id codec).
let first = match state {
horizon_world::nbt::Tag::Compound(_) => super::codec::either(
"Not a string".into(),
no_key("id", state),
),
horizon_world::nbt::Tag::Compound(_) => {
super::codec::either("Not a string".into(), no_key("id", state))
}
_ => not_map(state),
};
let _ = snbt;
@@ -1121,7 +1140,10 @@ impl ArgType {
reader.cursor = start;
return Err(CommandError::tr_at(
"argument.double.low",
&[&crate::snbt::java_double(*min), &crate::snbt::java_double(v)],
&[
&crate::snbt::java_double(*min),
&crate::snbt::java_double(v),
],
reader,
));
}
@@ -1129,7 +1151,10 @@ impl ArgType {
reader.cursor = start;
return Err(CommandError::tr_at(
"argument.double.big",
&[&crate::snbt::java_double(*max), &crate::snbt::java_double(v)],
&[
&crate::snbt::java_double(*max),
&crate::snbt::java_double(v),
],
reader,
));
}
@@ -1146,10 +1171,7 @@ impl ArgType {
// MessageArgument.Message.parseText.
let len = reader.remaining().encode_utf16().count();
if len > 256 {
return Err(CommandError::tr(
"argument.message.too_long",
&[&len, &256],
));
return Err(CommandError::tr("argument.message.too_long", &[&len, &256]));
}
let s = reader.remaining().to_owned();
if reg.permission >= 2 {
@@ -1329,7 +1351,11 @@ impl ArgType {
ArgType::HexColor => {
let text = reader.read_unquoted_string();
if !matches!(text.len(), 3 | 6) {
return Err(CommandError::tr_at("argument.hexcolor.invalid", &[&text], reader));
return Err(CommandError::tr_at(
"argument.hexcolor.invalid",
&[&text],
reader,
));
}
// Integer.parseInt(s, begin, end, 16) per channel: a bad digit
// escapes Brigadier as a dispatcher parse exception.
@@ -1341,7 +1367,11 @@ impl ArgType {
if let Some(i) = digits.find(|ch: char| !ch.is_ascii_hexdigit()) {
let index = i + usize::from(signed);
let message = format!("Error at index {index} in: \"{c}\"");
return Err(CommandError::tr_at("command.exception", &[&message], reader));
return Err(CommandError::tr_at(
"command.exception",
&[&message],
reader,
));
}
}
let v = i64::from_str_radix(text, 16).unwrap_or(0);
@@ -1361,7 +1391,10 @@ impl ArgType {
.take_while(|c| c.is_ascii_hexdigit() || *c == '-')
.map(char::len_utf8)
.sum();
match (run > 0).then(|| super::selector::java_uuid(&rest[..run])).flatten() {
match (run > 0)
.then(|| super::selector::java_uuid(&rest[..run]))
.flatten()
{
Some(_) => {
let text = rest[..run].to_owned();
reader.cursor += run;
@@ -1391,7 +1424,11 @@ impl ArgType {
let v = reader.read_float()?;
let unit = reader.read_unquoted_string();
let Some(m) = time_unit(unit) else {
return Err(CommandError::tr_at("argument.time.invalid_unit", &[], reader));
return Err(CommandError::tr_at(
"argument.time.invalid_unit",
&[],
reader,
));
};
// Math.round(float): floor(x + 0.5).
let ticks = (v * m as f32 + 0.5).floor() as i32;
@@ -1425,7 +1462,10 @@ impl ArgType {
// ParserBasedArgument.withCodec: SNBT, then the codec (errors at the start).
let tag = super::snbt::parse_tag_argument(reader)?;
let (decoded, key) = if matches!(self, ArgType::Style) {
(super::component::decode_style(&tag), "argument.style.invalid")
(
super::component::decode_style(&tag),
"argument.style.invalid",
)
} else {
(super::component::decode(&tag), "argument.component.invalid")
};
+3 -6
View File
@@ -379,7 +379,8 @@ impl<'r, 'a> BlockParser<'r, 'a> {
}
let prefix = b.remaining_lower();
for (name, _) in definition {
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix) {
if !self.properties.iter().any(|(n, _)| n == name) && name.starts_with(&prefix)
{
b.suggest(format!("{name}="));
}
}
@@ -439,11 +440,7 @@ impl<'r, 'a> BlockParser<'r, 'a> {
}
/// BlockStateParser.parseForBlock / parseForTesting (the reader is rewound on errors).
pub fn parse(
reader: &mut Reader,
reg: &Registries,
for_testing: bool,
) -> CmdResult<BlockInput> {
pub fn parse(reader: &mut Reader, reg: &Registries, for_testing: bool) -> CmdResult<BlockInput> {
let cursor = reader.cursor;
let r = BlockParser::new(reader, reg, for_testing, true).parse();
if r.is_err() {
+3 -7
View File
@@ -86,10 +86,7 @@ fn provider_fields(id: &str, map: &Compound, t: &Tag) -> Result<(), String> {
}
}
/// A holder list codec: a single element, a `#tag`, or a list.
fn element_or_list(
t: &Tag,
element: &dyn Fn(&Tag) -> Result<(), String>,
) -> Result<(), String> {
fn element_or_list(t: &Tag, element: &dyn Fn(&Tag) -> Result<(), String>) -> Result<(), String> {
let first = match element(t) {
Ok(()) => return Ok(()),
Err(e) => e,
@@ -179,7 +176,6 @@ pub fn resource_or_id(reader: &mut Reader, reg: &Registries, registry: &str) ->
}
reader.cursor = start;
let tag = super::snbt::parse_tag_argument(reader)?;
decode(registry, &tag, reg).map_err(|m| {
CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader)
})
decode(registry, &tag, reg)
.map_err(|m| CommandError::tr_at("argument.resource_or_id.failed_to_parse", &[&m], reader))
}
+23 -13
View File
@@ -47,7 +47,11 @@ fn is_string(t: &Tag) -> bool {
matches!(t, Tag::String(_))
}
fn string(t: &Tag) -> R {
if is_string(t) { Ok(()) } else { err("Not a string") }
if is_string(t) {
Ok(())
} else {
err("Not a string")
}
}
fn string_of(t: &Tag) -> Option<String> {
match t {
@@ -131,9 +135,9 @@ pub fn identifier(t: &Tag) -> Result<(), String> {
"Not a valid resource location: {s} Non [a-z0-9_.-] character in namespace of location: {ns}:{path}"
));
}
let path_ok = path
.chars()
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/'));
let path_ok = path.chars().all(|c| {
c.is_ascii_lowercase() || c.is_ascii_digit() || matches!(c, '_' | '.' | '-' | '/')
});
if !path_ok {
return Err(format!(
"Not a valid resource location: {s} Non [a-z0-9/._-] character in path of location: {ns}:{path}"
@@ -208,7 +212,13 @@ fn style_fields(map: &Compound) -> R {
if let Some(c) = get(map, "color") {
color(c)?;
}
for key in ["bold", "italic", "underlined", "strikethrough", "obfuscated"] {
for key in [
"bold",
"italic",
"underlined",
"strikethrough",
"obfuscated",
] {
if let Some(v) = get(map, key)
&& bool_value(v).is_err()
{
@@ -262,12 +272,13 @@ fn content(kind: &str, map: &Compound) -> bool {
_ => false,
},
"selector" => {
get(map, "selector").is_some_and(selector_string) && optional_component(map, "separator")
get(map, "selector").is_some_and(selector_string)
&& optional_component(map, "separator")
}
"nbt" => {
let path_ok = get(map, "nbt").and_then(string_of).is_some_and(|p| {
crate::nbt_path::NbtPath::parse(&p).is_ok()
});
let path_ok = get(map, "nbt")
.and_then(string_of)
.is_some_and(|p| crate::nbt_path::NbtPath::parse(&p).is_ok());
let source = if let Some(e) = get(map, "entity") {
selector_string(e)
} else if let Some(b) = get(map, "block") {
@@ -336,10 +347,9 @@ fn full(t: &Tag) -> R {
style_fields(map)
}
fn component(t: &Tag) -> R {
either(
either(string(t), || non_empty_list(t, &component)),
|| full(t),
)
either(either(string(t), || non_empty_list(t, &component)), || {
full(t)
})
}
/// ComponentSerialization.CODEC.parse: the error message on failure.
pub fn decode(t: &Tag) -> Result<(), String> {
+53 -15
View File
@@ -1,7 +1,9 @@
//! ItemParser (item stacks with components, `stone[damage=5]`) and
//! ItemPredicateArgument (ComponentPredicateParser's packrat grammar:
//! `#logs[count~1..|!damage]`), with vanilla's errors and suggestions.
use super::args::{ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources};
use super::args::{
ItemInput, Registries, is_id_char, parse_identifier, read_identifier, suggest_resources,
};
use super::codec::{either, is_number, not_map, snbt};
use super::packrat::{Grammar, Reason, State, Sugg, Term, Value, alt, ch, opt, seq};
use super::{CmdResult, CommandError, Reader, SuggestionsBuilder};
@@ -159,7 +161,10 @@ impl ItemState<'_, '_> {
self.sugg = ISugg::Component;
let ty = self.component_type()?;
if visited.contains(&ty) {
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
return Err(CommandError::tr(
"arguments.item.component.repeated",
&[&ty],
));
}
visited.push(ty);
self.sugg = ISugg::Nothing;
@@ -167,7 +172,10 @@ impl ItemState<'_, '_> {
} else {
let ty = self.component_type()?;
if visited.contains(&ty) {
return Err(CommandError::tr("arguments.item.component.repeated", &[&ty]));
return Err(CommandError::tr(
"arguments.item.component.repeated",
&[&ty],
));
}
visited.push(ty.clone());
self.sugg = ISugg::Assignment;
@@ -401,10 +409,12 @@ fn predicate_grammar() -> Grammar<IV> {
"type",
alt(vec![element, seq(vec![ch('#'), Term::Cut, tag]), any]),
|s| {
Some(IV::Type(match s.scope.get_any(&["element_type", "tag_type"]) {
Some(IV::Elem(t)) => Some(t),
_ => None,
}))
Some(IV::Type(
match s.scope.get_any(&["element_type", "tag_type"]) {
Some(IV::Elem(t)) => Some(t),
_ => None,
},
))
},
);
g.put_term("any_type", ch('*'), |_| Some(IV::Unit));
@@ -415,7 +425,11 @@ fn predicate_grammar() -> Grammar<IV> {
if r.ids("minecraft:item").iter().any(|i| i == id) {
Ok(IV::Elem(TypeRes::Item(id.to_owned())))
} else {
Err(CommandError::tr_at("argument.item.id.invalid", &[&id], reader))
Err(CommandError::tr_at(
"argument.item.id.invalid",
&[&id],
reader,
))
}
})
});
@@ -426,7 +440,11 @@ fn predicate_grammar() -> Grammar<IV> {
if r.tags("minecraft:item").iter().any(|i| i == id) {
Ok(IV::Elem(TypeRes::Tag(id.to_owned())))
} else {
Err(CommandError::tr_at("arguments.item.tag.unknown", &[&id], reader))
Err(CommandError::tr_at(
"arguments.item.tag.unknown",
&[&id],
reader,
))
}
})
});
@@ -509,29 +527,49 @@ fn predicate_grammar() -> Grammar<IV> {
if id == "minecraft:count" {
return Ok(IV::Comp(id.to_owned()));
}
let known = r.ids("minecraft:data_component_type").iter().any(|c| c == id);
let known = r
.ids("minecraft:data_component_type")
.iter()
.any(|c| c == id);
if known && r.persistent_components.iter().any(|c| c == id) {
Ok(IV::Comp(id.to_owned()))
} else {
Err(CommandError::tr_at("arguments.item.component.unknown", &[&id], reader))
Err(CommandError::tr_at(
"arguments.item.component.unknown",
&[&id],
reader,
))
}
})
});
g.put_custom("predicate_type", move |s| {
let r = reg(s);
let mut list = vec!["minecraft:count".to_owned()];
list.extend(r.ids("minecraft:data_component_predicate_type").iter().cloned());
list.extend(
r.ids("minecraft:data_component_predicate_type")
.iter()
.cloned(),
);
lookup(s, id, Rc::new(list), &|id, reader| {
if id == "minecraft:count"
|| r.ids("minecraft:data_component_predicate_type").iter().any(|c| c == id)
|| r.ids("minecraft:data_component_predicate_type")
.iter()
.any(|c| c == id)
{
return Ok(IV::Pred(id.to_owned()));
}
if r.ids("minecraft:data_component_type").iter().any(|c| c == id) {
if r.ids("minecraft:data_component_type")
.iter()
.any(|c| c == id)
{
// A component existence predicate (decodes Unit).
return Ok(IV::Pred(format!("exists:{id}")));
}
Err(CommandError::tr_at("arguments.item.predicate.unknown", &[&id], reader))
Err(CommandError::tr_at(
"arguments.item.predicate.unknown",
&[&id],
reader,
))
})
});
g.put_custom("tag", |s| super::snbt::rule_in(s, IV::Tag));
+7 -3
View File
@@ -616,7 +616,8 @@ pub fn merge_suggestions(input: &str, list: Vec<Suggestion>) -> (Range, Vec<Sugg
// A stable insertion sort (the comparator mixes numeric and text order).
let mut sorted: Vec<Suggestion> = Vec::with_capacity(out.len());
for s in out {
let pos = sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
let pos =
sorted.partition_point(|x| compare_suggestions(x, &s) != std::cmp::Ordering::Greater);
sorted.insert(pos, s);
}
((start, end), sorted)
@@ -644,7 +645,8 @@ impl Suggests {
for id in &reg.summonable {
if args::resource_matches(&contents, id) {
let (ns, path) = id.split_once(':').unwrap_or(("minecraft", id));
let name = crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
let name =
crate::lang::get(&format!("entity.{ns}.{}", path.replace('/', ".")));
builder.suggest_tooltip(id.clone(), name);
}
}
@@ -673,7 +675,9 @@ impl Suggests {
.trigger_scores
.iter()
.filter(|(_, scores)| {
targets.iter().any(|t| scores.get(t).is_none_or(|locked| *locked))
targets
.iter()
.any(|t| scores.get(t).is_none_or(|locked| *locked))
})
.map(|(o, _)| o.as_str())
.collect();
+6 -6
View File
@@ -94,13 +94,11 @@ impl<V: Clone> Scope<V> {
let current = self.top_marker;
let n = self.top_entry - self.top_marker;
self.setup_new_frame();
let mut source = current + 1;
let mut target = self.top_entry;
for _ in 0..n {
for source in current + 1..current + 1 + n {
target += 1;
let key = self.stack[source].0;
self.set(target, key, None);
source += 1;
}
self.top_entry = target;
}
@@ -139,11 +137,13 @@ impl<V: Clone> Scope<V> {
}
}
pub fn get(&self, atom: Atom) -> Option<V> {
self.value_index(&[atom]).and_then(|i| self.stack[i].1.clone())
self.value_index(&[atom])
.and_then(|i| self.stack[i].1.clone())
}
/// Scope.getAny: the newest of several atoms.
pub fn get_any(&self, atoms: &[Atom]) -> Option<V> {
self.value_index(atoms).and_then(|i| self.stack[i].1.clone())
self.value_index(atoms)
.and_then(|i| self.stack[i].1.clone())
}
/// Whether the atom has an entry (even a null one).
pub fn has(&self, atom: Atom) -> bool {
@@ -385,7 +385,7 @@ impl<'g, V: Value> State<'g, V> {
r
}
fn first_error(&self) -> CommandError {
for (_, reason) in &self.errors {
if let Some((_, reason)) = self.errors.first() {
return match reason {
Reason::Delayed(key, args) => {
let args: Vec<&dyn std::fmt::Display> =
@@ -143,7 +143,12 @@ pub fn java_uuid(name: &str) -> Option<u128> {
Some((u128::from(msb) << 64) | u128::from(lsb))
}
fn rollback(reader: &mut Reader, start: usize, key: &str, args: &[&dyn std::fmt::Display]) -> CommandError {
fn rollback(
reader: &mut Reader,
start: usize,
key: &str,
args: &[&dyn std::fmt::Display],
) -> CommandError {
reader.cursor = start;
CommandError::tr_at(key, args, reader)
}
+57 -15
View File
@@ -153,7 +153,9 @@ fn parse_small(s: &str, radix: u32, lo: i32, hi: i32) -> Result<i32, String> {
}
fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
if s.starts_with('-') {
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
return Err(format!(
"Illegal leading minus sign on unsigned string {s}."
));
}
let len = s.chars().count();
if len <= 5 || (radix == 10 && len <= 9) {
@@ -168,7 +170,9 @@ fn parse_unsigned_int(s: &str, radix: u32) -> Result<i32, String> {
}
fn parse_unsigned_long(s: &str, radix: u32) -> Result<i64, String> {
if s.starts_with('-') {
return Err(format!("Illegal leading minus sign on unsigned string {s}."));
return Err(format!(
"Illegal leading minus sign on unsigned string {s}."
));
}
let digits = s.strip_prefix('+').unwrap_or(s);
match u64::from_str_radix(digits, radix) {
@@ -338,7 +342,11 @@ pub fn grammar() -> Grammar<SV> {
|s| s.scope.get("integer_suffix"),
);
g.put_custom("binary_numeral", |s| {
number_run(s, |c| matches!(c, '0' | '1' | '_'), "snbt.parser.expected_binary_numeral")
number_run(
s,
|c| matches!(c, '0' | '1' | '_'),
"snbt.parser.expected_binary_numeral",
)
});
g.put_custom("decimal_numeral", |s| {
number_run(
@@ -394,7 +402,10 @@ pub fn grammar() -> Grammar<SV> {
} else if let Some(h) = get_str(s, "hex_numeral") {
(Base::Hex, h)
} else {
(Base::Binary, get_str(s, "binary_numeral").unwrap_or_default())
(
Base::Binary,
get_str(s, "binary_numeral").unwrap_or_default(),
)
};
Some(SV::Int(IntLit {
minus,
@@ -408,8 +419,14 @@ pub fn grammar() -> Grammar<SV> {
g.put_term(
"float_type_suffix",
alt(vec![
seq(vec![chs('f', 'F'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float))]),
seq(vec![chs('d', 'D'), Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double))]),
seq(vec![
chs('f', 'F'),
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Float)),
]),
seq(vec![
chs('d', 'D'),
Term::Marker("float_type_suffix", SV::Type(TypeSuffix::Double)),
]),
]),
|s| s.scope.get("float_type_suffix"),
);
@@ -420,7 +437,10 @@ pub fn grammar() -> Grammar<SV> {
seq(vec![chs('e', 'E'), opt(sign), decimal]),
|s| {
let minus = matches!(s.scope.get("sign"), Some(SV::Sign(true)));
Some(SV::Exp(minus, get_str(s, "decimal_numeral").unwrap_or_default()))
Some(SV::Exp(
minus,
get_str(s, "decimal_numeral").unwrap_or_default(),
))
},
);
let sign = g.named("sign");
@@ -470,7 +490,11 @@ pub fn grammar() -> Grammar<SV> {
float_tag(&text, ty, s).map(SV::Tag)
},
);
for (name, n) in [("string_hex_2", 2), ("string_hex_4", 4), ("string_hex_8", 8)] {
for (name, n) in [
("string_hex_2", 2),
("string_hex_4", 4),
("string_hex_8", 8),
] {
g.put_custom(name, move |s| {
greedy(
s,
@@ -521,7 +545,9 @@ pub fn grammar() -> Grammar<SV> {
if let Some(SV::Str(plain)) = s.scope.get_any(&["string_escape_sequence"]) {
return Some(SV::Str(plain));
}
if let Some(SV::Str(h)) = s.scope.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
if let Some(SV::Str(h)) =
s.scope
.get_any(&["string_hex_2", "string_hex_4", "string_hex_8"])
{
let cp = u32::from_str_radix(&h, 16).unwrap_or(u32::MAX);
if cp > 0x10ffff {
@@ -531,7 +557,9 @@ pub fn grammar() -> Grammar<SV> {
));
return None;
}
return Some(SV::Str(char::from_u32(cp).unwrap_or('\u{fffd}').to_string()));
return Some(SV::Str(
char::from_u32(cp).unwrap_or('\u{fffd}').to_string(),
));
}
// Character.codePointOf: Unicode names are not tabulated here.
s.store_at_mark(delayed("snbt.parser.invalid_character_name"));
@@ -663,7 +691,8 @@ pub fn grammar() -> Grammar<SV> {
let qs = g.named("quoted_string_literal");
let uq = g.named("unquoted_string");
g.put_term("map_key", alt(vec![qs, uq]), |s| {
s.scope.get_any(&["quoted_string_literal", "unquoted_string"])
s.scope
.get_any(&["quoted_string_literal", "unquoted_string"])
});
let key = g.named("map_key");
let lit = g.named("literal");
@@ -712,9 +741,18 @@ pub fn grammar() -> Grammar<SV> {
g.put_term(
"array_prefix",
alt(vec![
seq(vec![ch('B'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte))]),
seq(vec![ch('L'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long))]),
seq(vec![ch('I'), Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int))]),
seq(vec![
ch('B'),
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Byte)),
]),
seq(vec![
ch('L'),
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Long)),
]),
seq(vec![
ch('I'),
Term::Marker("array_prefix", SV::Prefix(ArrayPrefix::Int)),
]),
]),
|s| s.scope.get("array_prefix"),
);
@@ -890,7 +928,11 @@ thread_local! {
pub fn parse_tag_argument(reader: &mut Reader) -> CmdResult<Tag> {
GRAMMAR.with(|g| match g.parse_for_commands(reader)? {
SV::Tag(t) => Ok(t),
_ => Err(CommandError::tr_at("argument.nbt.expected.compound", &[], reader)),
_ => Err(CommandError::tr_at(
"argument.nbt.expected.compound",
&[],
reader,
)),
})
}
/// TagParser.parseCompoundAsArgument.
+24 -5
View File
@@ -37,9 +37,18 @@ fn reg() -> Registries {
},
);
};
registry("minecraft:worldgen/biome", &["minecraft:desert", "minecraft:plains"]);
registry("minecraft:damage_type", &["minecraft:fall", "minecraft:magic"]);
registry("minecraft:attribute", &["minecraft:max_health", "minecraft:scale"]);
registry(
"minecraft:worldgen/biome",
&["minecraft:desert", "minecraft:plains"],
);
registry(
"minecraft:damage_type",
&["minecraft:fall", "minecraft:magic"],
);
registry(
"minecraft:attribute",
&["minecraft:max_health", "minecraft:scale"],
);
registry("minecraft:item_modifier", &["a:b"]);
registry("minecraft:predicate", &["a:b"]);
registry(
@@ -54,7 +63,14 @@ fn reg() -> Registries {
"minecraft:entity_type",
&["minecraft:pig", "minecraft:player", "minecraft:zombie"],
);
registry("minecraft:item", &["minecraft:diamond", "minecraft:diamond_sword", "minecraft:stone"]);
registry(
"minecraft:item",
&[
"minecraft:diamond",
"minecraft:diamond_sword",
"minecraft:stone",
],
);
r
}
fn run(input: &str) -> CmdResult<(String, Vec<String>)> {
@@ -436,7 +452,10 @@ fn world_command_trees() {
"particle minecraft:dust{color:[1.0,0.0,0.0],scale:2.0} ~ ~ ~ 0.1 0.1 0.1 0 10 force @a",
"particle",
),
("particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~", "particle"),
(
"particle minecraft:block{block_state:\"minecraft:stone\"} ~ ~ ~",
"particle",
),
("attribute @s minecraft:max_health get 2", "attribute.get"),
(
"attribute @s minecraft:max_health base set 40",
+10 -2
View File
@@ -52,7 +52,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
d.requires(compute, GAMEMASTERS);
context_sources(d, compute, &mut |d, node| {
let f = d.literal(node, "float");
let p = d.argument(f, "provider", ArgType::ResourceOrId("minecraft:context_float_provider"));
let p = d.argument(
f,
"provider",
ArgType::ResourceOrId("minecraft:context_float_provider"),
);
d.executes(p, "compute");
let scale = d.argument(
p,
@@ -64,7 +68,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
);
d.executes(scale, "compute");
let i = d.literal(node, "integer");
let p = d.argument(i, "provider", ArgType::ResourceOrId("minecraft:context_int_provider"));
let p = d.argument(
i,
"provider",
ArgType::ResourceOrId("minecraft:context_int_provider"),
);
d.executes(p, "compute");
});
@@ -48,7 +48,11 @@ pub fn register(d: &mut Dispatcher, root: usize) {
let t = d.argument(as_, "targets", ENTITIES);
d.redirect(t, ex);
let over = d.literal(positioned, "over");
let hm = d.argument(over, "heightmap", ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"));
let hm = d.argument(
over,
"heightmap",
ArgType::StrEnum(HEIGHTMAPS, "argument.enum.invalid"),
);
d.redirect(hm, ex);
let rotated = d.literal(ex, "rotated");
let rot = d.argument(rotated, "rot", ArgType::Rotation);
@@ -179,7 +183,11 @@ fn conditionals(d: &mut Dispatcher, m: usize, ex: usize) {
conditional(d, targets, ex);
let predicate = d.literal(m, "predicate");
let p = d.argument(predicate, "predicate", ArgType::ResourceOrId("minecraft:predicate"));
let p = d.argument(
predicate,
"predicate",
ArgType::ResourceOrId("minecraft:predicate"),
);
conditional(d, p, ex);
let stopwatch = d.literal(m, "stopwatch");
@@ -169,7 +169,11 @@ fn item(d: &mut Dispatcher, root: usize) {
d.argument(p, "targetPos", ArgType::BlockPos)
};
let slot = d.argument(target, "slot", ArgType::SlotSource);
let modifier = d.argument(slot, "modifier", ArgType::ResourceOrId("minecraft:item_modifier"));
let modifier = d.argument(
slot,
"modifier",
ArgType::ResourceOrId("minecraft:item_modifier"),
);
d.executes(modifier, "item.modify");
}
for mode in ["replace", "fill", "override"] {
@@ -56,19 +56,31 @@ pub fn register(d: &mut Dispatcher, root: usize) {
let place = d.literal(root, "place");
d.requires(place, GAMEMASTERS);
let feature = d.literal(place, "feature");
let f = d.argument(feature, "feature", ArgType::ResourceOrId("minecraft:worldgen/feature"));
let f = d.argument(
feature,
"feature",
ArgType::ResourceOrId("minecraft:worldgen/feature"),
);
d.executes(f, "place.feature");
let pos = d.argument(f, "pos", ArgType::BlockPos);
d.executes(pos, "place.feature");
let jigsaw = d.literal(place, "jigsaw");
let pool = d.argument(jigsaw, "pool", ArgType::ResKey("minecraft:worldgen/template_pool"));
let pool = d.argument(
jigsaw,
"pool",
ArgType::ResKey("minecraft:worldgen/template_pool"),
);
let target = d.argument(pool, "target", ArgType::Id("none"));
let depth = d.argument(target, "max_depth", ArgType::Integer { min: 1, max: 20 });
d.executes(depth, "place.jigsaw");
let position = d.argument(depth, "position", ArgType::BlockPos);
d.executes(position, "place.jigsaw");
let structure = d.literal(place, "structure");
let st = d.argument(structure, "structure", ArgType::ResKey("minecraft:worldgen/structure"));
let st = d.argument(
structure,
"structure",
ArgType::ResKey("minecraft:worldgen/structure"),
);
d.executes(st, "place.structure");
let pos = d.argument(st, "pos", ArgType::BlockPos);
d.executes(pos, "place.structure");
@@ -89,7 +101,10 @@ pub fn register(d: &mut Dispatcher, root: usize) {
let mirror = d.argument(
rotation,
"mirror",
ArgType::StrEnum(&["none", "left_right", "front_back"], "argument.enum.invalid"),
ArgType::StrEnum(
&["none", "left_right", "front_back"],
"argument.enum.invalid",
),
);
d.executes(mirror, "place");
let integrity = d.argument(mirror, "integrity", ArgType::Float { min: 0.0, max: 1.0 });
@@ -160,7 +175,14 @@ fn rotate_swing(d: &mut Dispatcher, root: usize) {
for hand in ["mainhand", "offhand"] {
let h = d.literal(targets, hand);
d.executes(h, "swing");
let animation = d.argument(h, "animation", ArgType::StrEnum(&["none", "whack", "stab"], "argument.swing_animation.invalid"));
let animation = d.argument(
h,
"animation",
ArgType::StrEnum(
&["none", "whack", "stab"],
"argument.swing_animation.invalid",
),
);
d.executes(animation, "swing");
let duration = d.argument(animation, "duration", ArgType::Time { min: 1 });
d.executes(duration, "swing");
@@ -285,7 +307,11 @@ fn fillbiome(d: &mut Dispatcher, root: usize) {
let biome = d.argument(to, "biome", ArgType::Res("minecraft:worldgen/biome"));
d.executes(biome, "fillbiome");
let replace = d.literal(biome, "replace");
let filter = d.argument(replace, "filter", ArgType::ResOrTag("minecraft:worldgen/biome"));
let filter = d.argument(
replace,
"filter",
ArgType::ResOrTag("minecraft:worldgen/biome"),
);
d.executes(filter, "fillbiome");
}
@@ -357,7 +383,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
// Sources.
for t in targets {
let fish = d.literal(t, "fish");
let table = d.argument(fish, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
let table = d.argument(
fish,
"loot_table",
ArgType::ResourceOrId("minecraft:loot_table"),
);
let pos = d.argument(table, "pos", ArgType::BlockPos);
d.executes(pos, "loot.fish");
for hand in ["mainhand", "offhand"] {
@@ -367,7 +397,11 @@ fn loot(d: &mut Dispatcher, root: usize) {
let tool = d.argument(pos, "tool", ArgType::Item);
d.executes(tool, "loot.fish");
let l = d.literal(t, "loot");
let lt = d.argument(l, "loot_table", ArgType::ResourceOrId("minecraft:loot_table"));
let lt = d.argument(
l,
"loot_table",
ArgType::ResourceOrId("minecraft:loot_table"),
);
d.executes(lt, "loot.loot");
let kill = d.literal(t, "kill");
let target = d.argument(kill, "target", ENTITY);
+10 -2
View File
@@ -322,13 +322,21 @@ pub fn build_with(dev: bool) -> Dispatcher {
let locate = d.literal(root, "locate");
d.requires(locate, GAMEMASTERS);
let s = d.literal(locate, "structure");
let a = d.argument(s, "structure", ArgType::ResOrTagKey("minecraft:worldgen/structure"));
let a = d.argument(
s,
"structure",
ArgType::ResOrTagKey("minecraft:worldgen/structure"),
);
d.executes(a, "locate.structure");
let b = d.literal(locate, "biome");
let a = d.argument(b, "biome", ArgType::ResOrTag("minecraft:worldgen/biome"));
d.executes(a, "locate.biome");
let p = d.literal(locate, "poi");
let a = d.argument(p, "poi", ArgType::ResOrTag("minecraft:point_of_interest_type"));
let a = d.argument(
p,
"poi",
ArgType::ResOrTag("minecraft:point_of_interest_type"),
);
d.executes(a, "locate.poi");
if dev {
+28 -18
View File
@@ -152,6 +152,23 @@ impl Parser<'_> {
Err("unterminated string".into())
}
}
/// The style fields of a component object.
fn object_style(o: &serde_json::Map<String, Value>) -> Style {
let mut style = Style::default();
if let Some(c) = o.get("color").and_then(Value::as_str) {
style.color = named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
style
}
/// `ComponentSerialization.CODEC` decoding into text. `selector` components
/// are resolved by `resolve` (ComponentUtils.updateForEntity).
pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
@@ -165,30 +182,23 @@ pub fn to_text(v: &Value, resolve: &mut dyn FnMut(&str) -> Text) -> Text {
return Text::empty();
};
let mut base = to_text(first, resolve);
let style = base
.spans
.first()
.map(|s| s.style.clone())
.unwrap_or_default();
// The siblings take the first component's own style, even when
// its text is empty.
let style = match first {
Value::Object(o) => object_style(o),
_ => base
.spans
.first()
.map(|s| s.style.clone())
.unwrap_or_default(),
};
for sibling in rest {
base.push(to_text(sibling, resolve).with_style(&style));
}
base
}
Value::Object(o) => {
let mut style = Style::default();
if let Some(c) = o.get("color").and_then(Value::as_str) {
style.color = named_color(c).or_else(|| {
c.strip_prefix('#')
.and_then(|h| u32::from_str_radix(h, 16).ok())
});
}
let flag = |k: &str| o.get(k).and_then(Value::as_bool);
style.bold = flag("bold");
style.italic = flag("italic");
style.underlined = flag("underlined");
style.strikethrough = flag("strikethrough");
style.obfuscated = flag("obfuscated");
let style = object_style(o);
let content = if let Some(t) = o.get("text") {
match t {
Value::String(s) => Text::literal(s.clone()),
+9 -1
View File
@@ -75,6 +75,9 @@ pub struct Crafter {
pub disabled: [bool; 9],
pub triggered: bool,
pub crafting_ticks: i32,
/// The block state's `triggered` when last ticked (not saved): the block
/// entity flag follows it only when it changes (`CrafterBlock.neighborChanged`).
pub state_triggered: Option<bool>,
}
impl Crafter {
/// `slotCanBeDisabled`: only empty slots.
@@ -184,7 +187,12 @@ impl BlockEntity {
return Some(Self::Sculk(Box::new(sculk)));
}
if name == "minecraft:crafter" {
return Some(Self::Crafter(Crafter::default()));
// CrafterBlock.newBlockEntity copies the placed state's flag: the
// first tick sees it as a change from untriggered.
return Some(Self::Crafter(Crafter {
state_triggered: Some(false),
..Crafter::default()
}));
}
if name.ends_with("_banner") {
return Some(Self::Banner(Banner::default()));
+52 -1
View File
@@ -11,6 +11,10 @@ struct Globals {
sun: vec4<f32>,
// x: sky fog end, y: cloud fog end
fog_ends: vec4<f32>,
// Atlas rects (u0, v0, width, height) of end_sky.png and end_portal.png.
end_portal_sprites: array<vec4<f32>, 2>,
// x: vanilla's GameTime ((game time % 24000 + partial tick) / 24000)
time: vec4<f32>,
}
@group(0) @binding(0) var<uniform> globals: Globals;
@group(0) @binding(1) var atlas: texture_2d<f32>;
@@ -27,11 +31,14 @@ struct Out {
@location(4) @interpolate(flat) overlay: u32,
// Overlay 6: the sprite's v origin and height, for v repeated inside it.
@location(6) @interpolate(flat) wrap: vec2<f32>,
// Overlays 9 and 10: projection_from_position(gl_Position), the End portal's texProj0.
@location(7) proj: vec4<f32>,
}
@vertex fn vs_entity(@location(0) position: vec3<f32>, @location(1) uv: vec2<f32>,
@location(2) overlay: u32, @location(3) color: vec4<f32>, @location(4) light: vec4<u32>) -> Out {
var out: Out;
out.clip = globals.view_proj * vec4(position, 1.0);
out.proj = vec4(out.clip.xy * 0.5 + out.clip.w * 0.5, out.clip.zw);
out.uv = uv;
let l = vec2(f32(light.y), f32(light.x));
let lm = textureSampleLevel(lightmap, lightmap_sampler,
@@ -52,6 +59,41 @@ struct Out {
if overlay == 4u { out.spherical = length(position); }
return out;
}
// rendertype_end_portal.fsh: the layer colours.
const PORTAL_COLORS = array<vec3<f32>, 16>(
vec3(0.022087, 0.098399, 0.110818),
vec3(0.011892, 0.095924, 0.089485),
vec3(0.027636, 0.101689, 0.100326),
vec3(0.046564, 0.109883, 0.114838),
vec3(0.064901, 0.117696, 0.097189),
vec3(0.063761, 0.086895, 0.123646),
vec3(0.084817, 0.111994, 0.166380),
vec3(0.097489, 0.154120, 0.091064),
vec3(0.106152, 0.131144, 0.195191),
vec3(0.097721, 0.110188, 0.187229),
vec3(0.133516, 0.138278, 0.148582),
vec3(0.070006, 0.243332, 0.235792),
vec3(0.196766, 0.142899, 0.214696),
vec3(0.047281, 0.315338, 0.321970),
vec3(0.204675, 0.390010, 0.302066),
vec3(0.080955, 0.314821, 0.661491),
);
// textureProj on a repeating texture that lives in the atlas at `rect`.
fn portal_sample(rect: vec4<f32>, p: vec4<f32>) -> vec3<f32> {
let st = fract(p.xy / p.w);
return textureSampleLevel(atlas, atlas_sampler, rect.xy + st * rect.zw, 0.0).rgb;
}
// texProj0 * end_portal_layer(layer): the row vector through mat4(scale * rotate),
// translate and SCALE_TRANSLATE in turn.
fn portal_layer(p: vec4<f32>, layer: f32) -> vec4<f32> {
let a = radians((layer * layer * 4321.0 + layer * 9.0) * 2.0);
let c = cos(a);
let s = sin(a);
let k = (4.5 - layer / 4.0) * 2.0;
var v = vec4(k * (c * p.x - s * p.y), k * (s * p.x + c * p.y), p.z, p.w);
v = vec4(v.x + 17.0 / layer * v.w, v.y + (2.0 + layer / 1.5) * (globals.time.x * 1.5) * v.w, v.z, v.w);
return vec4(0.5 * v.x + 0.25 * v.w, 0.5 * v.y + 0.25 * v.w, v.z, v.w);
}
fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
if d <= start { return 0.0; }
if d >= end { return 1.0; }
@@ -61,7 +103,16 @@ fn linear_fog(d: f32, start: f32, end: f32) -> f32 {
var uv = in.uv;
if in.overlay == 6u { uv.y = in.wrap.x + fract(in.uv.y) * in.wrap.y; }
var color = textureSample(atlas, atlas_sampler, uv) * in.color;
if in.overlay == 4u {
if in.overlay == 9u || in.overlay == 10u {
// rendertype_end_portal: end_sky and PORTAL_LAYERS (15 for portals, 16
// for gateways) layers of end_portal.png projected from the screen.
let layers = select(15, 16, in.overlay == 10u);
var c = portal_sample(globals.end_portal_sprites[0], in.proj) * PORTAL_COLORS[0];
for (var i = 0; i < layers; i++) {
c += portal_sample(globals.end_portal_sprites[1], portal_layer(in.proj, f32(i + 1))) * PORTAL_COLORS[i];
}
color = vec4(c, 1.0);
} else if in.overlay == 4u {
// clouds.fsh: alpha fades out towards the cloud fog end.
return vec4(color.rgb, color.a * (1.0 - linear_fog(in.spherical, 0.0, globals.fog_ends.y)));
}
+58 -26
View File
@@ -253,17 +253,37 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
} else {
map.insert("equipment".into(), Tag::compound(equipment));
}
let attr = |id: &str, v: f64| {
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
let modelled = [
("minecraft:max_health", f64::from(m.attrs.max_health)),
("minecraft:movement_speed", m.attrs.speed),
("minecraft:jump_strength", m.extra.vector[0]),
];
// AttributeMap.pack writes the instances that exist: the ones the
// entity was loaded with, or all three randomized by finalizeSpawn.
let attributes = match map.get(&"attributes".into()) {
Some(Tag::List(list)) => list
.iter()
.map(|a| {
let id = text(a.get("id"));
let value = modelled.iter().find(|(m, _)| Some(*m) == id.as_deref());
match (a, value) {
(Tag::Compound(c), Some((_, v))) => {
let mut c = c.clone();
c.insert("base".into(), Tag::Double(v.to_bits()));
Tag::Compound(c)
}
_ => a.clone(),
}
})
.collect(),
_ => modelled
.iter()
.map(|(id, v)| {
Tag::compound([("id", string(id)), ("base", Tag::Double(v.to_bits()))])
})
.collect(),
};
map.insert(
"attributes".into(),
Tag::List(vec![
attr("minecraft:max_health", f64::from(m.attrs.max_health)),
attr("minecraft:movement_speed", m.attrs.speed),
attr("minecraft:jump_strength", m.extra.vector[0]),
]),
);
map.insert("attributes".into(), Tag::List(attributes));
}
"minecraft:pig" | "minecraft:strider" => {
let mut equipment = match map.get(&"equipment".into()) {
@@ -387,7 +407,9 @@ pub fn mob_to_nbt(m: &Mob, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
}
// Skeletons are spawned holding a bow (finalizeSpawn); a mob loaded
// from NBT keeps only its saved equipment.
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged" if raw.is_none() => {
"minecraft:skeleton" | "minecraft:stray" | "minecraft:bogged"
if raw.is_none() && m.extra.main_hand.as_deref() == Some("minecraft:bow") =>
{
map.entry("equipment".into()).or_insert_with(|| {
Tag::compound([(
"mainhand",
@@ -926,7 +948,8 @@ pub fn falling_block_to_nbt(
state: Tag,
position: Vec3,
motion: Vec3,
time: u32,
time: i32,
drop_item: bool,
uuid: [i32; 4],
) -> Tag {
let base = Base {
@@ -941,8 +964,8 @@ pub fn falling_block_to_nbt(
let mut tag = base.tag(None);
for (key, value) in [
("BlockState", state),
("Time", Tag::Int(time as i32)),
("DropItem", Tag::Byte(1)),
("Time", Tag::Int(time)),
("DropItem", Tag::Byte(i8::from(drop_item))),
("HurtEntities", Tag::Byte(0)),
("CancelDrop", Tag::Byte(0)),
("FallHurtAmount", Tag::Float(0)),
@@ -953,15 +976,23 @@ pub fn falling_block_to_nbt(
tag
}
/// (block state tag, position, motion, time)
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, u32)> {
pub fn falling_block_from_nbt(tag: &Tag) -> Option<(Tag, Vec3, Vec3, i32)> {
Some((
tag.get("BlockState")?.clone(),
position(tag)?,
motion(tag),
number(tag.get("Time")).unwrap_or(0).max(0) as u32,
number(tag.get("Time")).unwrap_or(0) as i32,
))
}
/// `NoGravity` and `DropItem` (true when absent) of a saved falling block.
pub fn falling_block_flags(tag: &Tag) -> (bool, bool) {
(
flag(tag, "NoGravity"),
number(tag.get("DropItem")).is_none_or(|v| v != 0),
)
}
/// Painting.addAdditionalSaveData: `facing`, `variant` and `block_pos`.
pub fn painting_to_nbt(p: &Painting, uuid: [i32; 4], raw: Option<&Tag>) -> Tag {
let (lo, hi) = p.bounds();
@@ -1107,22 +1138,19 @@ pub fn armor_stand_from_nbt(tag: &Tag, id: u32) -> Option<ArmorStand> {
a.on_ground = flag(tag, "OnGround");
Some(a)
}
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_raft`).
/// A boat entity (`minecraft:<wood>_boat`, `minecraft:bamboo_chest_raft`...):
/// chest boats keep their slots in `Items` (`ContainerHelper.saveAllItems`).
pub fn boat_to_nbt(
wood: &str,
id: &str,
position: Vec3,
motion: Vec3,
yaw: f32,
uuid: [i32; 4],
raw: Option<&Tag>,
items: Option<&[Option<crate::items::ItemStack>]>,
) -> Tag {
let id = if wood == "bamboo" {
"minecraft:bamboo_raft".to_owned()
} else {
format!("minecraft:{wood}_boat")
};
Base {
id: &id,
let mut tag = Base {
id,
uuid,
position,
motion,
@@ -1130,7 +1158,11 @@ pub fn boat_to_nbt(
on_ground: false,
fall_distance: 0.0,
}
.tag(raw)
.tag(raw);
if let (Some(items), Tag::Compound(map)) = (items, &mut tag) {
map.insert("Items".into(), crate::save::encode_items(items));
}
tag
}
/// A vehicle's base entity tag (boats and minecarts).
pub fn vehicle_to_nbt(
+66
View File
@@ -451,6 +451,72 @@ impl Font {
.sum::<f32>()
.ceil()
}
/// `StringSplitter.splitLines`: each line and whether it continues a
/// wrapped one. A line breaks at a `\n`, or once a character overflows
/// `max_width` (never the first visible one) at the last space so far, or
/// before that character; the `\n` or space at the break is dropped.
pub fn split_lines(&self, t: &Text, max_width: f32) -> Vec<(Text, bool)> {
let max_width = max_width.max(1.0);
let chars: Vec<(char, &Style)> = t.chars().collect();
let n = chars.len();
let mut out = Vec::new();
let (mut start, mut wrapped, mut force_new_line) = (0, false, false);
loop {
// LineBreakFinder over what is left.
let (mut width, mut had_visible, mut last_space) = (0.0f32, false, None);
let mut split = None;
for (i, &(c, style)) in chars.iter().enumerate().skip(start) {
if c == '\n' {
split = Some(i);
break;
}
if c == ' ' {
last_space = Some(i);
}
let advance = self.advance(c, style);
width += advance;
if !had_visible || width <= max_width {
had_visible |= advance != 0.0;
} else {
split = Some(last_space.unwrap_or(i));
break;
}
}
let Some(at) = split else { break };
let new_line = chars[at].0 == '\n';
let skip = usize::from(new_line || chars[at].0 == ' ');
force_new_line = new_line;
out.push((t.slice(start, at), wrapped));
wrapped = !new_line;
start = at + skip;
}
if start < n {
out.push((t.slice(start, n), wrapped));
} else if force_new_line {
out.push((Text::empty(), false));
}
out
}
/// `ComponentRenderUtils.wrapComponents`: the split lines, wrapped ones
/// indented by an unstyled space; one empty line for empty text.
pub fn wrap_components(&self, t: &Text, max_width: i32) -> Vec<Text> {
let lines: Vec<Text> = self
.split_lines(t, max_width as f32)
.into_iter()
.map(|(line, wrapped)| {
if wrapped {
Text::literal(" ").append(line)
} else {
line
}
})
.collect();
if lines.is_empty() {
vec![Text::empty()]
} else {
lines
}
}
/// StringSplitter.splitLines: wrap at the last space that fits (or
/// mid-word when none does); `\n` always breaks. Styles are kept.
pub fn split(&self, t: &Text, max_width: f32) -> Vec<Text> {
File diff suppressed because it is too large. Load diff
@@ -415,6 +415,8 @@ impl Game<'_> {
pose[5] = deg(rot[2]);
}
}
// Item layers pose from the hidden arms too (`visible` only skips drawing).
let posed = poses.clone();
let mut hide = |name: &str| {
if poses.has(name) {
let pose = poses.get(name);
@@ -446,6 +448,7 @@ impl Game<'_> {
0,
out,
);
self.armor_stand_items(a, model, base, &posed, packed, out);
// HumanoidArmorLayer: head, chest, legs, feet.
for (slot, layer) in [
(5usize, "helmet"),
@@ -562,3 +565,120 @@ impl Game<'_> {
true
}
}
impl Game<'_> {
/// `ItemInHandLayer` and `CustomHeadLayer` of an armour stand: the hand
/// items in the third-person contexts, and a non-armour head item on the
/// head (`translateToHead`: down a quarter, turned, 0.625 scale).
fn armor_stand_items(
&self,
a: &ArmorStand,
model: &Part,
base: glam::Mat4,
poses: &Poses,
light: u32,
out: &mut Vec<Vertex>,
) {
use horizon_assets::item_model::DisplayContext;
use horizon_client::mobs::render::part_matrix;
let owner = super::item_draw::owner_at(
[a.position.x, a.position.y, a.position.z],
a.yaw,
"minecraft:armor_stand",
);
// `useBabyOffset`: small stands use the baby hand offsets.
let (ox, oy, oz) = if a.small {
(0.0, 1.0, -4.5)
} else {
(1.0, 2.0, -10.0)
};
for (slot, left, context) in [
(0usize, false, DisplayContext::ThirdPersonRightHand),
(1, true, DisplayContext::ThirdPersonLeftHand),
] {
let Some(stack) = &a.equipment[slot] else {
continue;
};
let arm = if left { "left_arm" } else { "right_arm" };
let Some(hand) = part_matrix(model, base, poses, arm, 0.0) else {
continue;
};
let pose = hand
* glam::Mat4::from_rotation_x((-90.0f32).to_radians())
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
* glam::Mat4::from_translation(glam::Vec3::new(
if left { -ox } else { ox } / 16.0,
oy / 16.0,
oz / 16.0,
));
let seed = a.id as i32 + if left { 1 } else { 2 };
let state = self.resolve_item(stack, context, Some(&owner), seed);
self.submit_item_world(&state, pose, light, out);
}
// `CustomHeadLayer`: a worn skull draws the skull model.
if let Some(stack) = &a.equipment[5]
&& let Some(kind) = skull_kind(&stack.item)
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
{
let pose = head * glam::Mat4::from_scale(glam::Vec3::splat(1.1875));
let special = horizon_assets::item_model::SpecialModel {
kind: if kind == "player" {
horizon_assets::item_model::SpecialKind::PlayerHead
} else {
horizon_assets::item_model::SpecialKind::Head {
kind: kind.to_owned(),
texture: None,
animation: 0.0,
}
},
};
let mut sink = horizon_client::item_render::WorldSink {
out,
light,
lights: Some(horizon_client::block_entity_render::diffuse_lights(
self.dim == super::Dim::Nether,
)),
};
self.res.item_renderer.submit_special(
&special,
&serde_json::Value::Null,
false,
pose,
&self.res.atlas.sprites,
&self.item_host(),
&mut sink,
);
return;
}
// A head item that is not worn armour.
if let Some(stack) = &a.equipment[5]
&& self
.res
.items
.get(&stack.item)
.is_none_or(|i| i.equip_asset.is_none())
&& let Some(head) = part_matrix(model, base, poses, "head", 0.0)
{
let pose = head
* glam::Mat4::from_translation(glam::Vec3::new(0.0, -0.25, 0.0))
* glam::Mat4::from_rotation_y(std::f32::consts::PI)
* glam::Mat4::from_scale(glam::Vec3::new(0.625, -0.625, -0.625));
// `updateForLiving`: seeded by the entity id plus HEAD's ordinal.
let state =
self.resolve_item(stack, DisplayContext::Head, Some(&owner), a.id as i32 + 5);
self.submit_item_world(&state, pose, light, out);
}
}
}
/// `AbstractSkullBlock.getType` of a skull item.
fn skull_kind(item: &str) -> Option<&'static str> {
Some(match item {
"minecraft:skeleton_skull" => "skeleton",
"minecraft:wither_skeleton_skull" => "wither_skeleton",
"minecraft:zombie_head" => "zombie",
"minecraft:creeper_head" => "creeper",
"minecraft:dragon_head" => "dragon",
"minecraft:piglin_head" => "piglin",
"minecraft:player_head" => "player",
_ => return None,
})
}
-159
View File
@@ -2,14 +2,10 @@
//! banner item to the block when placed and back to the dropped item when
//! broken (`copy_components` in the banner loot tables).
use super::Game;
use glam::{EulerRot, Mat4, Quat, Vec3};
use horizon_client::{
banner::{self, BannerLayer},
block_entity_render::{Paint, draw_part},
containers::{Banner, BlockEntity},
hud::{HudBatch, rgba},
items::ItemStack,
mesher::Vertex,
};
impl Game<'_> {
@@ -34,161 +30,6 @@ impl Game<'_> {
b.name = stack.custom_name().map(str::to_owned);
}
}
/// A banner in a slot: `item/template_banner`'s `gui` transform on the
/// banner model (pole, bar and flag with every layer), flat-lit and
/// depth-sorted. False when the stack is not a banner.
pub(super) fn draw_banner_icon(
&self,
batch: &mut HudBatch,
stack: &ItemStack,
x: f32,
y: f32,
) -> bool {
let Some(base) = banner::banner_color(&stack.item) else {
return false;
};
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
return true;
};
let banners = &self.res.stations.banners;
let sprites = &self.res.atlas.sprites;
// display.gui, then the item's special transformation (a banner is
// modelled upside down and 2/3 size around the block centre).
let m = Mat4::from_translation(Vec3::new(0.0, -3.25 / 16.0, 0.0))
* Mat4::from_quat(Quat::from_euler(
EulerRot::XYZ,
30f32.to_radians(),
20f32.to_radians(),
0.0,
))
* Mat4::from_scale(Vec3::splat(0.5325))
* Mat4::from_translation(Vec3::new(0.0, -0.5, 0.0))
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
let wave = |name: &str, p: &mut [f32; 9]| {
if name == "flag" {
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
}
};
let none = |_: &str, _: &mut [f32; 9]| {};
let mut passes: Vec<(&str, u32, bool, String)> = vec![
("entity/banner/banner_base", 0xFFFFFF, true, String::new()),
(
"entity/banner/base",
banner::dye_rgb(base),
false,
String::new(),
),
];
for layer in banner::layers(stack).iter().take(16) {
passes.push((
"",
banner::dye_rgb(&layer.color),
false,
banners.banner_texture(&layer.pattern),
));
}
// (depth, pass, screen corners, uv, colour)
let mut faces: Vec<(f32, usize, [[f32; 2]; 4], [[f32; 2]; 4], u32)> = Vec::new();
for (index, (fixed, tint, with_pole, dynamic)) in passes.iter().enumerate() {
let name = if fixed.is_empty() {
dynamic.as_str()
} else {
fixed
};
let Some(sprite) = sprites.get(name).copied() else {
continue;
};
let paint = Paint {
sprite,
tint: 0xFFFFFF,
light: 0,
lights: (Vec3::ZERO, Vec3::ZERO),
};
let mut verts: Vec<Vertex> = Vec::new();
if *with_pole {
draw_part(main, "root", m, &none, paint, &mut verts);
}
draw_part(flag, "root", m, &wave, paint, &mut verts);
for quad in verts.as_chunks::<4>().0 {
let p = |i: usize| Vec3::from(quad[i].pos);
let normal = (p(1) - p(0)).cross(p(2) - p(0)).normalize_or_zero();
// Faces turned away from the viewer are culled.
if normal.z <= 0.0 {
continue;
}
let depth = (0..4).map(|i| p(i).z).sum::<f32>() / 4.0;
let shade = (0.6 + 0.4 * normal.z).min(1.0);
let c = |s: u32| (((tint >> s) & 255) as f32 * shade) as u8;
faces.push((
depth,
index,
std::array::from_fn(|i| {
[
x + 8.0 + quad[i].pos[0] * 16.0,
y + 8.0 - quad[i].pos[1] * 16.0,
]
}),
std::array::from_fn(|i| quad[i].uv),
rgba(c(16), c(8), c(0), 255),
));
}
}
faces.sort_by(|a, b| a.0.total_cmp(&b.0).then(a.1.cmp(&b.1)));
for (_, _, corners, uv, color) in faces {
batch.quad(corners, uv, color);
}
true
}
/// A banner in the first-person hand: the standing banner model under the
/// item's `firstperson_righthand` transform (`m`) and its special
/// transformation. False when the stack is not a banner.
pub(super) fn held_banner_geometry(
&self,
m: Mat4,
stack: &ItemStack,
packed: u32,
out: &mut Vec<Vertex>,
) -> bool {
let Some(base) = banner::banner_color(&stack.item) else {
return false;
};
let Some((main, flag)) = self.res.block_entity_render.banner_models() else {
return true;
};
let m = m
* Mat4::from_translation(Vec3::new(0.5, 0.0, 0.5))
* Mat4::from_scale(Vec3::new(2.0 / 3.0, -2.0 / 3.0, -2.0 / 3.0));
let wave = |name: &str, p: &mut [f32; 9]| {
if name == "flag" {
p[3] = (-0.0125 + 0.01) * std::f32::consts::PI;
}
};
let none = |_: &str, _: &mut [f32; 9]| {};
let lights = horizon_client::block_entity_render::diffuse_lights(false);
let sprites = &self.res.atlas.sprites;
let paint = |name: &str, tint: u32| {
sprites.get(name).map(|&sprite| Paint {
sprite,
tint,
light: packed,
lights,
})
};
if let Some(p) = paint("entity/banner/banner_base", 0xFFFFFF) {
draw_part(main, "root", m, &none, p, out);
draw_part(flag, "root", m, &wave, p, out);
}
if let Some(p) = paint("entity/banner/base", banner::dye_rgb(base)) {
draw_part(flag, "root", m, &wave, p, out);
}
for layer in banner::layers(stack).iter().take(16) {
let texture = self.res.stations.banners.banner_texture(&layer.pattern);
if let Some(p) = paint(&texture, banner::dye_rgb(&layer.color)) {
draw_part(flag, "root", m, &wave, p, out);
}
}
true
}
/// The layers a banner dropped at `pos` carries.
pub(super) fn attach_banner_layers(&mut self, pos: [i32; 3], stack: &mut ItemStack) {
if !stack.item.ends_with("_banner") {
+11 -72
View File
@@ -522,9 +522,6 @@ impl Game<'_> {
else {
return;
};
// The sprite's v range rides in the light bytes (entity shader overlay 6).
let q = |v: f32| (v * 65535.0).round().clamp(0.0, 65535.0) as u32;
let wrap = q(s.v0) | q(s.v1 - s.v0) << 16;
let time = (self.world.game_time.rem_euclid(40)) as f32 + alpha as f32;
let scoping = self.scoping();
for (k, e) in &self.world.block_entities {
@@ -539,75 +536,17 @@ impl Game<'_> {
} else {
(distance / 96.0).max(1.0)
};
let o = (base - camera).as_vec3();
let mut start = 0i32;
for (i, &(color, height)) in b.sections.iter().enumerate() {
let height = if i == b.sections.len() - 1 {
2048
} else {
height
};
let end = start + height;
let scroll = -time;
let v_off = (scroll * 0.2 - (scroll * 0.1).floor()).rem_euclid(1.0);
let rgb = |a: u32| {
a << 24 | (color & 255) << 16 | (color >> 8 & 255) << 8 | (color >> 16 & 255)
};
// Solid beam: radius 0.2, rotated with time.
let r = 0.2 * scale;
let rot = glam::Mat3::from_rotation_y((time * 2.25 - 45.0).to_radians());
let corners = [
rot * glam::Vec3::new(0.0, 0.0, r),
rot * glam::Vec3::new(r, 0.0, 0.0),
rot * glam::Vec3::new(-r, 0.0, 0.0),
rot * glam::Vec3::new(0.0, 0.0, -r),
];
let v2 = -1.0 + v_off;
let v1 = height as f32 * (0.5 / r) + v2;
beam_part(out, o, start, end, corners, [v1, v2], rgb(255), s, wrap);
// Glow: radius 0.25, translucent.
let g = 0.25 * scale;
let corners = [
glam::Vec3::new(-g, 0.0, -g),
glam::Vec3::new(g, 0.0, -g),
glam::Vec3::new(-g, 0.0, g),
glam::Vec3::new(g, 0.0, g),
];
let v1 = height as f32 + v2;
beam_part(out, o, start, end, corners, [v1, v2], rgb(32), s, wrap);
start = end;
}
}
}
}
/// `renderPart`: four quads between the corners (wn, en, ws, es).
#[allow(clippy::too_many_arguments)]
fn beam_part(
out: &mut Vec<Vertex>,
o: glam::Vec3,
start: i32,
end: i32,
[wn, en, ws, es]: [glam::Vec3; 4],
[v1, v2]: [f32; 2],
color: u32,
s: horizon_client::hud::Rect,
wrap: u32,
) {
for (a, b) in [(wn, en), (es, ws), (en, es), (ws, wn)] {
let at = |c: glam::Vec3, y: i32| o + c + glam::Vec3::new(0.0, y as f32, 0.0);
for (p, uv) in [
(at(a, end), [s.u1, v1]),
(at(a, start), [s.u1, v2]),
(at(b, start), [s.u0, v2]),
(at(b, end), [s.u0, v1]),
] {
out.push(Vertex {
pos: p.to_array(),
uv,
layer: 6,
color,
light: wrap,
});
let origin = (glam::DVec3::new(f64::from(k.0), f64::from(k.1), f64::from(k.2))
- camera)
.as_vec3();
horizon_client::block_entity_render::beacon_beam(
origin,
&b.sections,
time,
scale,
s,
out,
);
}
}
}
@@ -0,0 +1,291 @@
//! The client side of animated block entities (`horizon_client::block_entity_anim`):
//! their client tickers (`Level.tickBlockEntities` on the client level) and
//! the block events that drive them (`ClientboundBlockEventPacket` reaching
//! `BlockEntity.triggerEvent`). The renderer reads the resulting state through
//! [`Game::block_anim`] and [`Game::chest_lid`].
use super::Game;
use horizon_client::{
block_entity_anim::{BellEntity, Shulker, ShulkerStatus, Ticker},
block_entity_render::BlockAnim,
collisions::{SceneCollisions, no_collision_empty},
particles::{ParticleOptions, ParticleType},
};
use horizon_core::geometry::{Aabb, Vec3};
use std::collections::HashMap;
/// The handle of the local player among a bell's gathered entities.
const PLAYER: u64 = u64::MAX;
impl Game<'_> {
/// `BlockEntity.triggerEvent(a, b)` on the client copy of the block entity at `pos`.
pub(super) fn block_entity_event(&mut self, pos: [i32; 3], a: i32, b: i32) {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
let time = self.world.game_time;
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
self.be_anims
.chests
.entry(pos)
.or_default()
.trigger_event(a, b);
}
Some(Ticker::Shulker { .. }) => {
self.be_anims
.shulkers
.entry(pos)
.or_default()
.trigger_event(a, b);
}
Some(Ticker::Bell) => {
let gathered = self.bell_gather(pos);
self.be_anims
.bells
.entry(pos)
.or_default()
.trigger_event(a, b, time, || gathered);
}
Some(Ticker::Pot) => {
self.be_anims
.pots
.entry(pos)
.or_default()
.trigger_event(a, b, time);
}
_ => {}
}
}
/// A player opened (`open`) or closed the container at `key`: the opener
/// count block event (`ContainerOpenersCounter` / `ShulkerBoxBlockEntity`
/// `startOpen`/`stopOpen`) for chests, ender chests and shulker boxes,
/// on both halves of a double chest (`CompoundContainer`).
pub(super) fn container_opener_event(&mut self, key: (i32, i32, i32), open: bool) {
// Spectators never count as openers.
if self.spectator() {
return;
}
let pos = [key.0, key.1, key.2];
let state = self.world.block(pos[0], pos[1], pos[2]).0;
match self.be_anims.ticker(state) {
Some(Ticker::Lid) => {
let count = i32::from(open);
self.block_entity_event(pos, 1, count);
if let Some(other) = self.chest_other_half(key) {
self.block_entity_event([other.0, other.1, other.2], 1, count);
}
}
Some(Ticker::Shulker { .. }) => {
let shulker = self.be_anims.shulkers.entry(pos).or_default();
let (count, _) = if open {
shulker.start_open()
} else {
shulker.stop_open()
};
self.block_entity_event(pos, 1, count);
}
_ => {}
}
}
/// `BellBlockEntity.updateEntities`: the living entities whose bounding
/// box meets the bell's block box inflated by 48.
fn bell_gather(&self, pos: [i32; 3]) -> Vec<u64> {
let area = Aabb::new(
Vec3::new(
f64::from(pos[0]) - 48.0,
f64::from(pos[1]) - 48.0,
f64::from(pos[2]) - 48.0,
),
Vec3::new(
f64::from(pos[0]) + 49.0,
f64::from(pos[1]) + 49.0,
f64::from(pos[2]) + 49.0,
),
);
let mut out = Vec::new();
if self
.player_view()
.is_some_and(|p| p.bounds.intersects(area))
{
out.push(PLAYER);
}
for m in &self.mobs {
if !m.removed && m.bounds().intersects(area) {
out.push(u64::from(m.id));
}
}
out
}
/// `ShulkerBoxBlock.canOpen`: a closed box opens only when its lid has
/// room to rise (`level.noCollision` of the half-open lid box).
pub(super) fn shulker_can_open(&self, pos: [i32; 3]) -> bool {
let state = self.world.block(pos[0], pos[1], pos[2]).0;
let Some(Ticker::Shulker { facing }) = self.be_anims.ticker(state) else {
return true;
};
let closed = self
.be_anims
.shulkers
.get(&pos)
.is_none_or(|s| s.status == ShulkerStatus::Closed);
if !closed {
return true;
}
let b = Shulker::lid_box(pos, facing);
let mut scene = SceneCollisions {
world: &self.world,
binding: self.collision,
};
no_collision_empty(
&mut scene,
Aabb::new(Vec3::new(b[0], b[1], b[2]), Vec3::new(b[3], b[4], b[5])),
)
}
/// The client tickers of the loaded animated block entities, once per
/// client tick (`ClientLevel.tickBlockEntities`).
pub(super) fn tick_block_entity_anims(&mut self) {
let Some(r) = &self.renderer else {
return;
};
let present: HashMap<[i32; 3], Ticker> = r
.block_entities()
.filter_map(|(p, s)| Some((*p, self.be_anims.ticker(*s)?)))
.collect();
self.be_anims.retain_present(&present);
let players: Vec<[f64; 3]> = match &self.player {
Some(p) if !self.spectator() => vec![[p.position.x, p.position.y, p.position.z]],
_ => Vec::new(),
};
let raiders = self
.res
.stations
.enchantments
.entity_tags
.get("minecraft:raiders")
.cloned()
.unwrap_or_default();
let frames = self
.res
.items
.block_tags
.get("minecraft:conduit_effect_block")
.cloned()
.unwrap_or_default();
let mut positions: Vec<([i32; 3], Ticker)> = present.into_iter().collect();
positions.sort_unstable_by_key(|(p, _)| *p);
for (pos, ticker) in positions {
match ticker {
Ticker::Lid => self.be_anims.chests.entry(pos).or_default().tick(),
Ticker::Book => {
let anims = &mut self.be_anims;
anims
.books
.entry(pos)
.or_default()
.tick(pos, &players, &mut anims.book_random);
}
Ticker::Shulker { facing } => {
// The lid's push of entities in its way (moveCollidedEntities)
// and the neighbour updates are left to the level.
self.be_anims
.shulkers
.entry(pos)
.or_default()
.tick(pos, facing);
}
Ticker::Skull { powered } => {
self.be_anims.skulls.entry(pos).or_default().tick(powered);
}
Ticker::Bell => {
let player = self.player_view().map(|p| BellEntity {
position: [p.position.x, p.position.y, p.position.z],
alive: p.alive,
raider: false,
});
let mobs = &self.mobs;
let entity = |h: u64| {
if h == PLAYER {
return player;
}
let m = mobs.iter().find(|m| u64::from(m.id) == h)?;
Some(BellEntity {
position: [m.position.x, m.position.y, m.position.z],
alive: m.alive() && !m.removed,
raider: raiders.contains(&m.kind.id),
})
};
let tick = self
.be_anims
.bells
.entry(pos)
.or_default()
.tick(pos, entity);
let centre = [
f64::from(pos[0]) + 0.5,
f64::from(pos[1]) + 0.5,
f64::from(pos[2]) + 0.5,
];
if tick.resonate {
// The server's copy plays the resonance for everyone.
self.audio
.play("minecraft:block.bell.resonate", Some(centre), 1.0, 1.0);
}
if let Some((particles, glow)) = tick.ended {
for (p, color) in particles {
// ColorParticleOption.create(ENTITY_EFFECT, color): ARGB.
let c = color as u32;
let channel = |shift: u32| ((c >> shift) & 255) as f32 / 255.0;
let options = ParticleOptions {
color: [channel(16), channel(8), channel(0), channel(24)],
..ParticleOptions::simple(ParticleType::EntityEffect)
};
self.particles.add(&options, p, [0.0; 3]);
}
// makeRaidersGlow.
for m in self.mobs.iter_mut() {
if glow.contains(&u64::from(m.id)) {
m.add_effect("minecraft:glowing", 60, 0);
}
}
}
}
Ticker::Conduit => {
let world = &self.world;
let facts = &self.res.facts;
let registry = self.res.assets.registry;
let is_water = |p: [i32; 3]| {
facts.get(world.block(p[0], p[1], p[2])).fluid
== horizon_client::blocks::Fluid::Water
};
let is_frame = |p: [i32; 3]| {
registry
.state(world.block(p[0], p[1], p[2]))
.is_some_and(|s| frames.contains(&s.name))
};
let time = world.game_time;
let anims = &mut self.be_anims;
let particles = anims.conduits.entry(pos).or_default().client_tick(
pos,
time,
is_water,
is_frame,
None,
self.particles.random(),
);
let nautilus = ParticleOptions::simple(ParticleType::Nautilus);
for (p, v) in particles {
self.particles.add(&nautilus, p, v);
}
}
Ticker::Pot => {}
}
}
}
/// The renderer's view of the block entity at `pos`.
pub(super) fn block_anim(&self, pos: [i32; 3], partial: f32) -> BlockAnim {
self.be_anims
.block_anim(pos, self.world.game_time, partial, Default::default())
}
/// `ChestLidController.getOpenness(partial)` at `pos`.
pub(super) fn chest_lid(&self, pos: [i32; 3], partial: f32) -> f32 {
self.be_anims.lid(pos, partial)
}
}
+96 -17
View File
@@ -48,7 +48,12 @@ pub(super) struct Boat {
/// left, right, up, down
pub input: [bool; 4],
pub on_ground: bool,
/// `ChestBoat`: a chest with 27 slots in the back seat.
pub chest: bool,
pub items: Vec<Option<ItemStack>>,
}
/// `ChestBoat.CONTAINER_SIZE`.
pub(super) const CHEST_SLOTS: usize = 27;
const WIDTH: f64 = 1.375;
const HEIGHT: f64 = 0.5625;
/// Player.DEFAULT_VEHICLE_ATTACHMENT minus the boat's passenger attachment.
@@ -76,8 +81,16 @@ impl Boat {
paddling: [false; 2],
input: [false; 4],
on_ground: false,
chest: false,
items: Vec::new(),
}
}
/// A chest boat or chest raft.
pub fn with_chest(mut self) -> Self {
self.chest = true;
self.items = vec![None; CHEST_SLOTS];
self
}
pub fn bounds(&self) -> Aabb {
let (w, p) = (WIDTH / 2.0, self.position);
Aabb::new(
@@ -95,6 +108,30 @@ impl Boat {
}
}
/// Item id -> (boat entity id, wood).
impl Boat {
/// The vanilla entity type id (`oak_boat`, `bamboo_chest_raft`), also
/// the item it drops.
pub(super) fn entity_type(&self) -> String {
let chest = if self.chest { "chest_" } else { "" };
if &*self.wood == "bamboo" {
format!("minecraft:bamboo_{chest}raft")
} else {
format!("minecraft:{}_{chest}boat", self.wood)
}
}
}
/// The wood of a boat or chest boat item or entity type, and whether it
/// carries a chest.
pub(super) fn any_boat_wood(item: &str) -> Option<(&str, bool)> {
let name = item.strip_prefix("minecraft:")?;
if let Some(wood) = name
.strip_suffix("_chest_boat")
.or_else(|| name.strip_suffix("_chest_raft"))
{
return Some((wood, true));
}
boat_wood(item).map(|w| (w, false))
}
pub(super) fn boat_wood(item: &str) -> Option<&str> {
let name = item.strip_prefix("minecraft:")?;
name.strip_suffix("_boat")
@@ -103,10 +140,20 @@ pub(super) fn boat_wood(item: &str) -> Option<&str> {
}
pub(super) struct BoatModels {
pub models: HashMap<String, Part>,
pub chest_models: HashMap<String, Part>,
}
impl BoatModels {
/// Whether a boat of this wood (with or without a chest) can be drawn.
pub fn has(&self, wood: &str, chest: bool) -> bool {
if chest {
self.chest_models.contains_key(wood)
} else {
self.models.contains_key(wood)
}
}
pub fn load(root: &std::path::Path) -> Self {
let mut models = HashMap::new();
let mut chest_models = HashMap::new();
if let Ok(bytes) = std::fs::read(root.join("entity-models.json"))
&& let Ok(serde_json::Value::Object(all)) =
serde_json::from_slice::<serde_json::Value>(&bytes)
@@ -119,9 +166,19 @@ impl BoatModels {
{
models.insert(wood.to_owned(), part);
}
if let Some(wood) = key
.strip_prefix("minecraft:chest_boat/")
.and_then(|k| k.strip_suffix("#main"))
&& let Some(part) = horizon_client::mobs::load_model(root, key)
{
chest_models.insert(wood.to_owned(), part);
}
}
}
Self { models }
Self {
models,
chest_models,
}
}
}
impl Game<'_> {
@@ -457,10 +514,10 @@ impl Game<'_> {
}
/// `BoatItem.use`: aim at a block or the water surface.
pub(super) fn place_boat(&mut self, stack: &ItemStack) -> bool {
let Some(wood) = boat_wood(&stack.item) else {
let Some((wood, chest)) = any_boat_wood(&stack.item) else {
return false;
};
if !self.boat_models.models.contains_key(wood) {
if !self.boat_models.has(wood, chest) {
return false;
}
let Some(eye) = self.eye_position() else {
@@ -507,12 +564,15 @@ impl Game<'_> {
(None, Some(w)) => w.0,
(None, None) => return false,
};
let boat = Boat::new(
let mut boat = Boat::new(
self.next_entity + 1,
Arc::from(wood),
Vec3::new(hit[0], hit[1], hit[2]),
self.yaw,
);
if chest {
boat = boat.with_chest();
}
let bb = boat.bounds();
let (lo, hi) = (bb.min_position(), bb.max_position());
let e = 1.0e-7;
@@ -575,11 +635,24 @@ impl Game<'_> {
return false;
};
let sneaking = self.keys.contains(&winit::keyboard::KeyCode::ShiftLeft);
if !self.before_block(d)
|| sneaking
|| self.boats[i].out_of_control >= 60.0
|| self.riding.is_some()
{
if !self.before_block(d) {
return false;
}
// ChestBoat.interact: sneaking (or a full seat) opens the chest.
if self.boats[i].chest && (sneaking || self.riding.is_some()) {
let b = &self.boats[i];
let key = (i32::MAX - 15, super::minecarts::CART_KEY_Y, b.id as i32);
self.world.block_entities.insert(
key,
horizon_client::containers::BlockEntity::Chest(b.items.clone()),
);
self.container = Some(key);
self.screen = super::Screen::Inventory;
self.capture(false);
self.right = false;
return true;
}
if sneaking || self.boats[i].out_of_control >= 60.0 || self.riding.is_some() {
return false;
}
self.riding = Some(self.boats[i].id);
@@ -601,13 +674,13 @@ impl Game<'_> {
let destroyed = self.creative || b.damage > 40.0;
if destroyed {
let b = self.boats.remove(i);
let at = [b.position.x, b.position.y, b.position.z];
if !self.creative && self.ui.rules.bool("entity_drops") {
let item = if &*b.wood == "bamboo" {
"minecraft:bamboo_raft".to_owned()
} else {
format!("minecraft:{}_boat", b.wood)
};
let at = [b.position.x, b.position.y, b.position.z];
// ChestBoat.destroy: chestVehicleDestroyed spills the contents.
for stack in b.items.iter().flatten() {
self.spawn_item(stack.clone(), at, super::dispenser::new_item_motion(), 10);
}
let item = b.entity_type();
self.spawn_item(
ItemStack {
item: Arc::from(item.as_str()),
@@ -628,14 +701,20 @@ impl Game<'_> {
pub(super) fn boat_geometry(&self, camera: glam::DVec3, alpha: f64, out: &mut Vec<Vertex>) {
let a = alpha as f32;
for b in &self.boats {
let Some(model) = self.boat_models.models.get(&*b.wood) else {
let models = if b.chest {
&self.boat_models.chest_models
} else {
&self.boat_models.models
};
let Some(model) = models.get(&*b.wood) else {
continue;
};
let folder = if b.chest { "chest_boat" } else { "boat" };
let Some(uv) = self
.res
.atlas
.sprites
.get(&format!("entity/boat/{}", b.wood))
.get(&format!("entity/{folder}/{}", b.wood))
.map(|r| [r.u0, r.v0, r.u1, r.v1])
else {
continue;
+16 -59
View File
@@ -4,10 +4,9 @@ use super::{
commands::{R, err},
};
use horizon_client::{
bossbar::BossBars,
bossbar::{self, BossBars},
command::{Arg, Context},
hud::{HudBatch, rgba},
save,
hud_overlays, save,
scoreboard::{self as sb, Comp, comp_text},
scoreboard_cmd::format_list,
text::Text,
@@ -334,26 +333,10 @@ impl Game<'_> {
Ok(self.resolve_component(value))
}
/// How many boss bars the dragon and withers already show.
pub(super) fn builtin_bar_count(&self) -> usize {
let dragon = usize::from(self.dragon.is_some() && self.dim == super::Dim::End);
let withers = self.player.as_ref().map_or(0, |p| {
self.mobs
.iter()
.filter(|m| {
m.species() == horizon_client::mobs::Species::Wither
&& m.alive()
&& m.position.distance_squared(p.position) <= 64.0 * 64.0
})
.count()
});
dragon + withers
}
// ---- drawing ------------------------------------------------------------------
/// `BossHealthOverlay`: the visible custom bars under the built-in ones.
pub(super) fn draw_custom_bossbars(&self, batch: &mut HudBatch, mut y: f32) {
let atlas = &self.res.atlas;
/// The visible custom bars (`/bossbar`), after the built-in ones.
pub(super) fn custom_bossbars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for b in self
.bosses
.bars
@@ -361,44 +344,18 @@ impl Game<'_> {
.iter()
.filter(|b| b.visible && b.player)
{
if y >= batch.height / 3.0 {
break;
}
let x = (batch.width / 2.0 - 91.0).floor();
let sprite = |kind: &str| format!("gui/sprites/boss_bar/{kind}");
batch.sprite(atlas, &sprite(&format!("{}_background", b.color)), x, y);
if b.progress() > 0.0 {
batch.sprite_part(
atlas,
&sprite(&format!("{}_progress", b.color)),
x,
y,
b.progress(),
);
}
if b.style != "progress" {
batch.sprite(atlas, &sprite(&format!("{}_background", b.style)), x, y);
if b.progress() > 0.0 {
batch.sprite_part(
atlas,
&sprite(&format!("{}_progress", b.style)),
x,
y,
b.progress(),
);
}
}
let name = comp_text(&b.name);
let w = atlas.font.width_text(&name);
batch.text_styled(
atlas,
&name,
(batch.width / 2.0 - w / 2.0).floor(),
y - 9.0,
rgba(255, 255, 255, 255),
true,
);
y += 19.0;
bars.push(hud_overlays::BossBar {
name: comp_text(&b.name),
progress: b.progress(),
color: bossbar::COLORS
.iter()
.position(|c| *c == b.color)
.unwrap_or(0),
overlay: bossbar::STYLES
.iter()
.position(|s| *s == b.style)
.unwrap_or(0),
});
}
}
}
+1 -1
View File
@@ -511,7 +511,7 @@ impl Game<'_> {
return;
};
screen.resize(batch.width, batch.height);
let bg = self.ui.chat.options.background_opacity;
let bg = self.ui.chat.options.background_opacity as f32;
screen.draw(
batch,
&self.res.atlas,
+6 -8
View File
@@ -266,13 +266,11 @@ impl Game<'_> {
}
}
Target::Painting(_) => "minecraft:painting".into(),
Target::Boat(id) => self.boats.iter().find(|b| b.id == id).map_or_else(
|| "minecraft:oak_boat".into(),
|b| {
let kind = if &*b.wood == "bamboo" { "raft" } else { "boat" };
format!("minecraft:{}_{kind}", b.wood)
},
),
Target::Boat(id) => self
.boats
.iter()
.find(|b| b.id == id)
.map_or_else(|| "minecraft:oak_boat".into(), |b| b.entity_type()),
Target::Cart(id) => self
.carts
.iter()
@@ -687,7 +685,7 @@ impl Game<'_> {
.append("]")
.with_color(color)
}
fn item_rarity(&self, item: &str) -> String {
pub(super) fn item_rarity(&self, item: &str) -> String {
static RARITY: std::sync::OnceLock<std::collections::HashMap<String, String>> =
std::sync::OnceLock::new();
let map = RARITY.get_or_init(|| {
@@ -73,12 +73,13 @@ impl Game<'_> {
let uuid = self.uuid_for(id);
let b = self.boats.iter().find(|b| b.id == id)?;
entity_nbt::boat_to_nbt(
&b.wood,
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist.raw.get(&id),
b.chest.then_some(&b.items[..]),
)
}
Target::Cart(id) => {
@@ -67,10 +67,10 @@ impl Game<'_> {
} else {
let text = self.component_arg(ctx, "title")?;
if ctx.has("subtitle") {
self.titles.subtitle = Some(text);
self.titles.set_subtitle(text);
"show.subtitle"
} else if ctx.has("actionbar") {
self.titles.set_overlay(text);
self.titles.set_overlay(text, false);
"show.actionbar"
} else {
self.titles.set_title(text);
+1 -1
View File
@@ -509,7 +509,7 @@ impl Game<'_> {
"frames": self.frames,
"ticks": self.ticks,
"day_time": self.world.day_time,
"music": self.music.current(),
"music": self.music.current(&self.audio.engine),
"fishing": self.hook_status(),
"tridents": self.tridents.len(),
"rockets": self.rockets.len(),
+209 -452
View File
@@ -37,17 +37,6 @@ pub(super) struct Creatures {
/// The wandering trader spawner's clock.
pub trader: super::wandering_trader::Spawner,
}
/// MobCategory caps (maxInstancesPerChunk with the 17x17 spawnable chunk area).
const CATEGORIES: [(&str, usize); 7] = [
("monster", 70),
("creature", 10),
("ambient", 15),
("axolotls", 5),
("underground_water_creature", 5),
("water_creature", 5),
("water_ambient", 20),
];
struct ProjWorld<'a, 'w> {
w: &'a MobWorld<'w>,
targets: Vec<(Who, Aabb)>,
@@ -138,6 +127,14 @@ fn seen(m: &Mob, player: Option<[i32; 4]>) -> Seen {
}
_ => 0,
},
pushable: m.pushable(),
category: m.category(),
potential_job: horizon_client::mobs::villager_brain::potential_job_site(m),
meeting: (m.species() == Species::Villager)
.then_some(m.extra.meeting)
.flatten(),
profession: m.extra.variant2,
xp: horizon_client::mobs::villager::xp(m),
}
}
impl Game<'_> {
@@ -355,99 +352,6 @@ impl Game<'_> {
}
horizon_client::chunk::MIN_Y
}
fn weighted_entry(
&mut self,
entries: &[horizon_client::biome::SpawnEntry],
) -> Option<horizon_client::biome::SpawnEntry> {
let total: u32 = entries.iter().map(|e| e.weight).sum();
if total == 0 {
return None;
}
let mut r = self.world.random.next_int_bound(total as i32).ok()? as u32;
for e in entries {
if r < e.weight {
return Some(e.clone());
}
r -= e.weight;
}
None
}
/// NaturalSpawner.spawnMobsForChunkGeneration (creature packs at generation).
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
let Some(chunk) = self.world.chunks.get(&(cx, cz)).cloned() else {
return;
};
let biome = self.res.biomes.get(chunk.biome(8, 64, 8)).clone();
let Some(entries) = biome.spawns.get("creature").cloned() else {
return;
};
let (xo, zo) = (cx * 16, cz * 16);
while self.world.random.next_float() < biome.creature_probability {
let Some(entry) = self.weighted_entry(&entries) else {
return;
};
let count = entry.min
+ self
.world
.random
.next_int_bound((entry.max - entry.min + 1) as i32)
.unwrap_or(0) as u32;
let mut x = xo + self.world.random.next_int_bound(16).unwrap_or(0);
let mut z = zo + self.world.random.next_int_bound(16).unwrap_or(0);
let (sx, sz) = (x, z);
let Some(kind) = self.res.mob_types.get(&entry.entity).cloned() else {
continue;
};
for _ in 0..count {
let mut success = false;
let mut attempts = 0;
while !success && attempts < 4 {
attempts += 1;
let mut y = self.motion_top(x, z);
let placement = self.res.mob_data.placement(&entry.entity);
if placement == horizon_client::mobs::data::Placement::OnGround {
let below = self.world.block(x, y - 1, z);
if self.res.mob_data.state(below).flags & horizon_client::mobs::data::LAND
!= 0
{
y -= 1;
}
}
let ok = {
let w = self.mob_world_base(&[]);
spawning::placement_ok(kind.species, &w, x, y, z) && {
let mut r = self.world.random.clone();
spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z)
}
};
if ok {
let width = kind.width;
let fx =
f64::from(x).clamp(f64::from(xo) + width, f64::from(xo) + 16.0 - width);
let fz =
f64::from(z).clamp(f64::from(zo) + width, f64::from(zo) + 16.0 - width);
if self
.spawn_mob(&entry.entity, [fx, f64::from(y), fz], true)
.is_some()
{
success = true;
}
}
x += self.world.random.next_int_bound(5).unwrap_or(0)
- self.world.random.next_int_bound(5).unwrap_or(0);
z += self.world.random.next_int_bound(5).unwrap_or(0)
- self.world.random.next_int_bound(5).unwrap_or(0);
while x < xo || x >= xo + 16 || z < zo || z >= zo + 16 {
x = sx + self.world.random.next_int_bound(5).unwrap_or(0)
- self.world.random.next_int_bound(5).unwrap_or(0);
z = sz + self.world.random.next_int_bound(5).unwrap_or(0)
- self.world.random.next_int_bound(5).unwrap_or(0);
}
}
}
}
}
pub(super) fn mob_world_base<'s>(&'s self, others: &'s [Seen]) -> MobWorld<'s> {
let (rain, thunder) = self.world.weather.levels(1.0);
MobWorld {
@@ -467,6 +371,9 @@ impl Game<'_> {
block_tags: &self.res.items.block_tags,
biomes: &self.res.biomes,
mob_griefing: self.ui.rules.bool("mob_griefing"),
end: self.dim == Dim::End,
world_seed: self.level.seed,
sea_level: if self.dim == Dim::Nether { 32 } else { 63 },
difficulty: self.local_difficulty(
self.player
.as_ref()
@@ -514,162 +421,6 @@ impl Game<'_> {
}
}
}
/// NaturalSpawner.spawnForChunk over the chunks within 8 of the player.
fn spawn_natural(&mut self) {
let Some(p) = self.player.as_ref().map(|p| p.position) else {
return;
};
let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4);
let mut counts = [0usize; 7];
for m in &self.mobs {
if let Some(i) = CATEGORIES.iter().position(|c| c.0 == m.kind.category) {
counts[i] += 1;
}
}
let creature_tick = self.world.game_time % 400 == 0;
let mut chunks: Vec<(i32, i32)> = (-8..=8)
.flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz)))
.filter(|c| self.world.chunks.contains_key(c))
.collect();
// ChunkMap: shuffled each tick.
for i in (1..chunks.len()).rev() {
let j = self.world.random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
chunks.swap(i, j);
}
for (cx, cz) in chunks {
for (ci, (category, cap)) in CATEGORIES.iter().enumerate() {
if counts[ci] >= *cap || *category == "creature" && !creature_tick {
continue;
}
counts[ci] += self.spawn_category_for_chunk(category, cx, cz, p);
}
}
}
/// NaturalSpawner.spawnCategoryForPosition at a random point in the chunk.
fn spawn_category_for_chunk(
&mut self,
category: &str,
cx: i32,
cz: i32,
player: Vec3,
) -> usize {
let x0 = cx * 16 + self.world.random.next_int_bound(16).unwrap_or(0);
let z0 = cz * 16 + self.world.random.next_int_bound(16).unwrap_or(0);
let top = self.motion_top(x0, z0) + 1;
let min = horizon_client::chunk::MIN_Y;
let y = min + self.world.random.next_int_bound(top - min + 1).unwrap_or(0);
if y < min + 1 {
return 0;
}
let start = self.res.facts.get(self.world.block(x0, y, z0));
if start.full_collision {
return 0;
}
let mut spawned = 0;
for _ in 0..3 {
let (mut x, mut z) = (x0, z0);
let mut entry: Option<horizon_client::biome::SpawnEntry> = None;
let mut group = (self.world.random.next_float() * 4.0).ceil() as u32;
let mut cluster = 0;
let mut i = 0;
while i < group {
i += 1;
x += self.world.random.next_int_bound(6).unwrap_or(0)
- self.world.random.next_int_bound(6).unwrap_or(0);
z += self.world.random.next_int_bound(6).unwrap_or(0)
- self.world.random.next_int_bound(6).unwrap_or(0);
let (fx, fz) = (f64::from(x) + 0.5, f64::from(z) + 0.5);
let d2 = (fx - player.x).powi(2)
+ (f64::from(y) - player.y).powi(2)
+ (fz - player.z).powi(2);
if d2 <= 576.0 {
continue;
}
if entry.is_none() {
let Some(chunk) = self.world.chunks.get(&(x >> 4, z >> 4)) else {
break;
};
let biome = self.res.biomes.get(chunk.biome(x, y, z)).clone();
let list = match self.structure_spawns(category, [x, y, z]) {
Some(list) => list,
None => match biome.spawns.get(category).cloned() {
Some(list) => list,
None => break,
},
};
let list: Vec<_> = list
.into_iter()
.filter(|e| self.res.mob_types.contains_key(&e.entity))
.collect();
let Some(e) = self.weighted_entry(&list) else {
break;
};
group = e.min
+ self
.world
.random
.next_int_bound((e.max - e.min + 1) as i32)
.unwrap_or(0) as u32;
entry = Some(e);
}
let e = entry.clone().unwrap_or_else(|| unreachable!());
let despawn = if category == "water_ambient" {
64.0
} else {
128.0
};
if d2 > despawn * despawn {
continue;
}
let Some(kind) = self.res.mob_types.get(&e.entity).cloned() else {
break;
};
let ok = {
let w = self.mob_world_base(&[]);
let mut r = self.world.random.clone();
let placed = spawning::placement_ok(kind.species, &w, x, y, z)
&& spawning::spawn_rules_ok(kind.species, &w, &mut r, x, y, z);
let half = kind.width / 2.0;
let room = w.no_collision(Aabb::new(
Vec3::new(fx - half, f64::from(y), fz - half),
Vec3::new(fx + half, f64::from(y) + kind.height, fz + half),
));
// PathfinderMob.checkSpawnRules: walk target value >= 0 (monsters need darkness).
let light_ok = !kind.category.eq("monster")
|| matches!(
kind.species,
Species::Slime
| Species::SulfurCube
| Species::Ghast
| Species::MagmaCube
| Species::Guardian
| Species::ElderGuardian
)
|| w.brightness(x, y, z) <= 0.5;
let consumed = r;
(placed && room && light_ok, consumed)
};
self.world.random = ok.1;
if !ok.0 {
continue;
}
if d2 > 16384.0 && kind.category != "creature" {
continue;
}
if self
.spawn_mob(&e.entity, [fx, f64::from(y), fz], false)
.is_some()
{
cluster += 1;
spawned += 1;
if spawned >= 4 || cluster >= group.max(1) {
return spawned;
}
}
}
}
spawned
}
/// Advance every mob one tick, then apply their events.
pub(super) fn tick_creatures(&mut self) -> Result<()> {
self.attack_ticks = self.attack_ticks.saturating_add(1);
@@ -688,9 +439,12 @@ impl Game<'_> {
}
self.tick_inhabited();
self.tick_leads();
self.tick_wandering_trader();
self.spawn_natural();
self.tick_patrol_spawner();
// ServerChunkCache.tickChunks: the custom spawners tick only with spawn_mobs.
if self.ui.rules.bool("spawn_mobs") {
self.tick_wandering_trader();
self.tick_patrol_spawner();
}
self.tick_raids();
let uuid = self.level.player_uuid;
let snapshot: Vec<Seen> = self.mobs.iter().map(|m| seen(m, uuid)).collect();
@@ -700,6 +454,7 @@ impl Game<'_> {
{
let w = self.mob_world_base(&snapshot);
let mut scratch = self.world.random.clone();
let mut pushes = Vec::new();
mobs.retain_mut(|m| {
if !w
.world
@@ -707,18 +462,22 @@ impl Game<'_> {
{
return !m.hostile() || m.persistent;
}
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
let from = events.len();
let keep = m.tick(&w, &mut scratch, &mut events);
horizon_client::mobs::take_mob_pushes(&mut events, from, &mut pushes);
if !keep && !m.alive() && !m.removed {
dead.push(m.clone());
}
keep
});
push_apart(&mut mobs);
for m in &mut mobs {
horizon_client::mobs::apply_mob_pushes(m, &mut pushes);
}
}
self.mobs.append(&mut mobs);
self.sync_riders();
self.tick_skeleton_traps();
self.cache_mob_icons();
self.tick_apparition();
self.villager_reputation_tick();
self.villager_pickups();
@@ -734,18 +493,25 @@ impl Game<'_> {
self.tick_player_effects();
self.piglin_pickups();
self.sulfur_cube_pickups();
// Hurt and death sounds of damage dealt outside the mob ticks.
let mut queued = Vec::new();
for m in &mut self.mobs {
queued.append(&mut m.outbox);
}
self.handle_mob_events(queued)?;
Ok(())
}
/// LivingEntity.die / dropAllDeathLoot.
fn mob_died(&mut self, m: &Mob) {
let by_player = self.last_hurt_by_player.remove(&m.id) || m.ai.last_hurt_by_player > 0;
let mut dying = m.clone();
// LivingEntity.die: the death is heard, and a sculk catalyst nearby
// takes the experience.
// takes the experience (before the loot is dropped).
self.sculk_mob_died(m);
let consumed =
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |game| {
self.catalyst_takes_experience([m.position.x, m.position.y, m.position.z], |_| {
if by_player {
experience_reward(m, &mut game.world.random)
horizon_client::mobs::drops::base_experience(&mut dying).max(0) as u32
} else {
0
}
@@ -760,103 +526,8 @@ impl Game<'_> {
self.score_entity_removed(m.id);
self.raider_died(m, by_player);
self.villager_murdered(m, by_player);
let held = self.held().cloned();
let pos = [m.position.x, m.position.y + 0.5, m.position.z];
if !m.is_baby() {
let mut drops = self.res.items.entity_drops(
&m.kind.id,
by_player,
held.as_ref(),
&mut self.world.random,
);
if m.species() == Species::Sheep && !m.extra.sheared {
let color = horizon_client::mobs::render::DYE_NAMES[m.extra.color as usize & 15];
drops.extend(self.res.items.loot_table(
&format!("minecraft:entities/sheep/{color}"),
by_player,
held.as_ref(),
&mut self.world.random,
));
}
for d in drops {
if d.count > 0 && &*d.item != "minecraft:air" {
self.spawn_item(d, pos, [0.0, 0.2, 0.0], 10);
}
}
// Saddles, horse armour and chests always drop.
let mut gear: Vec<&str> = Vec::new();
if let Some(saddle) = m.saddle_item() {
self.spawn_item(saddle, pos, [0.0, 0.2, 0.0], 10);
}
// WitherBoss.dropCustomDeathLoot.
if m.species() == Species::Wither {
gear.push("minecraft:nether_star");
}
if species_drops_harness(m)
&& let Some(h) = m.extra.head.as_deref()
{
gear.push(h);
}
if m.extra.chested && horizon_client::mobs::equine::is_equine(m.species()) {
gear.push("minecraft:chest");
}
if horizon_client::mobs::equine::is_equine(m.species())
&& let Some(armor) = m.extra.head.as_deref()
{
gear.push(armor);
}
for item in gear {
self.spawn_item(ItemStack::new(item, 1), pos, [0.0, 0.2, 0.0], 10);
}
// AbstractChestedHorse.dropEquipment: the chest contents.
for stack in m.extra.items.clone().into_iter().flatten() {
self.spawn_item(stack, pos, [0.0, 0.2, 0.0], 10);
}
self.raider_death_loot(m, pos);
let worn = if horizon_client::mobs::equine::is_equine(m.species()) {
[None, None, None, None]
} else {
[
m.extra.head.clone(),
m.extra.chest.clone(),
m.extra.legs.clone(),
m.extra.feet.clone(),
]
};
for (index, item) in std::iter::once(m.extra.main_hand.clone())
.chain(worn)
.enumerate()
{
// Main hand, then head to feet (the enchantment slots).
let slot = if index == 0 {
horizon_client::mobs::gear::MAIN
} else {
horizon_client::mobs::gear::HEAD + index - 1
};
// Equipment drop chance 8.5% (+ player kill only).
// Vexes never drop their sword.
if let Some(item) = &item
&& by_player
&& &**item != horizon_client::mobs::raider::BANNER
&& m.species() != Species::Vex
&& self.world.random.next_float() < 0.085
{
let mut stack = ItemStack::new(item, 1);
if let Some(g) = horizon_client::mobs::gear::gear(m) {
for (e, l) in &g.slots[slot] {
stack.enchant(e, *l);
}
}
self.spawn_item(stack, pos, [0.0, 0.2, 0.0], 10);
}
}
}
if by_player && !consumed {
let xp = experience_reward(m, &mut self.world.random);
if xp > 0 {
self.spawn_orbs(pos, xp);
}
}
self.drop_death_loot(&mut dying, by_player, consumed, pos);
if m.species() == Species::SulfurCube {
self.split_sulfur_cube(m);
}
@@ -881,6 +552,89 @@ impl Game<'_> {
}
}
}
/// `dropAllDeathLoot` through `mobs::drops`: the entity loot table from the
/// world's random sequence, equipment by drop chance, species extras and
/// the experience orbs, spawned in order.
fn drop_death_loot(&mut self, m: &mut Mob, by_player: bool, consumed: bool, pos: [f64; 3]) {
use horizon_client::mobs::{DamageKind, Who, drops};
use horizon_world::loot::{LootDamage, LootEntity};
let Some(loot) = horizon_client::structure_blocks::loot_data(&self.options.resources)
else {
return;
};
let player = || {
let mut e = LootEntity::new("minecraft:player");
if let Some(held) = self.held() {
e.equipment.push(("mainhand".into(), to_loot(held)));
}
e
};
let damage = m.last_damage.map(|(d, _)| {
let source = match d.attacker {
Some(Who::Player) => Some(player()),
Some(Who::Mob(id)) => self
.mobs
.iter()
.find(|o| o.id == id)
.map(|o| drops::loot_entity(o, None)),
None => None,
};
let direct = match d.kind {
DamageKind::Arrow => Some(LootEntity {
kind: "minecraft:arrow".into(),
..LootEntity::default()
}),
DamageKind::Fireball => Some(LootEntity {
kind: "minecraft:fireball".into(),
..LootEntity::default()
}),
_ => source.clone(),
};
let player_source = matches!(d.attacker, Some(Who::Player));
LootDamage {
kind: damage_type(d.kind, player_source).into(),
source,
direct,
}
});
let vehicle = m
.extra
.mount
.and_then(|id| self.mobs.iter().find(|o| o.id == id))
.map(|o| LootEntity::new(&o.kind.id));
let death = drops::Death {
loot: &loot,
damage,
player: by_player.then(player),
luck: 0.0,
vehicle,
mob_drops: self.ui.rules.bool("mob_drops"),
world_seed: self.level.seed,
};
let (items, skulls) = drops::die(m, &death, &mut self.rand_sequences);
if skulls
&& let Some(Who::Mob(id)) = m.last_damage.and_then(|(d, _)| d.attacker)
&& let Some(creeper) = self.mobs.iter_mut().find(|o| o.id == id)
{
// Creeper.droppedSkulls: one head per charged explosion.
creeper.extra.charged = false;
}
let raider = horizon_client::mobs::raider::is_raider(m.species());
for d in items {
match d {
drops::Drop::Item(stack) => {
let item = if raider && stack.item == horizon_client::mobs::raider::BANNER {
horizon_client::mobs::raider::ominous_banner()
} else {
from_loot(&stack)
};
self.spawn_item(item, pos, [0.0, 0.2, 0.0], 10);
}
drops::Drop::Orb(value) if !consumed => self.spawn_orbs(pos, value as u32),
drops::Drop::Orb(_) => {}
}
}
}
pub(super) fn handle_mob_events(&mut self, events: Vec<MobEvent>) -> Result<()> {
for e in events {
match e {
@@ -888,6 +642,17 @@ impl Game<'_> {
self.audio
.play(&format!("minecraft:{name}"), Some(at), volume, pitch)
}
MobEvent::LevelEventSound {
sound,
at,
volume,
spread,
} => {
let r = &mut self.world.random;
let pitch = (r.next_float() - r.next_float()) * spread + 1.0;
self.audio
.play(&format!("minecraft:{sound}"), Some(at), volume, pitch)
}
MobEvent::SoundId(name, at, volume, pitch) => {
self.audio.play(&name, Some(at), volume, pitch)
}
@@ -950,24 +715,18 @@ impl Game<'_> {
if let Some(t) = self.mobs.iter_mut().find(|m| m.id == target) {
let hit = t.damage(amount, source);
if hit {
let (voice, center, dead) = (
horizon_client::mobs::ai::voice_sounds(t),
t.center(),
!t.alive(),
);
killed = dead;
let pitch = (self.world.random.next_float()
- self.world.random.next_float())
* 0.2
+ 1.0;
let s = voice[if dead { 2 } else { 1 }];
if !s.is_empty() {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
1.0,
pitch,
);
killed = !t.alive();
// The hurt or death sound the mob raised.
let center = t.center();
for event in std::mem::take(&mut t.outbox) {
if let MobEvent::Sound(s, _, volume, pitch) = event {
self.audio.play(
&format!("minecraft:{s}"),
Some(center),
volume,
pitch,
);
}
}
}
}
@@ -2614,11 +2373,6 @@ impl Game<'_> {
}
}
}
fn species_drops_harness(m: &Mob) -> bool {
// Body equipment set to drop when killed: a harness, nautilus armour, a
// sulfur cube's swallowed block.
horizon_client::mobs::species::body_slot(m.species())
}
fn eat_sound(s: Species) -> &'static str {
match s {
Species::Pig => "entity.pig.eat",
@@ -2633,73 +2387,67 @@ fn eat_sound(s: Species) -> &'static str {
}
}
/// Mob.getBaseExperienceReward
fn experience_reward(m: &Mob, random: &mut impl RandomSource) -> u32 {
use Species as S;
match m.species() {
S::Blaze | S::Ghast | S::Hoglin | S::Zoglin | S::PiglinBrute | S::Breeze => 10,
S::Slime | S::MagmaCube => m.extra.size.max(1) as u32,
S::Endermite => 3,
S::Bat
| S::Squid
| S::GlowSquid
| S::Cod
| S::Salmon
| S::TropicalFish
| S::Pufferfish
| S::Dolphin => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
S::IronGolem | S::SnowGolem | S::Villager | S::WanderingTrader => 0,
// SulfurCube.getBaseExperienceReward.
S::SulfurCube if m.is_baby() => 0,
S::SulfurCube => 1 + random.next_int_bound(2).unwrap_or(0) as u32,
// Animals in the monster category (Animal.getBaseExperienceReward).
S::CamelHusk | S::ZombieNautilus => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
_ if m.hostile() => {
5 + if m.extra.main_hand.is_some() {
1 + random.next_int_bound(3).unwrap_or(0) as u32
} else {
0
}
}
_ if m.is_baby() => 0,
_ => 1 + random.next_int_bound(3).unwrap_or(0) as u32,
/// The damage type id of a Horizon damage kind.
fn damage_type(kind: horizon_client::mobs::DamageKind, player: bool) -> &'static str {
use horizon_client::mobs::DamageKind as K;
match kind {
K::PlayerAttack => "minecraft:player_attack",
K::MobAttack => "minecraft:mob_attack",
K::Arrow => "minecraft:arrow",
K::Thrown => "minecraft:thrown",
K::Fireball => "minecraft:fireball",
K::Explosion if player => "minecraft:player_explosion",
K::Explosion => "minecraft:explosion",
K::Fall => "minecraft:fall",
K::OnFire => "minecraft:on_fire",
K::InFire => "minecraft:in_fire",
K::Lava => "minecraft:lava",
K::Drown => "minecraft:drown",
K::Magic => "minecraft:magic",
K::Sting => "minecraft:sting",
K::Cactus => "minecraft:cactus",
K::Generic => "minecraft:generic",
K::InWall => "minecraft:in_wall",
K::Thorns => "minecraft:thorns",
K::SonicBoom => "minecraft:sonic_boom",
K::OutOfWorld => "minecraft:out_of_world",
}
}
/// Entity.push between overlapping mobs (LivingEntity.pushEntities).
fn push_apart(mobs: &mut [Mob]) {
let n = mobs.len();
for i in 0..n {
if !mobs[i].alive() {
continue;
}
let bi = mobs[i].bounds();
for j in (i + 1)..n {
if !mobs[j].alive() || !bi.intersects(mobs[j].bounds()) {
continue;
}
// A rider and its vehicle do not push each other.
if mobs[i].extra.mount == Some(mobs[j].id) || mobs[j].extra.mount == Some(mobs[i].id) {
continue;
}
let (dx, dz) = (
mobs[j].position.x - mobs[i].position.x,
mobs[j].position.z - mobs[i].position.z,
);
let mut dd = dx.abs().max(dz.abs());
if dd >= 0.01 {
dd = dd.sqrt();
let (mut xa, mut za) = (dx / dd, dz / dd);
let inv = (1.0 / dd).min(1.0);
xa *= inv * 0.05;
za *= inv * 0.05;
mobs[i].velocity.x -= xa;
mobs[i].velocity.z -= za;
mobs[j].velocity.x += xa;
mobs[j].velocity.z += za;
}
}
/// A Horizon item stack as a loot stack (id, count, damage, enchantments).
fn to_loot(stack: &ItemStack) -> horizon_world::loot::LootStack {
horizon_world::loot::LootStack {
item: stack.item.to_string(),
count: stack.count as i32,
damage: stack.damage as i32,
enchantments: stack
.enchantments()
.iter()
.map(|(e, l)| (e.to_string(), *l as i32))
.collect(),
potion: None,
map: false,
ominous_amplifier: None,
}
}
/// A generated loot stack as a Horizon item stack.
fn from_loot(stack: &horizon_world::loot::LootStack) -> ItemStack {
let mut item = ItemStack {
item: Arc::from(stack.item.as_str()),
count: stack.count.max(1) as u32,
damage: stack.damage.max(0) as u32,
extra: None,
};
for (id, level) in &stack.enchantments {
item.enchant(id, (*level).max(0) as u32);
}
if let Some(potion) = &stack.potion {
item = item.with_potion(potion);
}
if let Some(amplifier) = stack.ominous_amplifier.filter(|a| *a > 0) {
item.data_mut().ominous_amplifier = Some(amplifier as u8);
}
item
}
impl Game<'_> {
/// The rest of `NetherPortalBlock.randomTick` after a won roll: with a
/// player close enough for spawning, a zombified piglin spawns on the block
@@ -2707,7 +2455,10 @@ impl Game<'_> {
pub(super) fn portal_spawn(&mut self, pos: [i32; 3]) {
// ChunkMap.anyPlayerCloseEnoughForSpawning: a non-spectator within 128
// blocks of the chunk centre.
let centre = [f64::from((pos[0] >> 4) * 16 + 8), f64::from((pos[2] >> 4) * 16 + 8)];
let centre = [
f64::from((pos[0] >> 4) * 16 + 8),
f64::from((pos[2] >> 4) * 16 + 8),
];
let spectator = self.spectator();
let Some(close) = self.player.as_ref().filter(|_| !spectator).map(|p| {
let (dx, dz) = (p.position.x - centre[0], p.position.z - centre[1]);
@@ -2719,7 +2470,9 @@ impl Game<'_> {
return;
}
let mut y = pos[1];
while &*self.res.facts.get(self.world.block(pos[0], y, pos[2])).name == "minecraft:nether_portal" {
while &*self.res.facts.get(self.world.block(pos[0], y, pos[2])).name
== "minecraft:nether_portal"
{
y -= 1;
}
// isValidSpawn: a sturdy top face (portal frames emit no light).
@@ -2727,7 +2480,11 @@ impl Game<'_> {
if below.sturdy_full & (1 << 1) == 0 {
return;
}
let at = [f64::from(pos[0]) + 0.5, f64::from(y + 1), f64::from(pos[2]) + 0.5];
let at = [
f64::from(pos[0]) + 0.5,
f64::from(y + 1),
f64::from(pos[2]) + 0.5,
];
let _ = self.spawn_mob("minecraft:zombified_piglin", at, false);
}
}
@@ -0,0 +1,510 @@
//! The Rust side of tools/test_dispenser_parity.py: with
//! `HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT` the game loads its resources, runs
//! every case (a dispenser or dropper with items, blocks and target entities
//! around it) through `dispense_from` on a fresh headless world and prints what
//! changed in the format of `VanillaDispenserOracle`.
use super::{
Game,
dispenser::{Slot, body_in_head, mob_set_slot, mob_slot_item},
};
use horizon_client::{
Result,
chunk::Chunk,
containers::BlockEntity,
gamerules::Value as RuleValue,
item_data::{canon_stack, parse_stack},
mobs::{Mob, Species},
projectiles::ProjectileKind,
world::World,
};
use horizon_core::{
position::BlockPos,
random::{LegacyRandom, RandomSource},
};
use horizon_world::{level::Ticks, registry::BlockStateId};
use serde_json::Value;
use std::{cell::RefCell, collections::HashMap, fmt::Write as _, io::Write as _, sync::Arc};
thread_local! {
/// Level and game events recorded while a fixture case runs.
static LOG: RefCell<Option<Vec<String>>> = const { RefCell::new(None) };
}
/// Record a line if a fixture case is running.
pub(super) fn record(line: impl FnOnce() -> String) {
LOG.with(|log| {
if let Some(lines) = log.borrow_mut().as_mut() {
lines.push(line());
}
});
}
/// `Level.levelEvent` lines for the level events the behaviour library reported.
pub(super) fn record_events(events: &[horizon_world::level::LevelEvent]) {
for event in events {
if let horizon_world::level::LevelEvent::LevelEvent { id, pos, data } = event {
record(|| format!("L {id} {},{},{} {data}", pos.x, pos.y, pos.z));
}
}
}
fn num(v: f64) -> String {
format!("{v:.6}")
}
fn floor_pos(p: [f64; 3]) -> String {
format!(
"{},{},{}",
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32
)
}
/// The targets a case put in front of the dispenser.
enum Target {
Mob(u32),
Stand(u32),
}
impl Game<'_> {
/// Run every case of `spec` ("CASES,OUTPUT") and write the results.
pub(super) fn dispense_fixture(&mut self, spec: &str) -> Result<()> {
let (input, output) = spec
.split_once(',')
.ok_or("HORIZON_DISPENSE_FIXTURE=CASES,OUTPUT")?;
// Saplings and feature placers grow from the live feature registry.
self.install_live_features();
let text = std::fs::read_to_string(input)?;
let mut out = std::io::BufWriter::new(std::fs::File::create(output)?);
for (index, line) in text.lines().enumerate() {
let case: Value = serde_json::from_str(line)?;
writeln!(out, "case {index}")?;
match self.dispense_case(&case) {
Ok(lines) => {
for l in lines {
writeln!(out, "{l}")?;
}
}
Err(e) => writeln!(out, "failed {e}")?,
}
}
LOG.with(|log| *log.borrow_mut() = None);
Ok(())
}
fn fixture_reset(&mut self, seed: i64) {
self.world = World::new(self.res.facts.air, seed);
// The oracle level is plains everywhere (bone meal's flowers, freezing).
let plains = u32::from(self.res.biomes.id("minecraft:plains"));
for cx in -2..=2 {
for cz in -2..=2 {
let mut chunk = Chunk::empty(cx, cz);
for section in &mut chunk.sections {
Arc::make_mut(section).biomes = horizon_core::paletted::PalettedContainer::new(
horizon_client::chunk::BIOME_STRATEGY,
plains,
);
}
self.world.chunks.insert((cx, cz), Arc::new(chunk));
}
}
self.block_ticks = Ticks::new(0);
self.tick_chunks.clear();
self.sync_tick_chunks();
self.mobs.clear();
self.dropped.clear();
self.creatures.projectiles.clear();
self.tnts.clear();
self.boats.clear();
self.carts.clear();
self.armor_stands.clear();
self.rockets.clear();
self.potions.clear_thrown();
self.level_events.clear();
self.orbs.clear();
self.falling.clear();
self.ui.rules = Default::default();
// The oracle level plays on normal difficulty.
self.ui.difficulty = 2;
// The oracle level has world seed 0 and fresh random sequences.
self.level.seed = 0;
self.rand_sequences = Default::default();
}
fn dispense_case(&mut self, case: &Value) -> Result<Vec<String>> {
let seed = case["seed"].as_i64().unwrap_or(0);
self.fixture_reset(seed);
// The oracle level answers one sky and one block light everywhere.
let light = case["light"].as_array();
let level = |i: usize, default: i64| {
light
.and_then(|l| l.get(i))
.and_then(Value::as_i64)
.unwrap_or(default) as u8
};
let packed = (level(0, 15) << 4) | level(1, 0);
for chunk in self.world.chunks.values_mut() {
for section in &mut Arc::make_mut(chunk).sections {
Arc::make_mut(section).light = horizon_client::chunk::Light::Uniform(packed);
}
}
self.world.game_time = case["time"].as_i64().unwrap_or(0);
// The oracle level's clock stands at day time 6000.
self.world.day_time = 6000;
if let Some(rules) = case["rules"].as_object() {
for (name, value) in rules {
if let Some(b) = value.as_bool() {
self.ui.rules.set(name, RuleValue::Bool(b));
}
}
}
let mut before: HashMap<(i32, i32, i32), u32> = HashMap::new();
for cell in case["cells"].as_array().into_iter().flatten() {
let p = (
cell[0].as_i64().unwrap_or(0) as i32,
cell[1].as_i64().unwrap_or(0) as i32,
cell[2].as_i64().unwrap_or(0) as i32,
);
let state = BlockStateId(cell[3].as_u64().unwrap_or(0) as u32);
self.world.set(p.0, p.1, p.2, state)?;
before.insert(p, state.0);
if let Some(entity) = BlockEntity::for_block(&self.res.facts.get(state).name) {
self.world.block_entities.insert(p, entity);
}
}
for item in case["items"].as_array().into_iter().flatten() {
let p = (
item[0].as_i64().unwrap_or(0) as i32,
item[1].as_i64().unwrap_or(0) as i32,
item[2].as_i64().unwrap_or(0) as i32,
);
let slot = item[3].as_u64().unwrap_or(0) as usize;
let stack = parse_stack(item[4].as_str().unwrap_or("-"))?;
if let Some(cell) = self
.world
.block_entities
.get_mut(&p)
.and_then(|e| e.slots_mut().get_mut(slot))
{
*cell = stack;
}
}
let mut targets = Vec::new();
for e in case["entities"].as_array().into_iter().flatten() {
targets.push(self.fixture_target(e)?);
}
// Leashable.setLeashedTo: a lead from a target to an earlier one.
for (e, t) in case["entities"]
.as_array()
.into_iter()
.flatten()
.zip(&targets)
{
let (Some(holder), Target::Mob(id)) = (e["leash"].as_u64(), t) else {
continue;
};
let holder = match targets.get(holder as usize) {
Some(Target::Mob(h)) => horizon_client::mobs::leash::Holder::Mob(*h),
_ => continue,
};
if let Some(m) = self.mobs.iter_mut().find(|m| m.id == *id) {
m.extra.leash = Some(holder);
}
}
// Creating the targets must not move the stream the dispenser draws from.
self.world.random = LegacyRandom::new(seed);
self.world.block_log = Some(Vec::new());
self.audio.capture = Some(Vec::new());
LOG.with(|log| *log.borrow_mut() = Some(Vec::new()));
let old_mobs: Vec<u32> = self.mobs.iter().map(|m| m.id).collect();
let old_stands: Vec<u32> = self.armor_stands.iter().map(|s| s.id).collect();
let pos = &case["pos"];
let origin = BlockPos::new(
pos[0].as_i64().unwrap_or(0) as i32,
pos[1].as_i64().unwrap_or(0) as i32,
pos[2].as_i64().unwrap_or(0) as i32,
);
let state = self.world.block(origin.x, origin.y, origin.z);
self.dispense_from(origin, state)?;
let mut out = Vec::new();
let mut changed: Vec<(i32, i32, i32)> = self.world.block_log.take().unwrap_or_default();
changed.sort_unstable();
changed.dedup();
for p in changed {
let now = self.world.block(p.0, p.1, p.2).0;
if before.get(&p).copied().unwrap_or(self.res.facts.air.0) != now {
out.push(format!("set {},{},{} {now}", p.0, p.1, p.2));
}
}
let mut containers: Vec<_> = self.world.block_entities.keys().copied().collect();
containers.sort_unstable();
for p in containers {
let entity = &self.world.block_entities[&p];
for (i, slot) in entity.slots().iter().enumerate() {
if let Some(stack) = slot {
out.push(format!(
"inv {},{},{} {i} {}",
p.0,
p.1,
p.2,
canon_stack(Some(stack))
));
}
}
}
self.fixture_entities(&old_mobs, &old_stands, &mut out);
for (i, t) in targets.iter().enumerate() {
out.push(self.fixture_describe(i, t));
}
let log = LOG.with(|log| log.borrow_mut().take()).unwrap_or_default();
out.extend(log);
for (sound, at, _, _) in self.audio.capture.take().unwrap_or_default() {
out.push(format!("S {} {sound}", floor_pos(at)));
}
out.push(format!("rng {}", self.world.random.next_long()));
Ok(out)
}
/// `EntityType.create` and `snapTo` for one target: no spawn finalization.
fn fixture_target(&mut self, e: &Value) -> Result<Target> {
let kind = e["type"].as_str().unwrap_or("minecraft:pig");
let p = &e["pos"];
let at = horizon_core::geometry::Vec3::new(
p[0].as_f64().unwrap_or(0.0),
p[1].as_f64().unwrap_or(0.0),
p[2].as_f64().unwrap_or(0.0),
);
let yaw = e["yaw"].as_f64().unwrap_or(0.0) as f32;
self.next_entity += 1;
let id = self.next_entity;
if kind == "minecraft:armor_stand" {
self.armor_stands
.push(horizon_client::entities::ArmorStand::new(id, at, yaw));
return Ok(Target::Stand(id));
}
let t = self
.res
.mob_types
.get(kind)
.cloned()
.ok_or_else(|| format!("no mob type {kind}"))?;
let mut mob = Mob::new(id, t, at, yaw);
// EntityType.create equips nothing (that is finalizeSpawn's job).
for slot in [
&mut mob.extra.main_hand,
&mut mob.extra.off_hand,
&mut mob.extra.head,
&mut mob.extra.chest,
&mut mob.extra.legs,
&mut mob.extra.feet,
] {
*slot = None;
}
let s = mob.species();
let flag = |name: &str| e[name].as_bool().unwrap_or(false);
let ageable = !matches!(
s,
Species::Zombie | Species::Skeleton | Species::Bogged | Species::SnowGolem
);
if flag("baby") && ageable {
mob.extra.age = -24000;
}
let tamable =
body_in_head(s) || matches!(s, Species::Cat | Species::Parrot | Species::Ocelot);
if flag("tame") && tamable {
mob.extra.tame = true;
}
if flag("sheared") && matches!(s, Species::Sheep | Species::Bogged | Species::SnowGolem) {
mob.extra.sheared = true;
}
if flag("chest")
&& matches!(
s,
Species::Donkey | Species::Mule | Species::Llama | Species::TraderLlama
)
{
mob.extra.chested = true;
}
if flag("loot") {
mob.extra.pick_up_loot = true;
}
if flag("brown") && s == Species::Mooshroom {
mob.extra.variant = 1;
}
for (slot, snbt) in e["equip"].as_object().into_iter().flatten() {
if let (Some(slot), Some(stack)) = (
Slot::from_name(slot),
parse_stack(snbt.as_str().unwrap_or("-"))?,
) {
mob_set_slot(&mut mob, slot, stack);
}
}
self.mobs.push(mob);
Ok(Target::Mob(id))
}
/// `T` lines: removal, equipment by `EquipmentSlot`, shearing readiness, chests.
fn fixture_describe(&self, index: usize, target: &Target) -> String {
let mut line = format!("T {index} ");
match *target {
Target::Stand(id) => {
line.push_str("minecraft:armor_stand");
let Some(stand) = self.armor_stands.iter().find(|s| s.id == id) else {
line.push_str(" removed");
return line;
};
for (slot, name) in [
(0, "mainhand"),
(1, "offhand"),
(2, "feet"),
(3, "legs"),
(4, "chest"),
(5, "head"),
] {
if let Some(s) = &stand.equipment[slot] {
let _ = write!(line, " {name}={}x{}", s.item, s.count);
}
}
}
Target::Mob(id) => {
let Some(m) = self.mobs.iter().find(|m| m.id == id) else {
return line + "removed";
};
line.push_str(&m.kind.id);
if m.removed || !m.alive() {
line.push_str(" removed");
}
let s = m.species();
let x = &m.extra;
for (slot, name) in [
(Slot::MainHand, "mainhand"),
(Slot::OffHand, "offhand"),
(Slot::Feet, "feet"),
(Slot::Legs, "legs"),
(Slot::Chest, "chest"),
(Slot::Head, "head"),
(Slot::Body, "body"),
(Slot::Saddle, "saddle"),
] {
if let Some(item) = mob_slot_item(m, slot) {
let _ = write!(line, " {name}={item}x1");
}
}
let ready = match s {
Species::Sheep => Some(!x.sheared && !m.is_baby()),
Species::Mooshroom => Some(!m.is_baby()),
Species::Bogged => Some(!x.sheared),
Species::SnowGolem => Some(!x.sheared),
_ => None,
};
if let Some(r) = ready {
line.push_str(if r { " ready" } else { " unready" });
}
if x.chested
&& matches!(
s,
Species::Donkey | Species::Mule | Species::Llama | Species::TraderLlama
)
{
line.push_str(" chest");
}
if x.leash.is_some() {
line.push_str(" leashed");
}
}
}
line
}
/// `N` lines for every entity the dispenser added.
fn fixture_entities(&self, old_mobs: &[u32], old_stands: &[u32], out: &mut Vec<String>) {
let line = |kind: &str, p: [f64; 3], v: [f64; 3], extra: &str| {
let mut l = format!(
"N {kind} {} {} {} {} {} {}",
num(p[0]),
num(p[1]),
num(p[2]),
num(v[0]),
num(v[1]),
num(v[2])
);
if !extra.is_empty() {
l.push(' ');
l.push_str(extra);
}
l
};
for m in self.mobs.iter().filter(|m| !old_mobs.contains(&m.id)) {
let (p, v) = (m.position, m.velocity);
out.push(line(&m.kind.id, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
for s in self
.armor_stands
.iter()
.filter(|s| !old_stands.contains(&s.id))
{
let (p, v) = (s.position, s.velocity);
out.push(line(
"minecraft:armor_stand",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
}
for d in &self.dropped {
let (p, v) = (d.position, d.velocity);
out.push(line(
"minecraft:item",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
&canon_stack(Some(&d.stack)),
));
}
for shot in &self.creatures.projectiles {
let kind = match &shot.kind {
ProjectileKind::Arrow {
effect: Some(("minecraft:glowing", 200)),
..
} => "minecraft:spectral_arrow",
ProjectileKind::Arrow { .. } => "minecraft:arrow",
ProjectileKind::Snowball => "minecraft:snowball",
ProjectileKind::Egg => "minecraft:egg",
ProjectileKind::SmallFireball => "minecraft:small_fireball",
ProjectileKind::WindCharge => "minecraft:wind_charge",
_ => "minecraft:unknown_projectile",
};
let (p, v) = (shot.position, shot.velocity);
out.push(line(kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
for (kind, p, v) in self.potions.thrown_entities() {
out.push(line(kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
for (p, v) in self.rockets.iter().map(|r| r.motion()) {
out.push(line(
"minecraft:firework_rocket",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
"",
));
}
for t in &self.tnts {
let (p, v) = (t.position, t.velocity);
out.push(line(
"minecraft:tnt",
[p.x, p.y, p.z],
[v.x, v.y, v.z],
&format!("fuse={}", t.fuse),
));
}
for b in &self.boats {
let kind = b.entity_type();
let (p, v) = (b.position, b.velocity);
out.push(line(&kind, [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
for c in &self.carts {
let (p, v) = (c.position, c.velocity);
out.push(line(c.kind.id(), [p.x, p.y, p.z], [v.x, v.y, v.z], ""));
}
}
}
File diff suppressed because it is too large. Load diff
+26 -24
View File
@@ -8,9 +8,11 @@
//! exit portal, the egg and the next gateway; and its rendering and boss bar.
use super::{Dim, Game};
use horizon_client::{
hud::{HudBatch, rgba},
hud::HudBatch,
hud_layout, hud_overlays,
mesher::Vertex,
particles::{ParticleOptions, ParticleType},
text::Text,
};
use horizon_core::{
geometry::{Aabb, Vec3},
@@ -1839,30 +1841,30 @@ impl Game<'_> {
});
self.breath_clouds.append(&mut clouds);
}
/// The boss bar (`BossHealthOverlay`): name and the pink progress bar.
pub(super) fn draw_boss_bar(&self, batch: &mut HudBatch) {
let dragon = self.dragon.as_ref().filter(|_| self.dim == Dim::End);
let y = self.draw_wither_bars(batch, if dragon.is_some() { 31.0 } else { 12.0 });
self.draw_raid_bar(batch, y);
let Some(d) = dragon else {
return;
};
let atlas = &self.res.atlas;
let x = (batch.width / 2.0 - 91.0).floor();
let y = 12.0;
batch.sprite(atlas, "gui/sprites/boss_bar/pink_background", x, y);
let progress = (d.health / MAX_HEALTH).clamp(0.0, 1.0);
batch.sprite_part(atlas, "gui/sprites/boss_bar/pink_progress", x, y, progress);
let name = self.translate("entity.minecraft.ender_dragon", "Ender Dragon");
let w = HudBatch::text_width(atlas, &name);
batch.text(
atlas,
&name,
(batch.width / 2.0 - w / 2.0).floor(),
y - 9.0,
rgba(255, 255, 255, 255),
true,
/// `BossHealthOverlay`: the dragon's pink bar, then the withers', the
/// raids' and the custom bars, in that order.
pub(super) fn draw_boss_bars(&self, batch: &mut HudBatch) {
let mut bars = Vec::new();
if let Some(d) = self.dragon.as_ref().filter(|_| self.dim == Dim::End) {
bars.push(hud_overlays::BossBar {
name: Text::literal(
self.translate("entity.minecraft.ender_dragon", "Ender Dragon"),
),
progress: (d.health / MAX_HEALTH).clamp(0.0, 1.0),
color: 0,
overlay: 0,
});
}
self.wither_bars(&mut bars);
self.raid_bars(&mut bars);
self.custom_bossbars(&mut bars);
let draws = hud_overlays::boss_overlay(
batch.width as i32,
batch.height as i32,
&bars,
&self.res.atlas.font,
);
hud_layout::paint(batch, &self.res.atlas, &draws);
}
/// `music.dragon` while the boss bar plays music.
pub(super) fn boss_music(&self) -> bool {
+3 -220
View File
@@ -37,10 +37,6 @@ pub(super) struct EndState {
job: Option<GatewayJob>,
/// Ticks until the player can use a gateway again (TheEndGatewayBlockEntity cooldown).
gateway_cooldown: u32,
/// End portal and gateway blocks per loaded chunk (true = gateway), for rendering.
portals: HashMap<(i32, i32), Vec<([i32; 3], bool)>>,
/// Chunks left to scan for portal blocks in the current round.
scan: Vec<(i32, i32)>,
/// The Overworld generator, for stronghold positions (ServerLevel.findNearestMapStructure).
locator: std::sync::Mutex<Option<Arc<VanillaTerrain>>>,
/// Thrown eyes of ender in flight.
@@ -132,11 +128,6 @@ impl EyeOfEnder {
}
}
/// Chunks scanned for portal blocks per tick.
const SCAN_PER_TICK: usize = 6;
/// Radius (chunks) around the player in which portal blocks are rendered.
const PORTAL_RADIUS: i32 = 10;
impl Game<'_> {
/// The environment attributes of the current dimension.
pub(super) fn dim_environment(&self) -> &Environment {
@@ -295,7 +286,6 @@ impl Game<'_> {
)?;
}
}
self.rescan_portals_around(c, 1);
// Global LevelEvent 1038: END_PORTAL_SPAWN.
self.audio
.play("minecraft:block.end_portal.spawn", None, 1.0, 1.0);
@@ -350,7 +340,6 @@ impl Game<'_> {
self.tick_fight();
self.tick_dragon_fireballs();
}
self.scan_portals();
self.tick_eyes();
if self.dim == Dim::End {
self.end.flash.tick(self.world.game_time);
@@ -645,7 +634,6 @@ impl Game<'_> {
let id = registry.resolve(&state)?;
self.level_set([origin[0] + d[0], origin[1] + 2, origin[2] + d[1]], id)?;
}
self.rescan_portals_around(origin, 4);
Ok(())
}
@@ -669,7 +657,6 @@ impl Game<'_> {
}
}
}
self.rescan_portals_around(origin, 0);
Ok(())
}
@@ -887,214 +874,10 @@ impl Game<'_> {
Ok(())
}
/// Scan a few loaded chunks per tick for End portal and gateway blocks.
fn scan_portals(&mut self) {
let Some(p) = self.player.as_ref() else {
return;
};
let (pcx, pcz) = (
(p.position.x.floor() as i32) >> 4,
(p.position.z.floor() as i32) >> 4,
);
let chunks = &self.world.chunks;
self.end.portals.retain(|k, _| chunks.contains_key(k));
if self.end.scan.is_empty() {
let mut queue: Vec<(i32, i32)> = chunks
.keys()
.copied()
.filter(|&(x, z)| {
(x - pcx).abs() <= PORTAL_RADIUS && (z - pcz).abs() <= PORTAL_RADIUS
})
.collect();
// Nearest last: they are popped first.
queue.sort_by_key(|&(x, z)| std::cmp::Reverse((x - pcx).pow(2) + (z - pcz).pow(2)));
self.end.scan = queue;
}
for _ in 0..SCAN_PER_TICK {
let Some((cx, cz)) = self.end.scan.pop() else {
break;
};
self.scan_portal_chunk(cx, cz);
}
}
fn scan_portal_chunk(&mut self, cx: i32, cz: i32) {
let registry = self.res.assets.registry;
let (Some(portal), Some(gateway)) = (
registry.default_state("minecraft:end_portal"),
registry.default_state("minecraft:end_gateway"),
) else {
return;
};
let (portal, gateway) = (portal.0 as u16, gateway.0 as u16);
let Some(chunk) = self.world.chunks.get(&(cx, cz)) else {
self.end.portals.remove(&(cx, cz));
return;
};
let mut found = Vec::new();
for (s, section) in chunk.sections.iter().enumerate() {
if !section
.blocks
.maybe_has(|v| v == u32::from(portal) || v == u32::from(gateway))
{
continue;
}
let mut blocks = Box::new([0u16; 4096]);
section.unpack(&mut blocks);
for (i, &b) in blocks.iter().enumerate() {
if b == portal || b == gateway {
let y = horizon_client::chunk::MIN_Y + s as i32 * 16 + (i >> 8) as i32;
found.push((
[
cx * 16 + (i & 15) as i32,
y,
cz * 16 + ((i >> 4) & 15) as i32,
],
b == gateway,
));
}
}
}
if found.is_empty() {
self.end.portals.remove(&(cx, cz));
} else {
self.end.portals.insert((cx, cz), found);
}
}
fn rescan_portals_around(&mut self, p: [i32; 3], r: i32) {
for cz in (p[2] - r) >> 4..=(p[2] + r) >> 4 {
for cx in (p[0] - r) >> 4..=(p[0] + r) >> 4 {
self.scan_portal_chunk(cx, cz);
}
}
}
/// End portal and gateway surfaces (TheEndPortalRenderer: the starfield
/// approximated by end_portal.png in world space, unanimated) and the End
/// crystals on the spikes, as camera-relative entity geometry.
/// The End crystals on the spikes and thrown eyes of ender, as
/// camera-relative entity geometry (End portals and gateways are block
/// entities).
pub(super) fn end_geometry(&self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
let atlas = &self.res.atlas;
if let Some(r) = atlas.sprites.get("entity/end_portal/end_portal").copied() {
let full = (15 * 16) | ((15 * 16) << 8);
// Each block shows a quarter of the texture so neighbours join up.
let quarter = |a: i32, b: i32| {
let (fa, fb) = (a.rem_euclid(4) as f32 / 4.0, b.rem_euclid(4) as f32 / 4.0);
let (du, dv) = (r.u1 - r.u0, r.v1 - r.v0);
(
r.u0 + du * fa,
r.u0 + du * (fa + 0.25),
r.v0 + dv * fb,
r.v0 + dv * (fb + 0.25),
)
};
for blocks in self.end.portals.values() {
for &(p, gateway) in blocks {
let base = glam::DVec3::new(f64::from(p[0]), f64::from(p[1]), f64::from(p[2]))
- camera;
if base.length_squared() > 160.0 * 160.0 {
continue;
}
let b = base.as_vec3();
// Portal: top face at 12/16 and bottom at 6/16; gateway: the whole cube.
let (y0, y1) = if gateway { (0.0, 1.0) } else { (0.375, 0.75) };
let color = if gateway { 0xFFE0_E0A0 } else { 0xFFD0_E070 };
let mut quad =
|corners: [glam::Vec3; 4], (u0, u1, v0, v1): (f32, f32, f32, f32)| {
let uv = [[u0, v0], [u1, v0], [u1, v1], [u0, v1]];
for (c, uv) in corners.into_iter().zip(uv) {
out.push(Vertex {
pos: (b + c).to_array(),
uv,
layer: 0,
color,
light: full,
});
}
};
let v = glam::Vec3::new;
let open = |d: [i32; 3]| {
let n = [p[0] + d[0], p[1] + d[1], p[2] + d[2]];
!self
.res
.facts
.get(self.world.block(n[0], n[1], n[2]))
.solid_render
};
let uv_xz = quarter(p[0], p[2]);
if !gateway || open([0, 1, 0]) {
quad(
[
v(0.0, y1, 0.0),
v(1.0, y1, 0.0),
v(1.0, y1, 1.0),
v(0.0, y1, 1.0),
],
uv_xz,
);
}
if !gateway || open([0, -1, 0]) {
quad(
[
v(0.0, y0, 0.0),
v(1.0, y0, 0.0),
v(1.0, y0, 1.0),
v(0.0, y0, 1.0),
],
uv_xz,
);
}
if gateway {
let uv_x = quarter(p[2], p[1]);
let uv_z = quarter(p[0], p[1]);
if open([-1, 0, 0]) {
quad(
[
v(0.0, 0.0, 0.0),
v(0.0, 0.0, 1.0),
v(0.0, 1.0, 1.0),
v(0.0, 1.0, 0.0),
],
uv_x,
);
}
if open([1, 0, 0]) {
quad(
[
v(1.0, 0.0, 0.0),
v(1.0, 0.0, 1.0),
v(1.0, 1.0, 1.0),
v(1.0, 1.0, 0.0),
],
uv_x,
);
}
if open([0, 0, -1]) {
quad(
[
v(0.0, 0.0, 0.0),
v(1.0, 0.0, 0.0),
v(1.0, 1.0, 0.0),
v(0.0, 1.0, 0.0),
],
uv_z,
);
}
if open([0, 0, 1]) {
quad(
[
v(0.0, 0.0, 1.0),
v(1.0, 0.0, 1.0),
v(1.0, 1.0, 1.0),
v(0.0, 1.0, 1.0),
],
uv_z,
);
}
}
}
}
}
if self.dim == Dim::End {
self.end_crystal_geometry(camera, out);
}
+9 -4
View File
@@ -29,6 +29,12 @@ pub(super) struct Rocket {
angled: bool,
icon: Option<String>,
}
impl Rocket {
/// Position and motion (the dispenser parity fixture).
pub(super) fn motion(&self) -> (Vec3, Vec3) {
(self.position, self.velocity)
}
}
fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
// The item's default component: flight 1, no explosions.
stack
@@ -39,10 +45,9 @@ fn fireworks_of(stack: &ItemStack) -> (u8, Vec<FireworkExplosion>) {
impl Game<'_> {
pub(super) fn new_rocket(&mut self, at: Vec3, stack: &ItemStack, attached: bool) -> Rocket {
let (flight, explosions) = fireworks_of(stack);
let r = &mut self.world.random;
let tri = |r: &mut horizon_core::random::LegacyRandom| {
0.002297 * (f64::from(r.next_float()) - f64::from(r.next_float()))
};
// FireworkRocketEntity: motion and lifetime come from the rocket's own random.
let r = &mut super::dispenser::entity_random();
let tri = |r: &mut horizon_core::random::LegacyRandom| r.triangle(0.0, 0.002297);
let velocity = Vec3::new(tri(r), 0.05, tri(r));
let lifetime = 10 * (1 + i32::from(flight))
+ r.next_int_bound(6).unwrap_or(0)
+121 -63
View File
@@ -1,16 +1,19 @@
//! Field of view (`Camera.tickFov`, `calculateFov`,
//! `AbstractClientPlayer.getFieldOfViewModifier`) and the spyglass
//! (`SpyglassItem`, `Hud.extractSpyglassOverlay`).
//! `AbstractClientPlayer.getFieldOfViewModifier`), view and hurt bobbing
//! (`GameRenderer.bobHurt`, `bobView`) and the spyglass (`SpyglassItem`,
//! `Hud.extractSpyglassOverlay`). The arithmetic is `horizon_client::camera`.
use super::Game;
use horizon_client::{
camera::{
BobState, CameraFluid, FovPlayer, HurtBob, UseItem, bob_matrix, field_of_view_modifier,
},
fog::{FogType, fog_type},
hud::{HudBatch, rgba},
hud::HudBatch,
hud_layout, hud_overlays,
};
/// `Abilities.walkingSpeed`.
const WALKING_SPEED: f32 = 0.1;
/// `options.fovEffectScale` (vanilla default 1).
const FOV_EFFECT_SCALE: f32 = 1.0;
impl Game<'_> {
/// `Player.isScoping`: using a spyglass.
@@ -19,54 +22,115 @@ impl Game<'_> {
.and_then(|(slot, _)| self.stack_in(slot))
.is_some_and(|s| &*s.item == "minecraft:spyglass")
}
/// `AbstractClientPlayer.getFieldOfViewModifier` (first person).
fn fov_target(&self) -> f32 {
let Some(p) = &self.player else { return 1.0 };
let mut modifier = 1.0f32;
// Movement speed (sprinting, speed and slowness) relative to walking.
modifier *= (p.speed / WALKING_SPEED + 1.0) / 2.0;
if let Some((slot, ticks)) = self.using
&& let Some(stack) = self.stack_in(slot)
{
match &*stack.item {
"minecraft:bow" => {
let scale = (ticks as f32 / 20.0).min(1.0);
modifier *= 1.0 - scale * scale * 0.15;
}
"minecraft:spyglass" => return 0.1,
_ => {}
}
}
1.0 + (modifier - 1.0) * FOV_EFFECT_SCALE
/// `options.fovEffectScale`.
pub(super) fn fov_effect_scale(&self) -> f32 {
self.settings.file.double("fovEffectScale") as f32
}
/// `Camera.tickFov`.
/// What `AbstractClientPlayer.getFieldOfViewModifier` reads.
fn fov_player(&self) -> Option<FovPlayer> {
let p = self.player.as_ref()?;
let using = self.using.and_then(|(slot, ticks)| {
let stack = self.stack_in(slot)?;
let item = match &*stack.item {
"minecraft:bow" => UseItem::Bow,
"minecraft:spyglass" => UseItem::Spyglass,
_ => UseItem::Other,
};
Some((item, ticks as i32))
});
Some(FovPlayer {
flying: p.flying,
walking_speed: WALKING_SPEED,
// Player.aiStep keeps `speed` as the float movement speed attribute.
movement_speed: f64::from(p.speed),
using,
})
}
/// `Camera.tick`: the eye height and the field-of-view modifier ease
/// halfway toward the player's each tick.
pub(super) fn tick_fov(&mut self) {
let target = self.fov_target();
let [_, current] = self.fov_modifier;
let next = (current + (target - current) * 0.5).clamp(0.1, 1.5);
self.fov_modifier = [current, next];
let first_person = self.camera_type.first_person();
let effect = self.fov_effect_scale();
let target = self
.fov_player()
.map_or(1.0, |p| field_of_view_modifier(&p, first_person, effect));
let eye = self.player.as_ref().map(|p| p.dimensions.eye_height);
self.view.tick(eye, target);
}
/// The player's `deathTime` while dead or dying.
fn death_time(&self) -> Option<i32> {
let p = self.player.as_ref()?;
p.health.dead_or_dying().then(|| {
self.ui
.died_at
.map_or(0, |t| (t.elapsed().as_secs_f32() * 20.0) as i32)
})
}
/// The fluid at the camera, as `Camera.getFluidInCamera` reports it.
fn camera_fluid(&self, camera: [f64; 3]) -> CameraFluid {
match fog_type(&self.world, &self.res.facts, camera, self.forward()) {
FogType::Water => CameraFluid::Water,
FogType::Lava => CameraFluid::Lava,
FogType::PowderSnow => CameraFluid::PowderSnow,
FogType::Atmospheric => CameraFluid::None,
}
}
/// `Camera.calculateFov` with `modifyFovBasedOnDeathOrFluid`.
pub(super) fn fov_degrees(&self, partial: f32, camera: [f64; 3]) -> f32 {
let [old, new] = self.fov_modifier;
let mut fov = self.settings.fov * (old + (new - old) * partial);
if let Some(p) = &self.player
&& p.health.dead_or_dying()
{
let ticks = self
.ui
.died_at
.map_or(0.0, |t| t.elapsed().as_secs_f32() * 20.0);
let duration = (ticks + partial).min(20.0);
fov /= (1.0 - 500.0 / (duration + 500.0)) * 2.0 + 1.0;
self.view.calculate_fov(
partial,
self.settings.fov as i32,
self.fov_effect_scale(),
self.death_time(),
self.camera_fluid(camera),
false,
)
}
/// The pose `GameRenderer.renderLevel` multiplies into the projection:
/// `bobHurt`, then `bobView` with the View Bobbing option.
pub(super) fn bob_matrix(&self, partial: f32) -> glam::Mat4 {
let Some(p) = self.player.as_ref() else {
return glam::Mat4::IDENTITY;
};
let hurt = &p.health.hurt;
let state = BobState {
living: Some(HurtBob {
hurt_time: hurt.hurt_time as f32 - partial,
hurt_duration: hurt.hurt_duration,
hurt_dir: 0.0,
death_time: self.death_time().map(|t| t as f32 + partial),
}),
player: Some((
self.avatar.backwards_interpolated_walk_distance(partial),
self.avatar.interpolated_bob(partial),
)),
};
let tilt = self.settings.file.double("damageTiltStrength");
glam::Mat4::from_cols_array(&bob_matrix(&state, tilt, self.settings.view_bobbing).m)
}
/// `AbstractClientPlayer.tick` and `aiStep` (`ClientAvatarState.tick`,
/// `updateBob`), `rideTick`'s `resetBob` and `LocalPlayer.move`'s walked
/// distance, after the player's movement this tick. `before` is the
/// ground state, velocity and position at the start of the tick.
pub(super) fn tick_avatar(&mut self, before: (bool, [f64; 3], [f64; 3]), riding: bool) {
let Some(p) = self.player.as_ref() else {
return;
};
let (ground, velocity, start) = before;
let swimming = p.pose == horizon_client::pose::Pose::Swimming;
let alive = !p.health.dead_or_dying();
let (dx, dz) = (
(p.position.x - start[0]) as f32,
(p.position.z - start[2]) as f32,
);
self.avatar.tick();
self.avatar
.update_bob(ground && alive && !swimming, velocity);
if riding {
self.avatar.reset_bob();
} else {
self.avatar.add_walked(dx, dz);
}
if matches!(
fog_type(&self.world, &self.res.facts, camera, self.forward()),
FogType::Water | FogType::Lava
) {
fov *= 1.0 + (0.857_142_87 - 1.0) * FOV_EFFECT_SCALE;
}
fov
}
/// `SpyglassItem.use`: start scoping.
pub(super) fn use_spyglass(&mut self) {
@@ -82,26 +146,20 @@ impl Game<'_> {
self.audio
.play("minecraft:item.spyglass.stop_using", at, 1.0, 1.0);
}
/// `Hud.extractCameraOverlays`: the scope, its size easing from 0.5 to
/// 1.125 at half a step per tick of frame time.
/// `Hud.extractCameraOverlays`: the spyglass scope, its size easing
/// towards 1.125 by half the frame's game ticks and reset to 0.5 while
/// not scoping in first person.
pub(super) fn draw_spyglass(&mut self, batch: &mut HudBatch) {
self.scope_scale += 0.5 * self.frame_ticks * (1.125 - self.scope_scale);
if !self.camera_type.first_person() {
return;
}
if !self.scoping() {
self.scope_scale = 0.5;
return;
}
self.scope_scale += (1.125 - self.scope_scale) * (0.5 * self.frame_ticks).min(1.0);
let atlas = &self.res.atlas;
let (w, h) = (batch.width, batch.height);
let src = w.min(h);
let ratio = (w / src).min(h / src) * self.scope_scale;
let (sw, sh) = ((src * ratio).floor(), (src * ratio).floor());
let (left, top) = (((w - sw) / 2.0).floor(), ((h - sh) / 2.0).floor());
let (right, bottom) = (left + sw, top + sh);
batch.sprite_scaled(atlas, "misc/spyglass_scope", left, top, sw, sh, u32::MAX);
let black = rgba(0, 0, 0, 255);
batch.fill(atlas, 0.0, bottom, w, h - bottom, black);
batch.fill(atlas, 0.0, 0.0, w, top, black);
batch.fill(atlas, 0.0, top, left, bottom - top, black);
batch.fill(atlas, right, top, w - right, bottom - top, black);
let draws =
hud_overlays::spyglass(batch.width as i32, batch.height as i32, self.scope_scale);
hud_layout::paint(batch, &self.res.atlas, &draws);
}
}
+25 -88
View File
@@ -2,7 +2,7 @@
//! `ItemFrame.survives`, `interact` (put an item in, then rotate it),
//! `hurtServer` (take the item out, then break the frame) and
//! `ItemFrameRenderer` (the frame model and the item at a 45 degree step).
use super::{Game, Hit, Icon, view_vector};
use super::{Game, Hit, view_vector};
use horizon_assets::{bake::bake_model, model::CuboidModel, resolve::ModelLibrary};
use horizon_client::{entities::ItemFrame, items::ItemStack, mesher::Vertex};
use horizon_core::{
@@ -300,17 +300,10 @@ impl Game<'_> {
}
/// ItemFrameRenderer: the frame model, then the item half size on its face.
pub(super) fn frame_geometry(&mut self, camera: glam::DVec3, out: &mut Vec<Vertex>) {
let icons: Vec<Option<Icon>> = (0..self.item_frames.len())
.map(|i| {
self.item_frames[i]
.item
.clone()
.and_then(|s| self.icon(&s.item))
})
.collect();
let sprites = &self.res.atlas.sprites;
let mut maps: Vec<(i32, glam::Mat4, u32)> = Vec::new();
for (f, icon) in self.item_frames.iter().zip(icons) {
let mut items: Vec<(ItemStack, u32, glam::Mat4, u8)> = Vec::new();
for f in &self.item_frames {
let map = f.item.as_ref().and_then(super::maps::map_id);
let s = f.step();
// Pose: to the block centre, then X/Y rotation by facing.
@@ -391,89 +384,33 @@ impl Game<'_> {
maps.push((id, m.as_mat4(), item_light));
continue;
}
let Some(icon) = icon else { continue };
// GlowItemFrame items render full bright.
let item_light = if f.glow { 240 | 240 << 8 } else { packed };
let Some(stack) = f.item.clone() else {
continue;
};
// `GlowItemFrame` items render full bright (15728880).
let item_light = if f.glow { 0xFF } else { light };
let depth = if f.invisible { 0.5 } else { 0.4375 };
let m = pose
* glam::DMat4::from_translation(glam::DVec3::new(0.0, 0.0, depth))
* glam::DMat4::from_rotation_z((f64::from(f.rotation) * 45.0).to_radians())
* glam::DMat4::from_scale(glam::DVec3::splat(0.5));
let full = |r: &horizon_client::hud::Rect| {
[[r.u0, r.v1], [r.u1, r.v1], [r.u1, r.v0], [r.u0, r.v0]]
};
match icon {
// A flat item faces out of the frame (towards -z here).
Icon::Flat(sprite) => {
if let Some(r) = sprites.get(&sprite) {
let z = -0.03125;
quad(
&m,
[
[0.5, -0.5, z],
[-0.5, -0.5, z],
[-0.5, 0.5, z],
[0.5, 0.5, z],
],
full(r),
item_light,
);
}
}
Icon::Cube {
top, left, right, ..
} => {
let h = 0.25;
let face = |name: &str| sprites.get(name).copied();
if let (Some(t), Some(l), Some(r)) = (face(&top), face(&left), face(&right)) {
let c = |x: f64, y: f64, z: f64| [x * h, y * h, z * h - h];
quad(
&m,
[
c(-1., 1., -1.),
c(1., 1., -1.),
c(1., 1., 1.),
c(-1., 1., 1.),
],
full(&t),
item_light,
);
quad(
&m,
[
c(1., -1., -1.),
c(-1., -1., -1.),
c(-1., 1., -1.),
c(1., 1., -1.),
],
full(&l),
item_light,
);
quad(
&m,
[
c(-1., -1., -1.),
c(-1., -1., 1.),
c(-1., 1., 1.),
c(-1., 1., -1.),
],
full(&r),
item_light,
);
quad(
&m,
[
c(1., -1., 1.),
c(1., -1., -1.),
c(1., 1., -1.),
c(1., 1., 1.),
],
full(&r),
item_light,
);
}
}
}
items.push((stack, f.id, m.as_mat4(), item_light));
}
// `extractRenderState`: FIXED, seeded by the frame entity.
for (stack, id, m, light) in items {
let owner = super::item_draw::owner_at([0.0; 3], 0.0, "minecraft:item_frame");
let state = self.resolve_item(
&stack,
horizon_assets::item_model::DisplayContext::Fixed,
Some(&owner),
id as i32,
);
self.submit_item_world(
&state,
m,
horizon_client::block_entity_render::vertex_light(light),
out,
);
}
for (id, m, light) in maps {
self.map_vertices(id, m, light, out);
+62 -18
View File
@@ -24,6 +24,7 @@ impl Game<'_> {
if self.golem_block(below(1)) == "minecraft:snow_block"
&& self.golem_block(below(2)) == "minecraft:snow_block"
{
// The pattern ("^", "#", "#") is one column.
return Some((
"minecraft:snow_golem",
vec![head, below(1), below(2)],
@@ -38,9 +39,11 @@ impl Game<'_> {
&& self.golem_block(side(0, 2)) == "minecraft:iron_block"
&& [-1, 1].iter().all(|&s| air(side(s, 0)) && air(side(s, 2)))
{
let mut blocks = vec![head];
blocks.extend((-1..=1).map(|s| side(s, 1)));
blocks.push(side(0, 2));
// Every cell of ("~^~", "###", "~#~"), the air ones included.
let blocks = (-1..=1)
.flat_map(|s| (0..3).map(move |row| (s, row)))
.map(|(s, row)| side(s, row))
.collect();
return Some(("minecraft:iron_golem", blocks, side(0, 2)));
}
}
@@ -58,27 +61,68 @@ impl Game<'_> {
let Some((kind, blocks, base)) = self.find_golem(head) else {
return self.try_spawn_copper_golem(head);
};
for p in blocks {
let state = self.world.block(p[0], p[1], p[2]);
self.destroy_particles(p, state);
self.level_set(p, self.world.air)?;
}
// CarvedPumpkinBlock.spawnGolemInWorld
self.clear_pattern_blocks(&blocks)?;
let at = [
f64::from(base[0]) + 0.5,
f64::from(base[1]) + 0.05,
f64::from(base[2]) + 0.5,
];
if let Some(id) = self.spawn_mob(kind, at, false)
&& let Some(m) = self.mobs.iter_mut().find(|m| m.id == id)
{
m.yaw = 0.0;
m.body_yaw = 0.0;
m.head_yaw = 0.0;
m.previous_yaw = 0.0;
m.previous_body_yaw = 0.0;
m.previous_head_yaw = 0.0;
self.create_triggered(kind, at, 0.0);
self.update_pattern_blocks(&blocks)
}
/// Whether placing a golem head at `head` completes a snow or iron golem.
pub(super) fn completes_golem(&self, head: [i32; 3]) -> bool {
self.find_golem(head).is_some()
}
/// `EntityType.create(level, TRIGGERED)` and `snapTo`: a built golem or
/// wither has no spawn finalization and draws nothing from the level.
pub(super) fn create_triggered(&mut self, kind: &str, at: [f64; 3], yaw: f32) -> Option<u32> {
let t = self.res.mob_types.get(kind).cloned()?;
self.next_entity += 1;
let mut mob = horizon_client::mobs::Mob::new(
self.next_entity,
t,
horizon_core::geometry::Vec3::new(at[0], at[1], at[2]),
yaw,
);
mob.head_yaw = yaw;
mob.body_yaw = yaw;
mob.previous_yaw = yaw;
mob.previous_body_yaw = yaw;
mob.previous_head_yaw = yaw;
let id = mob.id;
self.mobs.push(mob);
Some(id)
}
/// `CarvedPumpkinBlock.clearPatternBlocks`: every cell of the match
/// becomes air (flags 2) with its destroy event (2001).
pub(super) fn clear_pattern_blocks(&mut self, cells: &[[i32; 3]]) -> Result<()> {
let air = self.world.air;
for &p in cells {
let state = self.world.block(p[0], p[1], p[2]);
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
self.with_level(|b, level, ticks| b.set_block(level, ticks, pos, air, 2));
self.level_events
.push(horizon_world::level::LevelEvent::LevelEvent {
id: 2001,
pos,
data: state.0 as i32,
});
}
Ok(())
self.process_level_events()
}
/// `CarvedPumpkinBlock.updatePatternBlocks`: neighbours of every cell
/// hear that air arrived.
pub(super) fn update_pattern_blocks(&mut self, cells: &[[i32; 3]]) -> Result<()> {
let air = self.world.air;
for &p in cells {
let pos = horizon_core::position::BlockPos::new(p[0], p[1], p[2]);
self.with_level(|b, level, ticks| {
b.update_neighbors_at(level, ticks, pos, b.block(air))
});
}
self.process_level_events()
}
/// A pumpkin on a copper block: the block turns into the matching copper
/// chest (facing the pumpkin's way) and a copper golem appears where the
@@ -1,226 +0,0 @@
//! `Hud.extractPlayerHealth`: armour, hearts, food. The layout is computed as
//! a list of sprites so it can be tested without drawing.
use std::time::Instant;
/// `java.util.Random`, for the HUD's per-tick jitter.
pub(super) struct JavaRandom(i64);
impl JavaRandom {
pub fn new(seed: i64) -> Self {
Self((seed ^ 0x5_DEEC_E66D) & ((1 << 48) - 1))
}
fn next(&mut self, bits: u32) -> i32 {
self.0 = (self.0.wrapping_mul(0x5_DEEC_E66D).wrapping_add(0xB)) & ((1 << 48) - 1);
(self.0 >> (48 - bits)) as i32
}
pub fn next_int(&mut self, bound: i32) -> i32 {
let mut r = self.next(31);
let m = bound - 1;
if bound & m == 0 {
return ((i64::from(bound) * i64::from(r)) >> 31) as i32;
}
let mut u = r;
loop {
r = u % bound;
if u.wrapping_sub(r).wrapping_add(m) >= 0 {
return r;
}
u = self.next(31);
}
}
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
pub(super) enum HeartKind {
Normal,
Poisoned,
Withered,
Absorbing,
}
impl HeartKind {
fn prefix(self) -> &'static str {
match self {
Self::Normal => "",
Self::Poisoned => "poisoned_",
Self::Withered => "withered_",
Self::Absorbing => "absorbing_",
}
}
}
/// State kept between frames (`Hud.lastHealth`, `displayHealth`, ...).
pub(super) struct HealthHud {
last_health: i32,
display_health: i32,
last_health_time: Instant,
health_blink_time: i64,
}
impl Default for HealthHud {
fn default() -> Self {
Self {
last_health: 0,
display_health: 0,
last_health_time: Instant::now(),
health_blink_time: 0,
}
}
}
pub(super) struct HealthInputs {
pub tick: u64,
pub health: f32,
pub max_health: f32,
pub absorption: f32,
pub armor: i32,
pub food: i32,
pub saturation: f32,
pub kind: HeartKind,
pub regeneration: bool,
pub hunger: bool,
pub hardcore: bool,
/// `damageCooldownTime > 0`.
pub damage_cooldown: bool,
/// A mount with its own hearts replaces the food bar.
pub show_food: bool,
}
fn heart_sprite(kind: HeartKind, hardcore: bool, half: bool, blink: bool) -> String {
let (hc, h) = (
if hardcore { "hardcore_" } else { "" },
if half { "half" } else { "full" },
);
let b = if blink { "_blinking" } else { "" };
format!("gui/sprites/hud/heart/{}{hc}{h}{b}", kind.prefix())
}
impl HealthHud {
/// Sprites (name, x, y) in draw order for a screen of `w` x `h` GUI pixels.
pub fn layout(
&mut self,
i: &HealthInputs,
w: i32,
h: i32,
now: Instant,
) -> Vec<(String, i32, i32)> {
let mut out = Vec::new();
let tick = i64::try_from(i.tick).unwrap_or(i64::MAX);
let current = i.health.ceil() as i32;
let blink = self.health_blink_time > tick && (self.health_blink_time - tick) / 3 % 2 == 1;
if current < self.last_health && i.damage_cooldown {
self.last_health_time = now;
self.health_blink_time = tick + 20;
} else if current > self.last_health && i.damage_cooldown {
self.last_health_time = now;
self.health_blink_time = tick + 10;
}
if now.duration_since(self.last_health_time).as_millis() > 1000 {
self.display_health = current;
self.last_health_time = now;
}
self.last_health = current;
let old = self.display_health;
let mut random = JavaRandom::new(i64::from((i.tick as i32).wrapping_mul(312_871)));
let (x_left, x_right, y_base) = (w / 2 - 91, w / 2 + 91, h - 39);
let max_health = i.max_health.max(old.max(current) as f32);
let absorption = i.absorption.ceil() as i32;
let rows = ((max_health + absorption as f32) / 2.0 / 10.0).ceil() as i32;
let row_height = (10 - (rows - 2)).max(3);
let offset_index = if i.regeneration {
(i.tick as i32).rem_euclid((max_health + 5.0).ceil() as i32)
} else {
-1
};
if i.armor > 0 {
let y = y_base - (rows - 1) * row_height - 10;
for n in 0..10 {
let name = match (n * 2 + 1).cmp(&i.armor) {
std::cmp::Ordering::Less => "armor_full",
std::cmp::Ordering::Equal => "armor_half",
std::cmp::Ordering::Greater => "armor_empty",
};
out.push((format!("gui/sprites/hud/{name}"), x_left + n * 8, y));
}
}
let containers = (max_health / 2.0).ceil() as i32;
let absorb_containers = (absorption as f32 / 2.0).ceil() as i32;
let container = |hardcore: bool, blink: bool| {
format!(
"gui/sprites/hud/heart/container{}{}",
if hardcore { "_hardcore" } else { "" },
if blink { "_blinking" } else { "" }
)
};
for c in (0..containers + absorb_containers).rev() {
let (row, column) = (c / 10, c % 10);
let x = x_left + column * 8;
let mut y = y_base - row * row_height;
if current + absorption <= 4 {
y += random.next_int(2);
}
if c < containers && c == offset_index {
y -= 2;
}
out.push((container(i.hardcore, blink), x, y));
let halves = c * 2;
if c >= containers {
let ah = halves - containers * 2;
if ah < absorption {
let kind = if i.kind == HeartKind::Withered {
i.kind
} else {
HeartKind::Absorbing
};
out.push((
heart_sprite(kind, i.hardcore, ah + 1 == absorption, false),
x,
y,
));
}
}
if blink && halves < old {
out.push((
heart_sprite(i.kind, i.hardcore, halves + 1 == old, true),
x,
y,
));
}
if halves < current {
out.push((
heart_sprite(i.kind, i.hardcore, halves + 1 == current, false),
x,
y,
));
}
}
if i.show_food {
let suffix = if i.hunger { "_hunger" } else { "" };
for n in 0..10 {
let mut y = y_base;
if i.saturation <= 0.0 && (i.tick as i32).rem_euclid(i.food * 3 + 1) == 0 {
y += random.next_int(3) - 1;
}
let x = x_right - n * 8 - 9;
out.push((format!("gui/sprites/hud/food_empty{suffix}"), x, y));
if n * 2 + 1 < i.food {
out.push((format!("gui/sprites/hud/food_full{suffix}"), x, y));
}
if n * 2 + 1 == i.food {
out.push((format!("gui/sprites/hud/food_half{suffix}"), x, y));
}
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn java_random_matches_reference() {
// new Random(42).nextInt(10) sequence: 0, 3, 8, 4, 0.
let mut r = JavaRandom::new(42);
let v: Vec<i32> = (0..5).map(|_| r.next_int(10)).collect();
assert_eq!(v, [0, 3, 8, 4, 0]);
}
}
+373
View File
@@ -0,0 +1,373 @@
//! Items drawn through the resolved item model pipeline
//! (`horizon_client::item_render`): GUI icons (`GuiGraphics.renderItem`),
//! dropped items (`ItemEntityRenderer`) and items posed in the world (hands,
//! frames, armour stands). This binds the resolver's host services
//! (`ItemEnv`) and the special renderers' registries to the game.
use super::{Dim, Game};
use horizon_assets::item_model::{
Components, DisplayContext, GlobalPos, ItemEnv, LevelState, OwnerState, StackView,
};
use horizon_client::{
block_entity_render::{diffuse_lights, vertex_light},
hud::HudBatch,
item_render::{self, ItemRenderState, SpecialHost, WorldSink},
items::ItemStack,
mesher::Vertex,
};
use serde_json::Value;
use winit::keyboard::KeyCode;
/// The game's answers to what the resolver cannot read from the stack.
pub(super) struct Env<'a, 'g> {
game: &'a Game<'g>,
}
impl ItemEnv for Env<'_, '_> {
fn potion_effect_color(&self, contents: &Value) -> Option<i32> {
// `PotionContents.getColorOr`: the effect mix when any effect is visible.
let id = contents["potion"].as_str();
let effects = id
.and_then(horizon_client::brewing::potion)
.is_some_and(|p| !p.effects.is_empty());
effects.then(|| (0xFF00_0000 | horizon_client::brewing::potion_color(id)) as i32)
}
fn grass_color(&self, temperature: f64, downfall: f64) -> i32 {
self.game
.res
.item_renderer
.grass_color(temperature, downfall)
}
fn bundle_fullness(&self, _stack: &StackView, components: &Components) -> f32 {
// `BundleContents.weight`: count / max stack size, summed.
let Some(items) = components
.get("minecraft:bundle_contents")
.and_then(Value::as_array)
else {
return 0.0;
};
let prototypes = &self.game.res.item_renderer.models.prototypes;
let mut total = 0.0f64;
for item in items {
let id = item["id"].as_str().unwrap_or("");
let count = item["count"].as_i64().unwrap_or(1) as f64;
let max = prototypes
.get(id)
.and_then(|c| c.get("minecraft:max_stack_size"))
.and_then(Value::as_i64)
.unwrap_or(1) as f64;
total += count / max;
}
total as f32
}
fn has_shift_down(&self) -> bool {
self.game.keys.contains(&KeyCode::ShiftLeft)
|| self.game.keys.contains(&KeyCode::ShiftRight)
}
fn local_time(&self, pattern: &str, _locale: &str, _time_zone: Option<&str>) -> String {
local_time(pattern)
}
fn random_float(&self) -> f32 {
let nanos = self.game.started.elapsed().subsec_nanos();
(nanos % 1_000_000) as f32 / 1_000_000.0
}
}
/// The days since 1970-01-01 as a civil (year, month, day) date.
fn civil(days: i64) -> (i64, u32, u32) {
let z = days + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(yoe + era * 400 + i64::from(m <= 2), m, d)
}
/// `DateTimeFormatter.ofPattern(pattern).format(now)` for the month, day,
/// year and time letters item models use (UTC: no time zone database).
pub(super) fn local_time(pattern: &str) -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map_or(0, |d| d.as_secs() as i64);
let (y, mo, d) = civil(secs.div_euclid(86_400));
let s = secs.rem_euclid(86_400);
let (h, mi, se) = (s / 3600, s / 60 % 60, s % 60);
let mut out = String::new();
let chars: Vec<char> = pattern.chars().collect();
let mut i = 0;
while i < chars.len() {
let c = chars[i];
let mut n = 1;
while i + n < chars.len() && chars[i + n] == c {
n += 1;
}
let num = |v: i64| format!("{v:0n$}");
match c {
'M' => out += &num(i64::from(mo)),
'd' => out += &num(i64::from(d)),
'y' | 'u' => out += &if n == 2 { num(y % 100) } else { num(y) },
'H' => out += &num(h),
'm' => out += &num(mi),
's' => out += &num(se),
'\'' => {
// Quoted literal text.
let end = chars[i + 1..].iter().position(|&x| x == '\'');
if let Some(e) = end {
out.extend(&chars[i + 1..i + 1 + e]);
i += e + 2;
continue;
}
n = 1;
}
other => out.extend(std::iter::repeat_n(other, n)),
}
i += n;
}
out
}
/// Banner and shield pattern textures from the banner pattern registry.
pub(super) struct Host<'a> {
banners: &'a horizon_client::banner::Banners,
}
impl SpecialHost for Host<'_> {
fn banner_texture(&self, pattern: &str) -> Option<String> {
self.banners
.pattern(pattern)
.map(|_| self.banners.banner_texture(pattern))
}
fn shield_texture(&self, pattern: &str) -> Option<String> {
self.banners
.pattern(pattern)
.map(|_| self.banners.shield_texture(pattern))
}
}
/// `ItemOwner` of an entity holding or showing an item.
pub(super) fn owner_at(position: [f64; 3], yaw: f32, entity_type: &str) -> OwnerState {
OwnerState {
position,
visual_rotation_y_degrees: yaw,
entity_type: entity_type.to_owned(),
..Default::default()
}
}
/// `UseDuration` of a stack: 72000 for drawn items, the consume time for food.
fn use_duration(components: &Components) -> i32 {
if let Some(c) = components.get("minecraft:consumable") {
let seconds = c["consume_seconds"].as_f64().unwrap_or(1.6);
return (seconds * 20.0) as i32;
}
let item_model = components
.get("minecraft:item_model")
.and_then(Value::as_str)
.unwrap_or("");
match item_model {
"minecraft:bow"
| "minecraft:crossbow"
| "minecraft:trident"
| "minecraft:spyglass"
| "minecraft:goat_horn"
| "minecraft:brush" => 72_000,
_ => 0,
}
}
impl Game<'_> {
pub(super) fn item_env(&self) -> Env<'_, '_> {
Env { game: self }
}
pub(super) fn item_host(&self) -> Host<'_> {
Host {
banners: &self.res.stations.banners,
}
}
/// The client level as item models read it.
pub(super) fn item_level(&self) -> LevelState {
let dimension = match self.dim {
Dim::Overworld => "minecraft:overworld",
Dim::Nether => "minecraft:the_nether",
Dim::End => "minecraft:the_end",
};
let sky = self
.dim_environment()
.sample(self.world.day_time, None, None);
LevelState {
dimension: dimension.to_owned(),
game_time: self.world.game_time,
sun_angle: sky.sun_angle,
moon_phase: (self.world.day_time.div_euclid(24_000)).rem_euclid(8) as i32,
respawn: self.spawn.map(|s| GlobalPos {
dimension: "minecraft:overworld".to_owned(),
pos: [s.x.floor() as i32, s.y.floor() as i32, s.z.floor() as i32],
}),
runs_normally: true,
}
}
/// The local player as `ItemOwner`; `stack` is the stack drawn, to tell
/// whether it is the one in use.
pub(super) fn player_owner(&self, stack: Option<&ItemStack>) -> OwnerState {
let position = self
.player
.as_ref()
.map_or([0.0; 3], |p| [p.position.x, p.position.y, p.position.z]);
let mut owner = owner_at(position, self.yaw, "minecraft:player");
owner.main_arm_left = self.settings.main_hand_left;
owner.is_local_player = true;
owner.is_view_entity = true;
if let (Some((slot, ticks)), Some(stack)) = (self.using, stack)
&& self.inventory.get(slot).and_then(Option::as_ref) == Some(stack)
{
let view = item_render::stack_view(stack);
let duration = use_duration(&self.res.item_renderer.models.components(&view));
owner.using_this_item = true;
owner.use_duration = duration;
owner.use_item_remaining_ticks = duration - ticks as i32;
owner.crossbow_charge_duration = 25;
}
owner.is_selected =
stack.is_some() && self.inventory.get(self.selected).and_then(Option::as_ref) == stack;
owner.is_carried = owner.is_selected;
owner
}
/// `ItemModelResolver.updateForTopItem`.
pub(super) fn resolve_item(
&self,
stack: &ItemStack,
display: DisplayContext,
owner: Option<&OwnerState>,
seed: i32,
) -> ItemRenderState {
let level = self.item_level();
self.res.item_renderer.resolve(
&item_render::stack_view(stack),
display,
owner,
Some(&level),
seed,
&self.item_env(),
)
}
/// `GuiGraphics.renderItem(player, level, stack, x, y, 0)`.
pub(super) fn draw_item_model(&self, batch: &mut HudBatch, stack: &ItemStack, x: f32, y: f32) {
let owner = self.player_owner(Some(stack));
let state = self.resolve_item(stack, DisplayContext::Gui, Some(&owner), 0);
item_render::gui::draw(
&state,
&self.res.item_renderer,
&self.res.atlas.sprites,
&self.item_host(),
batch,
x,
y,
self.started.elapsed().as_secs_f32(),
);
}
/// Submit a resolved item under `pose` (camera-relative) at `light`
/// (packed lightmap coordinates).
pub(super) fn submit_item_world(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
) {
let mut sink = WorldSink {
out,
light,
lights: Some(diffuse_lights(self.dim == Dim::Nether)),
};
state.submit(
&self.res.item_renderer,
&self.res.atlas.sprites,
&self.item_host(),
pose,
&mut sink,
);
}
/// [`Self::submit_item_world`] without the level's diffuse shading.
pub(super) fn submit_item_unlit(
&self,
state: &ItemRenderState,
pose: glam::Mat4,
light: u32,
out: &mut Vec<Vertex>,
) {
let mut sink = WorldSink {
out,
light,
lights: None,
};
state.submit(
&self.res.item_renderer,
&self.res.atlas.sprites,
&self.item_host(),
pose,
&mut sink,
);
}
/// `ItemEntityRenderer.submit` for every dropped item.
pub(super) fn dropped_item_geometry(
&self,
camera: glam::DVec3,
alpha: f64,
out: &mut Vec<Vertex>,
) {
for item in &self.dropped {
let (p, q) = (item.previous, item.position);
let pos = glam::DVec3::new(
p.x + (q.x - p.x) * alpha,
p.y + (q.y - p.y) * alpha,
p.z + (q.z - p.z) * alpha,
) - camera;
if pos.length_squared() > 64.0 * 64.0 {
continue;
}
// `extractItemGroupRenderState`: GROUND, seeded by the entity id.
let owner = owner_at([q.x, q.y, q.z], 0.0, "minecraft:item");
let state = self.resolve_item(
&item.stack,
DisplayContext::Ground,
Some(&owner),
item.id as i32,
);
if state.is_empty() {
continue;
}
let age = item.age as f32 + alpha as f32;
let (lo, _) = state.bounding_box();
let min_offset_y = -lo.y + 0.0625;
let bob = (age / 10.0 + item.bob_offset).sin() * 0.1 + 0.1;
let spin = item_render::item_spin(age, item.bob_offset);
let pose = glam::Mat4::from_translation(pos.as_vec3())
* glam::Mat4::from_translation(glam::Vec3::new(0.0, bob + min_offset_y, 0.0))
* glam::Mat4::from_rotation_y(spin);
let packed = self.world.light(
q.x.floor() as i32,
(q.y + 0.1).floor() as i32,
q.z.floor() as i32,
);
let seed = i64::from(self.res.item_renderer.item_id(&item.stack.item))
+ i64::from(item.stack.damage);
for copy in item_render::cluster_poses(
&state,
pose,
item_render::rendered_amount(item.stack.count),
seed,
) {
self.submit_item_world(&state, copy, vertex_light(packed), out);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn civil_dates_follow_the_gregorian_calendar() {
assert_eq!(civil(0), (1970, 1, 1));
assert_eq!(civil(19_716), (2023, 12, 25));
assert_eq!(civil(11_016), (2000, 2, 29));
}
}
+8 -22
View File
@@ -12,9 +12,6 @@ use horizon_client::{
use horizon_core::{position::BlockPos, random::RandomSource};
use horizon_world::level::flags as level_flags;
/// Linear attenuation range of a song (`SimpleSoundInstance.forJukeboxSong`: volume 4).
const RANGE: f64 = 64.0;
impl Game<'_> {
fn jukebox(&self, pos: [i32; 3]) -> Option<&Jukebox> {
match self.world.block_entities.get(&(pos[0], pos[1], pos[2])) {
@@ -101,19 +98,18 @@ impl Game<'_> {
let Some(s) = self.res.items.songs.get(song).cloned() else {
return;
};
if let Some(id) = self.audio.play_tracked(&s.sound, 0.0, false) {
self.jukebox_sounds.insert((pos[0], pos[1], pos[2]), id);
}
// LevelRenderer.playJukeboxSong: SimpleSoundInstance.forJukeboxSong.
let id = self.audio.play_jukebox_song(&s.sound, pos);
self.jukebox_sounds.insert((pos[0], pos[1], pos[2]), id);
let description =
horizon_client::component::to_text(&s.description, &mut |k| Text::literal(k));
self.titles
.set_overlay(Text::translate("record.nowPlaying", &[description]));
self.titles.overlay_animated = true;
.set_overlay(Text::translate("record.nowPlaying", &[description]), true);
self.update_song_volumes();
}
fn stop_song(&mut self, pos: [i32; 3]) {
if let Some(id) = self.jukebox_sounds.remove(&(pos[0], pos[1], pos[2])) {
self.audio.stop_tracked(id);
self.audio.stop_and_release(id);
}
}
/// `popOutTheItem`: the disc above the jukebox, and the song stops.
@@ -202,30 +198,20 @@ impl Game<'_> {
}
self.update_song_volumes();
}
/// Linear distance attenuation of each song at the listener.
/// Let go of songs that ended or whose jukebox stopped.
fn update_song_volumes(&mut self) {
let Some(eye) = self.eye_position() else {
return;
};
let sounds: Vec<_> = self
.jukebox_sounds
.iter()
.map(|(k, id)| (*k, *id))
.collect();
for (k, id) in sounds {
if !self.audio.tracked_active(id)
if !self.audio.engine.is_active(id)
|| !matches!(self.world.block_entities.get(&k), Some(BlockEntity::Jukebox(j)) if j.playing)
{
self.audio.stop_tracked(id);
self.audio.stop_and_release(id);
self.jukebox_sounds.remove(&k);
continue;
}
let d = ((f64::from(k.0) + 0.5 - eye[0]).powi(2)
+ (f64::from(k.1) + 0.5 - eye[1]).powi(2)
+ (f64::from(k.2) + 0.5 - eye[2]).powi(2))
.sqrt();
self.audio
.set_tracked_volume(id, (1.0 - d / RANGE).clamp(0.0, 1.0) as f32);
}
}
}
+10 -2
View File
@@ -186,7 +186,11 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
if e.chested {
meta.push((19, serializer::BOOLEAN, Value::Bool(true)));
}
meta.push((20, serializer::INT, Value::Int(i32::from(e.variant2.max(1)))));
meta.push((
20,
serializer::INT,
Value::Int(i32::from(e.variant2.max(1))),
));
if e.variant != 0 {
meta.push((21, serializer::INT, Value::Int(i32::from(e.variant))));
}
@@ -205,7 +209,11 @@ fn mob_meta(m: &Mob, hub: &Hub) -> Vec<(u8, i32, Value)> {
) {
let index = if kind == "minecraft:villager" { 19 } else { 20 };
let level = i32::from(e.variant3.max(1));
meta.push((index, serializer::VILLAGER_DATA, Value::Ids(vec![t, p, level])));
meta.push((
index,
serializer::VILLAGER_DATA,
Value::Ids(vec![t, p, level]),
));
}
}
// AbstractCubeMob.ID_SIZE.
+3 -7
View File
@@ -8,7 +8,6 @@ use horizon_client::{
mesher::Vertex,
mobs::leash::{self, Holder, Pending},
};
use horizon_core::random::RandomSource;
use horizon_world::nbt::Tag;
/// `Vec3.yRot(angle)`.
@@ -66,13 +65,10 @@ impl Game<'_> {
})
.collect()
}
/// `Entity.spawnAtLocation(level, Items.LEAD)`: the item entity draws its
/// motion from its own random, not the level's.
fn drop_lead_item(&mut self, at: [f64; 3]) {
let r = &mut self.world.random;
let v = [
f64::from(r.next_float()) * 0.2 - 0.1,
0.2,
f64::from(r.next_float()) * 0.2 - 0.1,
];
let v = super::dispenser::new_item_motion();
self.spawn_item(ItemStack::new("minecraft:lead", 1), at, v, 10);
}
/// `Leashable.dropLeash` / `removeLeash`: the lead falls as an item.
+6 -1
View File
@@ -605,7 +605,12 @@ impl Game<'_> {
let Some(slot) = slot else {
self.dispenser_event(1001, key, 0);
if !dropper {
self.post_block_game_event("minecraft:block_activate", [pos.x, pos.y, pos.z], None, Some(state));
self.post_block_game_event(
"minecraft:block_activate",
[pos.x, pos.y, pos.z],
None,
Some(state),
);
}
return Ok(());
};
@@ -72,7 +72,12 @@ impl Game<'_> {
let triggered = self.containers().prop(state, "triggered") == Some("true");
let mut finished = false;
if let Some(BlockEntity::Crafter(c)) = self.world.block_entities.get_mut(&pos) {
c.triggered = triggered;
// CrafterBlock.neighborChanged sets the flag with the state; a
// loaded crafter keeps its saved flag until then.
if c.state_triggered.is_some_and(|t| t != triggered) {
c.triggered = triggered;
}
c.state_triggered = Some(triggered);
let remaining = c.crafting_ticks - 1;
if remaining >= 0 {
c.crafting_ticks = remaining;
+34 -4
View File
@@ -28,6 +28,9 @@ pub(super) enum CartKind {
Hopper,
Furnace,
Tnt,
/// `MinecartCommandBlock`; its command block holds no command, so an
/// activator rail runs nothing.
CommandBlock,
}
impl CartKind {
pub fn from_id(id: &str) -> Option<Self> {
@@ -37,6 +40,7 @@ impl CartKind {
"hopper_minecart" => Self::Hopper,
"furnace_minecart" => Self::Furnace,
"tnt_minecart" => Self::Tnt,
"command_block_minecart" => Self::CommandBlock,
_ => return None,
})
}
@@ -47,6 +51,7 @@ impl CartKind {
Self::Hopper => "minecraft:hopper_minecart",
Self::Furnace => "minecraft:furnace_minecart",
Self::Tnt => "minecraft:tnt_minecart",
Self::CommandBlock => "minecraft:command_block_minecart",
}
}
fn slots(self) -> usize {
@@ -192,6 +197,7 @@ impl CartModels {
CartKind::Hopper,
CartKind::Furnace,
CartKind::Tnt,
CartKind::CommandBlock,
];
let models = kinds
.into_iter()
@@ -981,6 +987,8 @@ impl Game<'_> {
self.prime_cart(&mut c);
self.carts[i] = c;
}
// BaseCommandBlock.usedBy: only an operator in creative opens it.
CartKind::CommandBlock => return false,
}
self.swing_arm();
true
@@ -990,9 +998,10 @@ impl Game<'_> {
let Some(key) = self.container.filter(|k| k.1 == CART_KEY_Y) else {
return;
};
if let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key)
&& let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2)
{
let Some(BlockEntity::Chest(items)) = self.world.block_entities.remove(&key) else {
return;
};
if let Some(c) = self.carts.iter_mut().find(|c| c.id as i32 == key.2) {
c.items = items;
let at = [c.position.x, c.position.y, c.position.z];
if c.kind == CartKind::Chest {
@@ -1000,15 +1009,24 @@ impl Game<'_> {
self.audio
.play("minecraft:block.chest.close", Some(at), 0.5, pitch);
}
} else if let Some(b) = self.boats.iter_mut().find(|b| b.id as i32 == key.2) {
b.items = items;
}
}
/// The open cart's kind when a cart container is shown.
pub(super) fn open_cart_kind(&self) -> Option<CartKind> {
let key = self.container.filter(|k| k.1 == CART_KEY_Y)?;
// A chest boat's chest shows like a chest minecart's.
self.carts
.iter()
.find(|c| c.id as i32 == key.2)
.map(|c| c.kind)
.or_else(|| {
self.boats
.iter()
.any(|b| b.chest && b.id as i32 == key.2)
.then_some(CartKind::Chest)
})
}
/// `VehicleEntity.hurtServer` from the player's attack.
pub(super) fn attack_cart(&mut self, damage: f32) -> bool {
@@ -1121,6 +1139,7 @@ impl Game<'_> {
],
),
CartKind::Tnt => registry.default_state("minecraft:tnt"),
CartKind::CommandBlock => registry.default_state("minecraft:command_block"),
}
}
/// `AbstractMinecartRenderer` (old render): on the rail between the
@@ -1231,15 +1250,19 @@ impl Game<'_> {
let lid = |_: [i32; 3]| 0.0;
let scene = horizon_client::block_entity_render::Scene {
camera: glam::DVec3::ZERO,
orientation: glam::Quat::IDENTITY,
anim: &|_| horizon_client::block_entity_render::BlockAnim::default(),
texts: None,
partial: 0.0,
game_time: self.world.game_time,
max_y: self.dim.max_y(),
world: &self.world,
sprites: &self.res.atlas.sprites,
nether: self.dim == super::Dim::Nether,
lid: &lid,
sign: &|_| None,
banner: &|_| Vec::new(),
glyph: &|c| self.res.atlas.glyph(c),
glyph: &|c| self.res.atlas.glyph_cell(c),
};
self.res
.block_entity_render
@@ -1280,6 +1303,13 @@ impl Minecart {
put("Fuel", Tag::Short(self.fuel as i16));
}
CartKind::Tnt => put("fuse", Tag::Int(self.fuse)),
// BaseCommandBlock.save of an empty command block.
CartKind::CommandBlock => {
put("Command", Tag::String(JavaString::from("")));
put("SuccessCount", Tag::Int(0));
put("TrackOutput", Tag::Byte(1));
put("UpdateLastExecution", Tag::Byte(1));
}
CartKind::Rideable => {}
}
}
+70 -51
View File
@@ -1,8 +1,9 @@
//! Blocks mobs carry: block items in a hand (`ItemInHandLayer`) and the
//! block an enderman holds (`CarriedBlockLayer`), drawn as shaded cubes like
//! dropped block items.
use super::{Game, Icon};
use super::Game;
use horizon_client::{
items::ItemStack,
mesher::Vertex,
mobs::{Mob, Species, render},
};
@@ -176,22 +177,6 @@ impl Game<'_> {
render::draw(flag, m, &flag_poses, r, color, light, 0, out);
}
}
/// Resolve the icons of the items mobs hold, so drawing can look them up.
pub(super) fn cache_mob_icons(&mut self) {
let items: Vec<String> = self
.mobs
.iter()
.filter(|m| m.extra.main_hand.is_some() || m.extra.off_hand.is_some())
.flat_map(|m| [m.extra.main_hand.clone(), m.extra.off_hand.clone()])
.flatten()
.map(|i| i.to_string())
.collect();
for item in items {
if !self.res.icons.contains_key(&item) {
let _ = self.icon(&item);
}
}
}
fn push_cube(
&self,
t: glam::Mat4,
@@ -232,40 +217,7 @@ impl Game<'_> {
) {
let overlay = u32::from(mob.hurt_time > 0);
self.captain_banner_geometry(mob, camera, alpha, light, out);
for (item, left) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
let Some(item) = item else { continue };
let Some(Icon::Cube {
top,
left: z,
right: x,
tint,
}) = self.res.icons.get(&**item).cloned().flatten()
else {
continue;
};
let Some(t) =
render::held_block_transform(mob, alpha as f32, camera, &self.res.mob_data, left)
else {
continue;
};
let slot = if left {
horizon_client::mobs::gear::OFF
} else {
horizon_client::mobs::gear::MAIN
};
let glint = horizon_client::mobs::gear::enchanted(mob, slot);
let overlay = if glint { 7 } else { overlay };
self.push_cube(
t,
&Cube {
sprites: [&top, &z, &x],
tint,
},
light,
overlay,
out,
);
}
self.held_item_geometry(mob, camera, alpha, light, true, out);
if mob.species() == Species::Enderman
&& let Some(state) = mob.extra.carried
&& let Some((sprite, tint)) = self.particle_sprite(state)
@@ -292,4 +244,71 @@ impl Game<'_> {
);
}
}
/// `ItemInHandLayer`: both hands through the item model pipeline; `lit`
/// applies the level's diffuse lighting (off for GUI previews, which
/// light the projected quads themselves).
pub(super) fn held_item_geometry(
&self,
mob: &Mob,
camera: glam::DVec3,
alpha: f64,
light: u32,
lit: bool,
out: &mut Vec<Vertex>,
) {
let overlay = u32::from(mob.hurt_time > 0);
for (item, left) in [(&mob.extra.main_hand, false), (&mob.extra.off_hand, true)] {
let Some(item) = item else { continue };
let Some(t) =
render::held_item_transform(mob, alpha as f32, camera, &self.res.mob_data, left)
else {
continue;
};
let slot = if left {
horizon_client::mobs::gear::OFF
} else {
horizon_client::mobs::gear::MAIN
};
let mut stack = ItemStack::new(item, 1);
if horizon_client::mobs::gear::enchanted(mob, slot) {
// The gear's enchantments are not tracked, only that it has some.
stack.enchant("minecraft:unbreaking", 1);
}
if &**item == "minecraft:crossbow" && mob.extra.flag2 {
stack.data_mut().charged = vec![ItemStack::new("minecraft:arrow", 1)];
}
let mut owner = super::item_draw::owner_at(
[mob.position.x, mob.position.y, mob.position.z],
mob.body_yaw,
&mob.kind.id,
);
if !left && mob.extra.using_item > 0 {
owner.using_this_item = true;
owner.use_duration = 72_000;
owner.use_item_remaining_ticks = 72_000 - mob.extra.using_item;
owner.crossbow_charge_duration = 25;
}
let context = if left {
horizon_assets::item_model::DisplayContext::ThirdPersonLeftHand
} else {
horizon_assets::item_model::DisplayContext::ThirdPersonRightHand
};
// `updateForLiving`: seeded by the entity id plus the context ordinal.
let seed = mob.id as i32 + if left { 1 } else { 2 };
let state = self.resolve_item(&stack, context, Some(&owner), seed);
let start = out.len();
if lit {
self.submit_item_world(&state, t, light, out);
} else {
self.submit_item_unlit(&state, t, light, out);
}
if overlay != 0 {
for v in &mut out[start..] {
if v.layer == 0 {
v.layer = overlay;
}
}
}
}
}
}
+14 -43
View File
@@ -453,54 +453,25 @@ impl Game<'_> {
_ => {}
}
}
/// `Gui.renderVehicleHealth` and the jump bar (`JumpableVehicleBarRenderer`).
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: f32, h: f32) -> bool {
/// `Hud.extractVehicleHealth` and the jump bar that replaces the
/// experience bar on a saddled jumper. Returns the mount's heart count
/// (`getVehicleMaxHearts`) and whether the jump bar shows.
pub(super) fn draw_mount_hud(&self, batch: &mut HudBatch, w: i32, h: i32) -> (i32, bool) {
let Some(i) = self.mount_index() else {
return false;
return (0, false);
};
let m = &self.mobs[i];
let atlas = &self.res.atlas;
// Hearts in rows of ten, right-aligned where the food bar sits.
let max = (m.attrs.max_health + 0.5) as i32 / 2;
let health = m.health.ceil() as i32;
let right = w / 2.0 + 91.0;
let mut y = h - 39.0;
let mut drawn = 0;
while drawn < max {
let row = (max - drawn).min(10);
for k in 0..row {
let x = right - k as f32 * 8.0 - 9.0;
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_container", x, y);
let v = health - (drawn + k) * 2;
if v >= 2 {
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_full", x, y);
} else if v == 1 {
batch.sprite(atlas, "gui/sprites/hud/heart/vehicle_half", x, y);
}
}
drawn += row;
y -= 10.0;
}
// The jump bar replaces the experience bar on a saddled jumper.
let hearts = horizon_client::hud_layout::vehicle_max_hearts(m.attrs.max_health);
let draws = horizon_client::hud_layout::vehicle_health(w, h, hearts, m.health);
horizon_client::hud_layout::paint(batch, atlas, &draws);
if m.extra.saddled {
let x = w / 2.0 - 91.0;
let y = h - 29.0;
batch.sprite(atlas, "gui/sprites/hud/jump_bar_background", x, y);
if self.mount.jump_ticks < 0 {
batch.sprite(atlas, "gui/sprites/hud/jump_bar_cooldown", x, y);
} else {
let filled = (self.mount.jump_scale * 183.0).floor();
if filled > 0.0 {
batch.sprite_part(
atlas,
"gui/sprites/hud/jump_bar_progress",
x,
y,
filled / 182.0,
);
}
}
// JumpableVehicleBar: the cooldown shows while the jump recharges.
let cooldown = i32::from(self.mount.jump_ticks < 0);
let draws =
horizon_client::hud_overlays::jump_bar(w, h, cooldown, self.mount.jump_scale);
horizon_client::hud_layout::paint(batch, atlas, &draws);
}
true
(hearts, m.extra.saddled)
}
}
+105 -42
View File
@@ -1,6 +1,11 @@
//! `Minecraft.getSituationalMusic` and `getMusicVolume` for the music manager.
//! `Minecraft.getSituationalMusic` and `getMusicVolume` for the music manager,
//! the local player's ambient sound handlers and the sound engine tick.
use super::Game;
use horizon_client::{blocks::Fluid, music::Music};
use horizon_client::{
blocks::Fluid,
music::{BackgroundMusic, Music, Situation},
sound_engine::TickContext,
};
impl Game<'_> {
/// `Minecraft.tick` -> `MusicManager.tick`, once per client tick.
@@ -11,41 +16,86 @@ impl Game<'_> {
self.music
.tick(music.as_ref(), volume, loading, &mut self.audio);
}
fn situational_music(&self) -> (Option<Music>, f32) {
// Title screen: no player, Musics.MENU at full volume.
if self.title.is_some() {
return (Some(Music::menu()), 1.0);
/// `Minecraft.tick` -> `SoundManager.tick(pause)`, after the music, with
/// `pauseAllExcept(MUSIC, UI)` when the game pauses and `resume` when it
/// carries on.
pub(super) fn tick_sound_engine(&mut self) {
if self.pause != self.sound_paused {
if self.pause {
self.audio.pause();
} else {
self.audio.resume();
}
self.sound_paused = self.pause;
}
let Some(eye) = self.eye_position() else {
return (Some(Music::menu()), 1.0);
let ctx = TickContext {
player_underwater: self.ambience.was_underwater(),
player_removed: self.player.is_none(),
};
self.audio.tick(self.pause, &ctx);
}
/// `getSituationalMusic` and `getMusicVolume`.
fn situational_music(&self) -> (Option<Music>, f32) {
let empty = BackgroundMusic::default();
// The title screen has no player (and no level for the camera probe).
let eye = if self.title.is_some() {
None
} else {
self.eye_position()
};
let (background, probe_volume) = match eye {
Some(eye) => {
let (dimension, biome) = (self.dim.id(), self.biome_at(eye));
let tables = &self.res.music;
(
tables.background(dimension, biome),
tables.volume(dimension, biome),
)
}
None => (empty, 1.0),
};
let situation = Situation {
screen_music: None,
in_world: eye.is_some(),
in_end: self.dim == super::Dim::End,
boss_music: self.boss_music(),
background: &background,
probe_volume,
creative: self.creative,
underwater: self.ambience.was_underwater(),
};
(situation.music(), situation.volume())
}
/// `BiomeManager.getNoiseBiomeAtPosition`: the quart cell holding a position.
fn biome_at(&self, p: [f64; 3]) -> &str {
let (x, y, z) = (
eye[0].floor() as i32,
eye[1].floor() as i32,
eye[2].floor() as i32,
p[0].floor() as i32,
p[1].floor() as i32,
p[2].floor() as i32,
);
let biome = self
.world
self.world
.chunks
.get(&(x >> 4, z >> 4))
.map_or("minecraft:plains", |c| {
self.res.biomes.list[c.biome(x, y, z) as usize]
.name
.as_str()
});
// Minecraft.getSituationalMusic: the boss bar's music in the End.
if self.boss_music() {
return (Some(Music::end_boss()), 1.0);
})
}
/// `isEyeInFluid(WATER)`: the eye inside the water of its block column.
fn eye_in_water(&self, x: f64, eye_y: f64, z: f64) -> bool {
let (bx, by, bz) = (x.floor() as i32, eye_y.floor() as i32, z.floor() as i32);
let f = self.res.facts.get(self.world.block(bx, by, bz));
if f.fluid != Fluid::Water {
return false;
}
let dimension = self.dim.id();
let tables = &self.res.music;
// Player.isUnderWater: eyes in water.
let underwater = self.res.facts.get(self.world.block(x, y, z)).fluid == Fluid::Water;
let music = tables
.background(dimension, biome)
.select(self.creative, underwater)
.cloned();
(music, tables.volume(dimension, biome))
let above = self.res.facts.get(self.world.block(bx, by + 1, bz));
let height = if above.fluid == Fluid::Water {
1.0
} else {
f64::from(f.fluid_level) / 9.0
};
eye_y <= f64::from(by) + height
}
}
@@ -57,33 +107,46 @@ impl Game<'_> {
};
let feet = [p.position.x, p.position.y, p.position.z];
let eye_y = feet[1] + f64::from(p.dimensions.eye_height);
let (x, z) = (feet[0].floor() as i32, feet[2].floor() as i32);
let sounds = self.res.ambient.get(self.dim.id(), self.biome_at(feet));
let underwater = self.eye_in_water(feet[0], eye_y, feet[2]);
let world = &self.world;
let biome = world
.chunks
.get(&(x >> 4, z >> 4))
.map_or("minecraft:plains", |c| {
self.res.biomes.list[c.biome(x, feet[1].floor() as i32, z) as usize]
.name
.as_str()
});
let sounds = self.res.ambient.get(self.dim.id(), biome);
let underwater = self
.res
.facts
.get(world.block(x, eye_y.floor() as i32, z))
.fluid
== Fluid::Water;
let light = |b: [i32; 3]| {
let l = world.light(b[0], b[1], b[2]);
(l >> 4, l & 15)
};
let (half, height) = (
f64::from(p.dimensions.width / 2.0),
f64::from(p.dimensions.height),
);
let registry = &self.res.assets.registry;
let facts = &self.res.facts;
let bubble_column = horizon_client::ambience::bubble_column_in(
[
feet[0] - half,
feet[1],
feet[2] - half,
feet[0] + half,
feet[1] + height,
feet[2] + half,
],
|b| {
let state = world.block(b[0], b[1], b[2]);
(&*facts.get(state).name == "minecraft:bubble_column").then(|| {
registry
.state(state)
.and_then(|s| s.properties.get("drag"))
.is_some_and(|d| d == "true")
})
},
);
let ctx = horizon_client::ambience::AmbientContext {
sounds: &sounds,
position: feet,
eye_y,
underwater,
spectator: self.spectator(),
light: &light,
bubble_column,
};
self.ambience.tick(&ctx, &mut self.audio);
}
@@ -0,0 +1,355 @@
//! The game's side of `NaturalSpawner` (`mobs::spawn_selection`): the level
//! it reads, the spawning chunks and caps of `ServerChunkCache.tickChunks`,
//! and the mobs it adds (finalized with the game's `finalizeSpawn`).
use super::{Dim, Game};
use horizon_client::{
biome::{BiomeInfo, SpawnEntry},
blocks::BlockFacts,
collisions::{SceneCollisions, no_collision_empty},
mobs::{
Mob, MobWorld, Species,
data::{self, MobData},
spawn_rules::{Dimension, SpawnLevel},
spawn_selection::{
self, Category, Counted, Heightmap, NaturalLevel, SpawnCost, SpawnPlayer, SpawnRequest,
SpawnState, Spawned, StructureAt, StructureSpawns,
},
},
};
use horizon_core::{
geometry::{Aabb, Vec3},
random::{LegacyRandom, RandomSource, WorldgenRandom},
};
use horizon_world::registry::BlockStateId;
use std::collections::HashMap;
/// The game level as natural spawning reads it.
struct GameLevel<'g, 'a> {
game: &'g Game<'a>,
w: MobWorld<'g>,
/// Boxes of the mobs this pass created, not yet in the game's list.
pending: std::cell::RefCell<Vec<Aabb>>,
}
impl GameLevel<'_, '_> {
fn biome(&self, x: i32, y: i32, z: i32) -> Option<&BiomeInfo> {
let chunk = self.game.world.chunks.get(&(x >> 4, z >> 4))?;
Some(self.game.res.biomes.get(chunk.biome(x, y, z)))
}
}
impl SpawnLevel for GameLevel<'_, '_> {
fn state(&self, x: i32, y: i32, z: i32) -> BlockStateId {
self.w.state(x, y, z)
}
fn facts(&self) -> &BlockFacts {
self.w.facts
}
fn mob_data(&self) -> &MobData {
self.w.data
}
fn block_tag(&self, tag: &str, block: &str) -> bool {
self.w.block_tag(tag, block)
}
fn biome_tag(&self, tag: &str, x: i32, y: i32, z: i32) -> bool {
self.w.biome_tag(tag, x, y, z)
}
fn sky_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.w.sky_light(x, y, z)
}
fn block_light(&self, x: i32, y: i32, z: i32) -> i32 {
self.w.block_light(x, y, z)
}
fn sky_darken(&self) -> i32 {
SpawnLevel::sky_darken(&self.w)
}
fn thundering(&self) -> bool {
self.w.thundering
}
fn sea_level(&self) -> i32 {
self.w.sea_level()
}
fn world_surface(&self, x: i32, z: i32) -> i32 {
self.w.world_surface(x, z)
}
fn peaceful(&self) -> bool {
self.w.peaceful()
}
fn dimension(&self) -> Dimension {
self.w.dimension()
}
fn surface_slime_chance(&self, x: i32, y: i32, z: i32) -> f32 {
self.w.surface_slime_chance(x, y, z)
}
fn world_seed(&self) -> i64 {
self.w.world_seed
}
fn player_near(&self, x: f64, y: f64, z: f64, distance: f64) -> bool {
self.w.player_near(x, y, z, distance)
}
}
impl NaturalLevel for GameLevel<'_, '_> {
fn min_y(&self) -> i32 {
horizon_client::chunk::MIN_Y
}
fn max_y(&self) -> i32 {
horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT - 1
}
fn ambient_light(&self) -> f32 {
match self.game.dim {
Dim::Nether => 0.1,
Dim::End => 0.25,
_ => 0.0,
}
}
fn has_ceiling(&self) -> bool {
self.game.dim == Dim::Nether
}
fn mob_type(&self, entity: &str) -> Option<(Species, f32, f32, Category)> {
let t = self.game.res.mob_types.get(entity)?;
Some((
t.species,
t.width as f32,
t.height as f32,
Category::from_name(&t.category),
))
}
fn biome_spawns(&self, x: i32, y: i32, z: i32) -> Option<&HashMap<String, Vec<SpawnEntry>>> {
self.biome(x, y, z).map(|b| &b.spawns)
}
fn spawn_cost(&self, entity: &str, x: i32, y: i32, z: i32) -> Option<SpawnCost> {
self.biome(x, y, z)?.spawn_costs.get(entity).copied()
}
fn creature_probability(&self, x: i32, y: i32, z: i32) -> f32 {
self.biome(x, y, z).map_or(0.1, |b| b.creature_probability)
}
fn structures_at(&self, x: i32, y: i32, z: i32) -> Vec<StructureAt> {
self.game
.structures_reaching([x, y, z])
.into_iter()
.map(|(id, in_piece)| StructureAt {
id,
in_piece,
in_bounds: true,
})
.collect()
}
fn structure_spawns(&self) -> &StructureSpawns {
StructureSpawns::shared(&self.game.options.resources)
}
fn nearest_player_distance_sqr(&self, x: f64, y: f64, z: f64) -> Option<f64> {
let p = self.game.player.as_ref()?;
if self.game.spectator() {
return None;
}
let d = [p.position.x - x, p.position.y - y, p.position.z - z];
Some(d[0] * d[0] + d[1] * d[1] + d[2] * d[2])
}
fn respawn(&self) -> Option<[i32; 3]> {
// The world spawn point lives in the overworld.
if self.game.dim != Dim::Overworld {
return None;
}
self.game.level.spawn.map(|s| {
[
s[0].floor() as i32,
s[1].floor() as i32,
s[2].floor() as i32,
]
})
}
fn can_spawn_in_chunk(&self, cx: i32, cz: i32) -> bool {
self.game.world.chunks.contains_key(&(cx, cz))
}
fn no_collision(&self, bounds: Aabb) -> bool {
let mut scene = SceneCollisions {
world: &self.game.world,
binding: self.w.collision,
};
no_collision_empty(&mut scene, bounds)
}
fn unobstructed(&self, bounds: Aabb) -> bool {
let player = self
.game
.player_view()
.is_some_and(|p| p.alive && !p.spectator && p.bounds.intersects(bounds));
!player
&& !self.pending.borrow().iter().any(|b| b.intersects(bounds))
&& !self
.game
.mobs
.iter()
.any(|m| m.alive() && !m.removed && m.bounds().intersects(bounds))
}
fn spawn_mobs(&self) -> bool {
self.game.ui.rules.bool("spawn_mobs")
}
fn heightmap(&self, kind: Heightmap, x: i32, z: i32) -> i32 {
let top = horizon_client::chunk::MIN_Y + horizon_client::chunk::HEIGHT;
(horizon_client::chunk::MIN_Y..top)
.rev()
.find(|&y| spawn_selection::heightmap_blocks(self, kind, x, y, z))
.map_or(horizon_client::chunk::MIN_Y, |y| y + 1)
}
fn pathfindable_land(&self, x: i32, y: i32, z: i32) -> bool {
self.w.data.state(self.state(x, y, z)).flags & data::LAND != 0
}
}
impl<'a> Game<'a> {
fn natural_level(&self) -> GameLevel<'_, 'a> {
GameLevel {
game: self,
w: self.mob_world_base(&[]),
pending: Default::default(),
}
}
/// Creates a mob the spawner accepted, finalized the game's way; the
/// caller adds it with [`Game::add_spawned`].
fn finalize_spawned(&self, r: &SpawnRequest, pending: &mut Vec<Mob>) -> Option<Spawned> {
let kind = self.res.mob_types.get(r.entity)?.clone();
let [x, y, z] = r.position;
let mut mob = Mob::new(0, kind, Vec3::new(x, y, z), r.yaw);
let facts = self.biome_facts(x.floor() as i32, y.floor() as i32, z.floor() as i32);
let difficulty = self.local_difficulty(r.position);
horizon_client::mobs::spawning::finalize(&mut mob, &facts, &difficulty);
horizon_client::mobs::raider::finalize(
&mut mob,
horizon_client::mobs::raider::Reason::Natural,
);
let bounds = mob.bounds();
pending.push(mob);
Some(Spawned {
bounds,
school: true,
})
}
/// Adds the mobs a spawn pass created: ids, enchanted gear and jockeys.
fn add_spawned(&mut self, pending: Vec<Mob>, persistent: bool) {
for mut mob in pending {
self.next_entity += 1;
mob.id = self.next_entity;
mob.persistent = persistent;
let id = mob.id;
self.mobs.push(mob);
self.enchant_spawn_equipment(id);
if !persistent {
self.spawn_jockeys(id, true);
}
}
}
/// `ServerChunkCache.tickChunks`: the spawn state over the loaded mobs,
/// the categories under their global caps (creatures every 400 ticks),
/// then `spawnForChunk` in the shuffled chunks within 128 blocks of the
/// player.
pub(super) fn spawn_natural(&mut self) {
let Some(p) = self.player.as_ref().map(|p| p.position) else {
return;
};
let (pcx, pcz) = (p.x.floor() as i32 >> 4, p.z.floor() as i32 >> 4);
// ChunkMap.collectSpawningChunks: tracked chunks whose centre is
// within 128 blocks of a non-spectator.
let spectator = self.spectator();
let mut chunks: Vec<(i32, i32)> = (-8..=8)
.flat_map(|dx| (-8..=8).map(move |dz| (pcx + dx, pcz + dz)))
.filter(|&(cx, cz)| {
let (x, z) = (f64::from(cx * 16 + 8) - p.x, f64::from(cz * 16 + 8) - p.z);
!spectator && x * x + z * z < 16384.0 && self.world.chunks.contains_key(&(cx, cz))
})
.collect();
let spawn_mobs = self.ui.rules.bool("spawn_mobs");
let spawn_enemies = self.ui.rules.bool("spawn_monsters");
let mut random = std::mem::replace(&mut self.world.random, LegacyRandom::new(0));
let mut pending = Vec::new();
{
let level = self.natural_level();
let counted: Vec<Counted> = self
.mobs
.iter()
.map(|m| Counted {
entity: &m.kind.id,
category: Category::from_name(&m.kind.category),
block: [
m.position.x.floor() as i32,
m.position.y.floor() as i32,
m.position.z.floor() as i32,
],
is_mob: true,
persistent: m.persistent,
})
.collect();
let players = vec![SpawnPlayer {
position: [p.x, p.y, p.z],
spectator,
}];
// A single player's natural spawn chunk tracker covers 17 x 17 chunks.
let mut state = SpawnState::new(&level, 289, players, &counted, |cx, cz| {
self.world.chunks.contains_key(&(cx, cz))
});
let categories = if spawn_mobs {
state.filtered_categories(spawn_enemies, self.world.game_time % 400 == 0)
} else {
Vec::new()
};
// Util.shuffle.
for i in (1..chunks.len()).rev() {
let j = random.next_int_bound(i as i32 + 1).unwrap_or(0) as usize;
chunks.swap(i, j);
}
if !categories.is_empty() {
for chunk in chunks {
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
let spawned = l.game.finalize_spawned(r, &mut pending);
if let Some(s) = &spawned {
l.pending.borrow_mut().push(s.bounds);
}
spawned
};
spawn_selection::spawn_for_chunk(
&level,
&mut random,
&mut state,
&categories,
chunk,
&mut spawn,
&mut |_| {},
);
}
}
}
self.world.random = random;
self.add_spawned(pending, false);
}
/// `NoiseBasedChunkGenerator.spawnOriginalMobs`: creature packs for a
/// freshly generated chunk, from the biome at its corner column's top,
/// with the chunk's decoration-seeded random (mobs are not generated in
/// the End).
pub(super) fn spawn_generation_creatures(&mut self, cx: i32, cz: i32) {
if !self.world.chunks.contains_key(&(cx, cz)) || self.dim == Dim::End {
return;
}
let mut random = WorldgenRandom::new(LegacyRandom::new(0));
random.set_decoration_seed(self.level.seed, cx * 16, cz * 16);
// The region's own random: no creature's spawn predicate draws from it.
let mut rules = LegacyRandom::new(self.level.seed ^ (i64::from(cx) << 32 | i64::from(cz)));
let mut pending = Vec::new();
{
let level = self.natural_level();
let source = [cx * 16, level.max_y(), cz * 16];
let mut spawn = |l: &GameLevel, r: &SpawnRequest| {
let spawned = l.game.finalize_spawned(r, &mut pending);
if let Some(s) = &spawned {
l.pending.borrow_mut().push(s.bounds);
}
spawned
};
spawn_selection::spawn_mobs_for_chunk_generation(
&level,
source,
(cx, cz),
&mut random,
&mut rules,
&mut spawn,
&mut |_| {},
);
}
self.add_spawned(pending, true);
}
}
+13 -12
View File
@@ -176,13 +176,9 @@ impl Game<'_> {
self.settings.save(&Self::options_path());
self.apply_ui_settings();
}
/// Frame limiter for the Max Framerate option: when the next frame is due.
pub(super) fn next_frame_due(&self) -> Option<std::time::Instant> {
if self.options.frames.is_some() {
return None;
}
// FramerateLimitTracker.getFramerateLimit: the option, throttled when
// minimised, idle, or in a menu without a world.
/// `FramerateLimitTracker.getFramerateLimit`: the Max Framerate option,
/// throttled when minimised, idle, or in a menu without a world.
pub(super) fn framerate_limit(&self) -> i32 {
let context = FrameContext {
inactivity: if self.settings.inactivity_fps_limit == "minimized" {
InactivityFpsLimit::Minimized
@@ -201,11 +197,16 @@ impl Game<'_> {
has_screen: self.title.is_some() || self.screen != super::Screen::Playing,
has_overlay: false,
};
let fps = framerate_limit(self.settings.max_fps as i32, context);
if fps >= 260 {
return None;
framerate_limit(self.settings.max_fps as i32, context)
}
/// `Minecraft.runTick`'s frame limiter: `FramerateLimiter.limitDisplayFPS`
/// below 260 frames per second, sleeping then spin-waiting until the
/// frame's slot ends (not in scripted frame-count runs).
pub(super) fn limit_frame(&mut self) {
if self.options.frames.is_some() {
return;
}
let due = self.previous + std::time::Duration::from_secs_f64(1.0 / f64::from(fps));
(due > std::time::Instant::now()).then_some(due)
let limit = self.framerate_limit();
horizon_client::window::end_frame(&mut self.limiter, limit, &mut self.frame_clock);
}
}
+4 -2
View File
@@ -123,8 +123,10 @@ impl Game<'_> {
);
}
self.res.atlas = atlas;
self.res.icons.clear();
self.res.displays.clear();
match horizon_client::item_render::ItemRenderer::load(&root) {
Ok(r) => self.res.item_renderer = std::sync::Arc::new(r),
Err(e) => eprintln!("Horizon: item model reload failed: {e}"),
}
} else {
eprintln!(
"Horizon: resource pack atlas size changed; restart to apply GUI textures"
+38 -11
View File
@@ -48,7 +48,16 @@ fn with_raw(modelled: Tag, raw: Option<&Tag>) -> Tag {
// Defaults Horizon invents for fields it does not simulate give way to the save.
let invented = matches!(
k.to_string_lossy().as_str(),
"Fire" | "Air" | "Invulnerable" | "PortalCooldown" | "HasBeenShot"
"Fire"
| "Air"
| "Invulnerable"
| "PortalCooldown"
| "HasBeenShot"
// FallingBlockEntity fields Horizon does not simulate.
| "HurtEntities"
| "CancelDrop"
| "FallHurtAmount"
| "FallHurtMax"
);
if !(invented && out.contains_key(k)) {
out.insert(k.clone(), v.clone());
@@ -176,27 +185,40 @@ impl Game<'_> {
entity_nbt::falling_block_from_nbt(&tag)
&& let Some(state) = self.state_from_nbt(&state)
{
let (no_gravity, drop_item) = entity_nbt::falling_block_flags(&tag);
self.falling.push(FallingBlock {
state,
position,
previous: position,
velocity,
age,
no_gravity,
drop_item,
saved: uuid.map(|u| Box::new((u, tag.clone()))),
});
}
}
k if super::boats::boat_wood(k).is_some()
&& self
.boat_models
.models
.contains_key(super::boats::boat_wood(k).unwrap_or("")) =>
k if super::boats::any_boat_wood(k)
.is_some_and(|(wood, chest)| self.boat_models.has(wood, chest)) =>
{
if let Some((position, velocity, yaw)) = entity_nbt::boat_from_nbt(&tag) {
self.next_entity += 1;
let id = self.next_entity;
let wood =
std::sync::Arc::from(super::boats::boat_wood(k).unwrap_or("oak"));
let mut b = super::boats::Boat::new(id, wood, position, yaw);
let (wood, chest) =
super::boats::any_boat_wood(k).unwrap_or(("oak", false));
let mut b = super::boats::Boat::new(
id,
std::sync::Arc::from(wood),
position,
yaw,
);
if chest {
b = b.with_chest();
b.items = horizon_client::save::decode_items(
tag.get("Items"),
super::boats::CHEST_SLOTS,
);
}
b.velocity = velocity;
self.remember(id, uuid, tag);
self.boats.push(b);
@@ -497,12 +519,16 @@ impl Game<'_> {
}
for f in &self.falling {
if keys.contains(&chunk_of(f.position)) {
let (uuid, raw) = anon(&self.persist, "minecraft:falling_block", f.position);
let (uuid, raw) = match &f.saved {
Some(saved) => (saved.0, Some(saved.1.clone())),
None => anon(&self.persist, "minecraft:falling_block", f.position),
};
let tag = entity_nbt::falling_block_to_nbt(
self.state_to_nbt(f.state),
f.position,
f.velocity,
f.age,
f.drop_item,
uuid,
);
out.entry(chunk_of(f.position))
@@ -519,12 +545,13 @@ impl Game<'_> {
.copied()
.unwrap_or_else(random_uuid);
let tag = entity_nbt::boat_to_nbt(
&b.wood,
&b.entity_type(),
b.position,
b.velocity,
b.yaw,
uuid,
self.persist.raw.get(&b.id),
b.chest.then_some(&b.items[..]),
);
out.entry(chunk_of(b.position)).or_default().push(tag);
}
+188 -29
View File
@@ -3,13 +3,105 @@
//! `getMaxZoom`), and the local player drawn as a model: in third person
//! and in the inventory (`InventoryScreen.extractEntityInInventoryFollowsMouse`).
use super::{Game, Screen};
use horizon_client::{
blocks::BlockFacts,
camera::{CameraEntity, LivingCamera},
collisions::SceneCollisions,
};
use horizon_client::{
hud::HudBatch,
mobs::{Anim, Brain, Mob, MobType},
pose::Pose,
};
use horizon_core::geometry::Vec3;
use std::{path::Path, sync::Arc};
use horizon_core::{
geometry::{Aabb, ShapeOp, Vec3, VoxelShape},
position::BlockPos,
};
use horizon_world::{
collision_context::{CollisionContext, CollisionEntity, CollisionItem},
entity_obstruction::EntityIdentity,
registry::BlockStateId,
world_ray::{RayBlockMode, RayFluidMode, RayWorld, clip_world},
};
use std::{io, path::Path, sync::Arc};
/// `Level.clip` with `ClipContext.Block.VISUAL` and no fluids:
/// `BlockBehaviour.getVisualShape` is the collision shape except for the
/// blocks that override it.
struct VisualRays<'a, 'r> {
scene: SceneCollisions<'a, 'r>,
facts: &'a BlockFacts,
context: CollisionContext<'static>,
}
impl RayWorld for VisualRays<'_, '_> {
type Error = io::Error;
type Block = BlockStateId;
type Fluid = ();
fn block_state(&mut self, p: BlockPos) -> io::Result<BlockStateId> {
Ok(self.scene.world.block(p.x, p.y, p.z))
}
fn fluid_state(&mut self, _: BlockPos) -> io::Result<()> {
Ok(())
}
fn block_shape(
&mut self,
state: BlockStateId,
p: BlockPos,
_: RayBlockMode,
) -> io::Result<VoxelShape> {
let facts = self.facts.get(state);
let name = &*facts.name;
// TransparentBlock (glass, stained and tinted glass, copper grates),
// IronBarsBlock (bars and panes) and PowderSnowBlock: nothing;
// SoulSandBlock and MudBlock: a full block; SnowLayerBlock and
// FenceBlock: their outline.
if name.ends_with("glass")
|| name.ends_with("glass_pane")
|| name.ends_with("_bars")
|| name.ends_with("copper_grate")
|| name == "minecraft:powder_snow"
{
return Ok(VoxelShape::empty());
}
if name == "minecraft:soul_sand" || name == "minecraft:mud" {
return Ok(VoxelShape::block());
}
if name == "minecraft:snow" || name.ends_with("_fence") {
let mut shape = VoxelShape::empty();
for b in facts.outline.iter() {
let b = b.map(f64::from);
let part = VoxelShape::from_box(Aabb::new(
Vec3::new(b[0], b[1], b[2]),
Vec3::new(b[3], b[4], b[5]),
))
.map_err(|e| io::Error::other(format!("{e:?}")))?;
shape = shape
.join(&part, ShapeOp::OR)
.map_err(|e| io::Error::other(format!("{e:?}")))?;
}
return Ok(shape);
}
let binding = self.scene.binding;
binding
.collision_shape_with_context(&mut self.scene, state, p, &self.context)
.map_err(|e| io::Error::other(format!("{e:?}")))
}
fn interaction_shape(&mut self, _: BlockStateId, _: BlockPos) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
fn fluid_is_source(&mut self, _: ()) -> io::Result<bool> {
Ok(false)
}
fn fluid_is_empty(&mut self, _: ()) -> io::Result<bool> {
Ok(true)
}
fn fluid_is_water(&mut self, _: ()) -> io::Result<bool> {
Ok(false)
}
fn fluid_shape(&mut self, _: (), _: BlockPos) -> io::Result<VoxelShape> {
Ok(VoxelShape::empty())
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub(super) enum CameraType {
@@ -95,34 +187,99 @@ impl Game<'_> {
pub(super) fn cycle_camera(&mut self) {
self.camera_type = self.camera_type.cycle();
}
/// `Camera.setup`: the eye, or a point up to four blocks behind (or in
/// front of) it, pulled in where blocks are in the way. Returns the
/// `Camera.update` (`alignWithEntity` and `getMaxZoom`): the
/// interpolated eye, or a point up to the camera distance behind (or in
/// front of) it, pulled in where visual shapes are in the way. Returns the
/// position, yaw and pitch to render from.
pub(super) fn camera_view(&self, eye: glam::DVec3) -> (glam::DVec3, f32, f32) {
let (mut yaw, mut pitch) = (self.yaw, self.pitch);
if self.camera_type.first_person() || self.player.is_none() || self.sleeping.is_some() {
return (eye, yaw, pitch);
}
if self.camera_type == CameraType::ThirdPersonFront {
yaw += 180.0;
pitch = -pitch;
}
let f = super::view_vector(yaw, pitch);
let mut dist = 4.0f64;
for i in 0..8 {
let o = [
f64::from((i & 1) * 2 - 1) * 0.1,
f64::from((i >> 1 & 1) * 2 - 1) * 0.1,
f64::from((i >> 2 & 1) * 2 - 1) * 0.1,
];
let from = [eye.x + o[0], eye.y + o[1], eye.z + o[2]];
let back = [-f[0], -f[1], -f[2]];
if let Some(hit) = self.clip_ray(from, back, dist) {
let d = glam::DVec3::from_array(hit.point) - eye;
dist = dist.min(d.length());
}
}
(eye - glam::DVec3::from_array(f) * dist, yaw, pitch)
pub(super) fn camera_view(&mut self, partial: f32) -> Option<(glam::DVec3, f32, f32)> {
let (scale, distance) = {
let p = self.player.as_mut()?;
(
p.attribute("minecraft:scale").unwrap_or(1.0) as f32,
p.attribute("minecraft:camera_distance").unwrap_or(4.0),
)
};
let p = self.player.as_ref()?;
let sleeping = self.sleeping.map(|(head, _)| {
let state = self.world.block(head[0], head[1], head[2]);
let props = self.res.assets.registry.state(state).map(|s| &s.properties);
// BedBlock.getBedOrientation as Direction.toYRot.
props.and_then(|props| match props.get("facing").map(String::as_str) {
Some("south") => Some(0.0),
Some("west") => Some(90.0),
Some("north") => Some(180.0),
Some("east") => Some(270.0),
_ => None,
})
});
// A living mount at the default scale and camera distance.
let mount = self
.riding
.and_then(|id| self.mobs.iter().find(|m| m.id == id))
.map(|_| (1.0, 4.0));
let entity = CameraEntity {
position: [p.position.x, p.position.y, p.position.z],
previous: [
self.previous_position.x,
self.previous_position.y,
self.previous_position.z,
],
// Entity.turn moves the old rotation with the new: the mouse
// turns the view every frame without interpolation.
x_rot: self.pitch,
y_rot: self.yaw,
x_rot_o: self.pitch,
y_rot_o: self.yaw,
eye_height: p.dimensions.eye_height,
living: Some(LivingCamera {
scale,
camera_distance: distance,
sleeping,
dead: None,
}),
mount,
};
let mut view = self.view;
let kind = match self.camera_type {
CameraType::FirstPerson => horizon_client::camera::CameraType::FirstPerson,
CameraType::ThirdPersonBack => horizon_client::camera::CameraType::ThirdPersonBack,
CameraType::ThirdPersonFront => horizon_client::camera::CameraType::ThirdPersonFront,
};
let context = CollisionContext::for_entity(
CollisionEntity {
identity: EntityIdentity(0),
y: p.position.y,
descending: p.shift_key_down,
main_hand: Some(CollisionItem::EMPTY),
},
false,
);
let mut rays = VisualRays {
scene: SceneCollisions {
world: &self.world,
binding: self.collision,
},
facts: &self.res.facts,
context,
};
view.align_with_entity(&entity, partial, kind, |from, to| {
let hit = clip_world(
&mut rays,
Vec3::new(from[0], from[1], from[2]),
Vec3::new(to[0], to[1], to[2]),
RayBlockMode::Visual,
RayFluidMode::None,
4096,
)
.ok()?;
(!hit.miss).then_some([hit.hit.location.x, hit.hit.location.y, hit.hit.location.z])
});
self.view = view;
Some((
glam::DVec3::from_array(view.position),
view.y_rot,
view.x_rot,
))
}
/// Keep the player's model in step with the player (`LivingEntity.tick`
/// body turning, `WalkAnimationState`, the swing and the pose).
@@ -242,6 +399,7 @@ impl Game<'_> {
&self.player_armor_layers(),
out,
);
self.held_item_geometry(m, camera, alpha, packed, true, out);
}
/// `extractEntityInInventoryFollowsMouse`: the player in the box
/// `[x0, x1) x [y0, y1)`, `size` pixels per block, turned toward the
@@ -295,6 +453,7 @@ impl Game<'_> {
&self.player_armor_layers(),
&mut verts,
);
self.held_item_geometry(&m, camera, 1.0, 240 | 240 << 8, false, &mut verts);
let height = if m.extra.crouching { 1.5 } else { 1.8 };
// PictureInPictureRenderer: the box centre, (size, size, -size);
// then the translation and Rz(pi) * Rx(y_angle * 20 degrees).
+42 -46
View File
@@ -6,7 +6,8 @@ use super::{Game, Screen};
use horizon_client::{
brewing,
effects::{EffectAction, EffectInstance, PlayerEffects},
hud::{HudBatch, rgba},
hud::HudBatch,
hud_layout, hud_overlays,
items::ItemStack,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
@@ -65,6 +66,27 @@ pub(super) struct PotionState {
std::collections::HashMap<String, horizon_core::random::XoroshiroRandom>,
}
impl PotionState {
/// Thrown potions in flight as (entity type, position, motion), for the
/// dispenser parity fixture.
pub(super) fn thrown_entities(&self) -> Vec<(&'static str, Vec3, Vec3)> {
self.thrown
.iter()
.map(|t| {
let kind = match &*t.stack.item {
"minecraft:lingering_potion" => "minecraft:lingering_potion",
"minecraft:experience_bottle" => "minecraft:experience_bottle",
_ => "minecraft:splash_potion",
};
(kind, t.position, t.velocity)
})
.collect()
}
pub(super) fn clear_thrown(&mut self) {
self.thrown.clear();
}
}
impl Game<'_> {
/// Right-click with a potion, splash/lingering potion or glass bottle.
pub(super) fn use_potion_item(&mut self, stack: &ItemStack) -> horizon_client::Result<bool> {
@@ -542,52 +564,26 @@ impl Game<'_> {
if self.screen != Screen::Playing || self.potions.effects.list.is_empty() {
return;
}
let mut list: Vec<&EffectInstance> = self.potions.effects.list.iter().collect();
let color = |e: &EffectInstance| brewing::effect(e.id).map_or(0, |d| d.color);
list.sort_by(|a, b| {
(a.ambient, a.duration == -1, a.duration, color(a))
.cmp(&(b.ambient, b.duration == -1, b.duration, color(b)))
.reverse()
});
let (mut good, mut bad) = (0, 0);
let atlas = &self.res.atlas;
for e in list {
let beneficial =
brewing::effect(e.id).is_some_and(|d| d.category == brewing::Category::Beneficial);
let mut x = batch.width;
let mut y = 1.0;
if beneficial {
good += 1;
x -= 25.0 * good as f32;
} else {
bad += 1;
x -= 25.0 * bad as f32;
y += 26.0;
}
let mut alpha = 1.0f32;
if e.ambient {
batch.sprite(atlas, "gui/sprites/hud/effect_background_ambient", x, y);
} else {
batch.sprite(atlas, "gui/sprites/hud/effect_background", x, y);
if e.duration != -1 && e.duration <= 200 {
let d = e.duration as f32;
let used = 10.0 - (e.duration / 20) as f32;
alpha = ((d / 10.0 / 5.0 * 0.5).clamp(0.0, 0.5)
+ (d * std::f32::consts::PI / 5.0).cos()
* (used / 10.0 * 0.25).clamp(0.0, 0.25))
.clamp(0.0, 1.0);
let icons: Vec<hud_overlays::EffectIcon> = self
.potions
.effects
.list
.iter()
.map(|e| {
let def = brewing::effect(e.id);
hud_overlays::EffectIcon {
id: e.id.to_owned(),
beneficial: def.is_some_and(|d| d.category == brewing::Category::Beneficial),
color: def.map_or(0, |d| d.color as i32),
ambient: e.ambient,
duration: e.duration,
// Horizon keeps no showIcon flag: every effect shows its icon.
show_icon: true,
}
}
batch.sprite_scaled(
atlas,
&format!("mob_effect/{}", e.id),
x + 3.0,
y + 3.0,
18.0,
18.0,
rgba(255, 255, 255, (alpha * 255.0) as u8),
);
}
})
.collect();
let draws = hud_overlays::effects(batch.width as i32, false, &icons);
hud_layout::paint(batch, &self.res.atlas, &draws);
}
/// Effects saved in level.dat (`Player.active_effects`).
pub(super) fn restore_effects(&mut self) {
+10 -48
View File
@@ -4,9 +4,10 @@ use super::{Dim, Game};
use horizon_client::mobs::villager as mobs_villager;
use horizon_client::{
effects::EffectInstance,
hud::{HudBatch, rgba},
hud_overlays,
mobs::{Mob, Species, data, raider},
raid::{self, Action, BarName, RaidLevel, RaiderInfo, RaiderType, Raids, Spawn},
text::Text,
};
use horizon_core::{geometry::Vec3, random::RandomSource};
use std::collections::{HashMap, HashSet};
@@ -746,31 +747,12 @@ impl Game<'_> {
}
}
/// The raid boss bars (`BossHealthOverlay`): a red notched bar with the
/// raid's title. Returns the next free row.
pub(super) fn draw_raid_bar(&self, batch: &mut HudBatch, mut y: f32) -> f32 {
let atlas = &self.res.atlas;
/// The raid boss bars: red and notched in tens, with the raid's title.
pub(super) fn raid_bars(&self, bars: &mut Vec<hud_overlays::BossBar>) {
for raid in self.raiding.raids.map.values() {
if !raid.bar.visible || !raid.bar.player || raid.is_stopped() {
continue;
}
let x = (batch.width / 2.0 - 91.0).floor();
batch.sprite(atlas, "gui/sprites/boss_bar/red_background", x, y);
batch.sprite(atlas, "gui/sprites/boss_bar/notched_10_background", x, y);
let p = raid.bar.progress;
// Mth.lerpDiscrete(progress, 0, 182).
let width = (p * 181.0).floor() as i32 + i32::from(p > 0.0);
if width > 0 {
let fraction = width as f32 / 182.0;
batch.sprite_part(atlas, "gui/sprites/boss_bar/red_progress", x, y, fraction);
batch.sprite_part(
atlas,
"gui/sprites/boss_bar/notched_10_progress",
x,
y,
fraction,
);
}
let title = self.translate("event.minecraft.raid", "Raid");
let name = match raid.bar.name {
BarName::Raid => title,
@@ -789,18 +771,13 @@ impl Game<'_> {
self.translate("event.minecraft.raid.defeat.full", "Raid - Defeat")
}
};
let w = HudBatch::text_width(atlas, &name);
batch.text(
atlas,
&name,
(batch.width / 2.0 - w / 2.0).floor(),
y - 9.0,
rgba(255, 255, 255, 255),
true,
);
y += 19.0;
bars.push(hud_overlays::BossBar {
name: Text::literal(name),
progress: raid.bar.progress,
color: 2,
overlay: 2,
});
}
y
}
/// A firework a villager launches to celebrate (`CelebrateVillagersSurvivedRaid`).
pub(super) fn villager_firework(&mut self, at: [f64; 3], color: usize, flight: u8) {
@@ -921,21 +898,6 @@ impl Game<'_> {
}
/// A raider's ominous banner (drop chance 2.0: always dropped) and a
/// patrol captain's ominous bottle (`entities/pillager`).
pub(super) fn raider_death_loot(&mut self, m: &horizon_client::mobs::Mob, pos: [f64; 3]) {
if !raider::is_raider(m.species()) {
return;
}
if m.extra.head.as_deref() == Some(raider::BANNER) {
self.spawn_item(raider::ominous_banner(), pos, [0.0, 0.2, 0.0], 10);
}
if m.species() == Species::Pillager && raider::is_captain(m) {
let amplifier = self.world.random.next_int_bound(5).unwrap_or(0) as u8;
let amplifier = (amplifier > 0).then_some(amplifier);
let mut bottle = horizon_client::items::ItemStack::new("minecraft:ominous_bottle", 1);
bottle.data_mut().ominous_amplifier = amplifier;
self.spawn_item(bottle, pos, [0.0, 0.2, 0.0], 10);
}
}
/// `OminousBottleAmplifier.onConsume`: Bad Omen for 100 minutes at the
/// bottle's amplifier, then the dispose sound; the bottle is used up.
pub(super) fn drink_ominous_bottle(
+10 -5
View File
@@ -490,7 +490,8 @@ impl Game<'_> {
if !open {
self.recipe_ui.overlay = None;
}
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
if !self.book_open(book_type) || self.spectator() {
@@ -518,12 +519,14 @@ impl Game<'_> {
if self.recipe_ui.page + 1 < pages && inside(cursor, xo + 93.0, yo + 137.0, 12.0, 17.0)
{
self.recipe_ui.page += 1;
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
if self.recipe_ui.page > 0 && inside(cursor, xo + 38.0, yo + 137.0, 12.0, 17.0) {
self.recipe_ui.page -= 1;
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
}
@@ -570,7 +573,8 @@ impl Game<'_> {
let filtering = !self.book_filtering(book_type);
self.level.recipe_book.filtering[book_type.index()] = filtering;
self.recipe_ui.page = 0;
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
// Tabs.
@@ -581,7 +585,8 @@ impl Game<'_> {
self.recipe_ui.tab = Some(tab);
self.recipe_ui.page = 0;
}
self.audio.play("minecraft:ui.button.click", None, 1.0, 1.0);
self.audio
.play("minecraft:ui.button.click", None, 0.25, 1.0);
return true;
}
}
Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more