From 21845bd7367a260ee6a3d189417a8620aa5bf738 Mon Sep 17 00:00:00 2001 From: koka Date: Thu, 17 Sep 2026 06:57:34 +0200 Subject: [PATCH] Add chaotic flight when carried flying animals are struck (beta.114) --- README.md | 15 ++- docs/carried-flight-panic-beta114.md | 79 ++++++++++++ gradle.properties | 4 +- mods/sanctuary/build.gradle | 4 + .../client/CarryPanic114ClientChecks.java | 115 ++++++++++++++++++ .../koka/sanctuary/cosmetics/CarryGlide.java | 1 + .../koka/sanctuary/cosmetics/CarryPanic.java | 62 ++++++++++ .../sanctuary/cosmetics/CarryService.java | 7 +- .../sanctuary/cosmetics/FamiliarEntity.java | 5 +- .../sanctuary/cosmetics/FamiliarService.java | 2 +- .../mixin/CarryFlightConnectionMixin.java | 4 +- .../koka/sanctuary/mixin/CarryGlideMixin.java | 2 + .../sanctuary/mixin/CarryLivingMixin.java | 2 +- .../sanctuary/mixin/CarryPlayerMixin.java | 2 +- .../mixin/client/BoatPassengerPickMixin.java | 3 +- .../assets/sanctuary/lang/en_us.json | 1 + .../assets/sanctuary/lang/fr_fr.json | 1 + packwiz/pack.toml | 2 +- 18 files changed, 293 insertions(+), 18 deletions(-) create mode 100644 docs/carried-flight-panic-beta114.md create mode 100644 mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/CarryPanic114ClientChecks.java create mode 100644 mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryPanic.java diff --git a/README.md b/README.md index f7a40a4..ebc12fe 100644 --- a/README.md +++ b/README.md @@ -20,11 +20,10 @@ La [vision complète](docs/vision.md) conserve les intentions ; le L’économie, les claims et les dimensions décrits dans la vision restent à implémenter. -La version **beta.113** nomme les miniatures « sculptures d’argile » et les -cale contre le bord visé du bloc, sur la grille exacte de 1/16 de bloc. -Le [ticket](docs/clay-sculpture-grid-beta113.md) décrit l’accroche, sa sauvegarde -et les vérifications. Cette version est installée dans **Sanctuary Beta**. -L’atelier conserve son menu limité à la hotbar. +La version **beta.114** ajoute une fuite chaotique aux animaux volants portés : +un coup les fait décoller avec leur porteur, dans une direction aléatoire, +pendant deux à trois secondes. Le [ticket](docs/carried-flight-panic-beta114.md) +décrit le comportement et ses vérifications. La [livraison précédente](docs/clay-workshop-integration-beta110.md) réunit déjà l’atelier, les œufs et la neige saisonnière dans l’instance **Sanctuary Beta**. Les [synchronisations antérieures](docs/git-sync-beta092.md) restent documentées. @@ -35,6 +34,12 @@ La [carte native beta.005](docs/atlas-beta005.md) prend le relais après l’étude de Xaero et des alternatives libres. +## beta.114 — Animaux portés et fuite en vol + +Un animal volant porté entraîne son porteur après un coup. La trajectoire +aléatoire prend brièvement le dessus sur les commandes ; déposer l’animal +interrompt le vol. [Contrat et vérifications](docs/carried-flight-panic-beta114.md). + ## beta.113 — Sculptures d’argile et alignement Pose contre le bord visé, orientation selon le regard et coordonnées entières diff --git a/docs/carried-flight-panic-beta114.md b/docs/carried-flight-panic-beta114.md new file mode 100644 index 0000000..47c8049 --- /dev/null +++ b/docs/carried-flight-panic-beta114.md @@ -0,0 +1,79 @@ +# CARRY-08 — Fuite des animaux volants portés — beta.114 + +Contrat du 17 septembre 2026, branche `codex/carried-flight-panic-beta114`. + +Un coup reçu par un animal volant porté sur la tête déclenche une fuite de +40 à 60 ticks : il entraîne son porteur vers le haut, dans une direction +aléatoire, avec des embardées. Le serveur choisit le départ et la trajectoire, +transmis au client pour la physique native. Les touches de déplacement et le +regard ne pilotent pas cette brève fuite. La caméra reste libre. Un nouveau coup peut relancer la fuite avec un autre +cap après dix ticks, sans cumuler les vitesses. + +Le porteur peut viser et frapper son animal volant. Les animaux ordinaires +reçoivent les dégâts habituels ; le familier conserve son coup amical sans +perte de santé. Les attaques extérieures acceptées déclenchent aussi la fuite. +Les autres protections entre passagers et porteurs restent en place. +Les espèces reprennent le groupe déjà utilisé pour planer, poule comprise. + +La vitesse reste bornée, les collisions sont natives et aucun bloc n’est +traversé ni déplacé. Déposer l’animal (Maj + clic droit), le perdre, mourir, +se déconnecter ou changer de dimension arrête la propulsion. Immersion, +mode spectateur, vol créatif, élytres et porteur lui-même passager l’inhibent. +La fin de fuite rend le contrôle au joueur et conserve le planeur existant +si l’animal reste porté. Les montures colossales gardent leur pilotage actuel. + +L’état de fuite est temporaire, synchronisé et non sauvegardé. Aucun format +persistant, identifiant existant ou monde personnel n’est modifié. Les nouveaux +retours d’interface sont traduits FR/EN. Client et serveur utilisent beta.114. + +## Vérifications natives + +`CarryPanic114ClientChecks` passe en **56 secondes** avec un serveur intégré +Minecraft 26.3 et un monde plat jetable de graine 114 : + +- Véritable ciblage du perroquet sur la tête et frappe avec la touche native, + perte de santé de l’animal ordinaire, décollage et déplacement du joueur. +- Trajectoire synchronisée et priorité sur les touches avant/droite/saut et + le regard ; comparaison exacte avec la vitesse du tick de physique. +- Expiration, suppression de l’état temporaire et retour au planeur normal. +- 32 trajectoires tirées côté serveur couvrent les quatre quadrants, toutes + dans les bornes de durée et de vitesse ; Maj + clic droit natif interrompt. +- Sept espèces ordinaires : poule, chauve-souris, abeille, allay, perroquet, + blaze et breeze. Une vache portée reste protégée et ne déclenche aucun vol. +- Attaque extérieure, mort, activation du vol créatif et détachement arrêtent + ou déclenchent la réaction selon le contrat. +- Plafond bas : aucune traversée ; le passager bloqué est déposé par les + contrôles de place existants et la propulsion s’interrompt. +- Véritable coup amical sur un familier perroquet : lancement sans perte de + santé, puis arrêt immédiat lors du détachement. + +```sh +./gradlew :sanctuary:runClientGameTest \ + -PsanctuaryClientTests=true -PsanctuaryCarryPanic114ClientTests=true \ + -PsanctuaryClientNoVsync=true -PsanctuaryQuickTests=true +``` + +Marqueurs `CARRY114_NATIVE_FLIGHT_PASS` et `CARRY114_PASS` dans +`build/carry-panic114-client.log`. Capture en jeu relue dans +`build/carry-panic114-evidence/`. Le GameTest dédié reste exclu conformément +au refus antérieur de son EULA. Pas de test Windows, de réseau distant ou de +latence simulée ; aucun monde personnel n’a été ouvert. + +## Livraison + +`./gradlew check build assemblePack assembleTestPack -x :sanctuary:runGameTest` +passe en **2 min 22 s**, 125 tâches. Les sources JAR correspondent exactement +aux sources du dépôt. Les classes et ressources hors de ce ticket restent +identiques à beta.113, notamment les sculptures d’argile. Les deux packs +contiennent le même JAR, sans monde, GLB ni classes de test. + +- Sanctuary-beta.114.mrpack : 10179428 octets, SHA-256 + `92aac61c2c00a2206f3f5ac3d170ab058f6765526cf5a2a64c5336261db3f5bf`. +- Sanctuary-Test-beta.114.mrpack : 10198350 octets, SHA-256 + `5e8ec1f18e34ca51e09e2738f2b04f9a1bbac5d14c7a7c2d766218a4cff6021f`. +- JAR Sanctuary : SHA-256 + `18105b76f63008210c62d5728f999c7abc083a861ecc1de2c7ee87262dde5a40`. + +Reçu local : `build/carry-panic114-artifact.json`. + +Publication et synchronisation de l’instance à effectuer après le commit source. diff --git a/gradle.properties b/gradle.properties index f467341..7803055 100644 --- a/gradle.properties +++ b/gradle.properties @@ -9,8 +9,8 @@ loom_version=1.17.20 fabric_api_version=0.160.5+26.3 # Release counter: beta.001, .002, .003, ... (see docs/versioning.md). -mod_version=beta.113 -pack_version=beta.113 +mod_version=beta.114 +pack_version=beta.114 resource_pack_version=beta.090 maven_group=fr.koka.sanctuary jei_version=30.32.0-sanctuary.3 diff --git a/mods/sanctuary/build.gradle b/mods/sanctuary/build.gradle index 7962dd4..ec7b230 100644 --- a/mods/sanctuary/build.gradle +++ b/mods/sanctuary/build.gradle @@ -93,6 +93,10 @@ tasks.named('runGameTest') { } tasks.named('processGametestResources') { + def carryPanic114Client = providers.gradleProperty('sanctuaryCarryPanic114ClientTests').map { it.toBoolean() }.getOrElse(false) + inputs.property('sanctuaryCarryPanic114ClientTests', carryPanic114Client) + if (carryPanic114Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.CarryPanic114ClientChecks') } + def statuary106Client = providers.gradleProperty('sanctuaryStatuary106ClientTests').map { it.toBoolean() }.getOrElse(false) inputs.property('sanctuaryStatuary106ClientTests', statuary106Client) if (statuary106Client) filter { line -> line.replace('fr.koka.sanctuary.gametest.SanctuaryClientRenderTests', 'fr.koka.sanctuary.client.Statuary106ClientChecks') } diff --git a/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/CarryPanic114ClientChecks.java b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/CarryPanic114ClientChecks.java new file mode 100644 index 0000000..6c8d8b9 --- /dev/null +++ b/mods/sanctuary/src/clientGametest/java/fr/koka/sanctuary/client/CarryPanic114ClientChecks.java @@ -0,0 +1,115 @@ +package fr.koka.sanctuary.client; + +import com.mojang.blaze3d.platform.InputConstants; +import fr.koka.sanctuary.cosmetics.*; +import fr.koka.sanctuary.gametest.Starter039TestSupport; +import fr.koka.sanctuary.inventory.*; +import java.util.*; +import java.util.concurrent.atomic.*; +import net.fabricmc.fabric.api.client.event.lifecycle.v1.ClientTickEvents; +import net.fabricmc.fabric.api.client.gametest.v1.FabricClientGameTest; +import net.fabricmc.fabric.api.client.gametest.v1.context.ClientGameTestContext; +import net.minecraft.client.CameraType; +import net.minecraft.client.gui.components.Button; +import net.minecraft.client.gui.screens.worldselection.WorldCreationUiState; +import net.minecraft.client.input.KeyEvent; +import net.minecraft.core.BlockPos; +import net.minecraft.core.component.DataComponents; +import net.minecraft.nbt.CompoundTag; +import net.minecraft.network.chat.contents.TranslatableContents; +import net.minecraft.server.MinecraftServer; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.*; +import net.minecraft.world.item.*; +import net.minecraft.world.item.component.CustomData; +import net.minecraft.world.level.block.Blocks; +import net.minecraft.world.phys.*; + +/** Real cursor/attack packets, synchronized player travel, interruption and collision regression. */ +public final class CarryPanic114ClientChecks implements FabricClientGameTest { + public void runTest(ClientGameTestContext c){ + var intro=new AtomicBoolean(true); + c.runOnClient(m->{m.options.renderDistance().set(6);m.options.simulationDistance().set(5);m.options.pauseOnLostFocus=false;IntroClient.setQuickTestIntro(true);}); + ClientTickEvents.END_CLIENT_TICK.register(m->{if(intro.get()&&m.gui.screen() instanceof HelloWorldScreen screen){ + Starter039TestSupport.selectFirst(screen); + screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getClass().getSimpleName().equals("ColorButton")).findFirst().orElseThrow().onPress(enter()); + screen.children().stream().filter(Button.class::isInstance).map(Button.class::cast).filter(b->b.getMessage().getContents() instanceof TranslatableContents t&&t.getKey().equals("sanctuary.progression.enter")).findFirst().orElseThrow().onPress(enter());intro.set(false); + }}); + try(var world=c.worldBuilder().adjustSettings(s->{s.setName("CarryPanic114 "+UUID.randomUUID());s.setSeed("114");s.setAllowCommands(true);s.setGameMode(WorldCreationUiState.SelectedGameMode.SURVIVAL); + s.setWorldType(s.getNormalPresetList().stream().filter(e->e.preset()!=null&&e.preset().getRegisteredName().equals("sanctuary_test:flat")).findFirst().orElseThrow());}).create()){ + c.waitFor(m->ProgressionClient.active()&&m.player!=null&&m.gui.screen()==null&&!IntroSpawnFlash.active(),1200);var server=world.getServer(); + server.runOnServer(s->{var p=player(s);p.getInventory().clearContent();p.getAbilities().invulnerable=true;p.onUpdateAbilities();reset(p); + for(int x=-24;x<=24;x++)for(int z=-24;z<=24;z++)p.level().setBlockAndUpdate(new BlockPos(x,0,z),Blocks.STONE.defaultBlockState()); + s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"sanctuary time vanilla");s.getCommands().performPrefixedCommand(s.createCommandSourceStack(),"time set noon");}); + int parrot=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId()); + c.waitFor(m->m.level.getEntity(parrot)!=null&&CarryService.carried(m.level.getEntity(parrot)),150); + float health=server.computeOnServer(s->((LivingEntity)player(s).level().getEntity(parrot)).getHealth()); + attack(c,parrot);c.waitFor(m->CarryPanic.active(m.player),100); + server.runOnServer(s->{var p=player(s);check(CarryPanic.active(p),"Real native attack triggers server panic");check(((LivingEntity)p.level().getEntity(parrot)).getHealth()o.keyUp);c.getInput().holdKey(o->o.keyRight);c.getInput().holdKey(o->o.keyJump); + c.runOnClient(m->{m.player.setYRot(170);m.player.setXRot(15);});c.waitTicks(12); + c.runOnClient(m->{check(m.player.getY()>2.5&&m.player.position().subtract(new Vec3(.5,1,.5)).horizontalDistance()>1,"Actual client moves upward and sideways"); + check(CarryPanic.active(m.player),"Panic remains active through steering keys"); + var wanted=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime()); + // Level time can advance immediately after the player physics tick. Match either exact tick. + var previous=m.player.getAttached(CarryPanic.FLIGHT).velocity(m.level.getGameTime()-1); + check(Math.min(m.player.getDeltaMovement().distanceTo(wanted),m.player.getDeltaMovement().distanceTo(previous))<.00001,"Movement follows animal, not camera or held keys: actual="+m.player.getDeltaMovement()+", expected="+wanted+", flight="+m.player.getAttached(CarryPanic.FLIGHT)+", time="+m.level.getGameTime());check(m.player.fallDistance<.2,"Flight clears falling distance"); + m.options.setCameraType(CameraType.THIRD_PERSON_BACK);});c.waitTicks(2);c.takeScreenshot("carry114-chaotic-flight"); + c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump); + c.waitTicks(65);c.waitFor(m->!CarryPanic.active(m.player),100); + server.runOnServer(s->{var p=player(s);check(!p.hasAttached(CarryPanic.FLIGHT)&&CarryGlide.supported(p),"Timeout removes panic and retains ordinary glider");check(p.connection.isAcceptingMessages(),"Flight is accepted by server");}); + c.runOnClient(m->{check(m.player.getDeltaMovement().y<=0&&m.player.getDeltaMovement().y>=-CarryGlide.DESCENT-.02,"Normal gentle descent resumes");m.options.setCameraType(CameraType.FIRST_PERSON);}); + System.out.println("CARRY114_NATIVE_FLIGHT_PASS cursor, real attack, damage, movement, input override, timeout, glide"); + + server.runOnServer(s->{var p=player(s);p.getRandom().setSeed(114);var bird=p.level().getEntity(parrot);var quadrants=new HashSet(); + for(int i=0;i<32;i++){ + CarryPanic.clear(p);CarryPanic.start(bird);var flight=p.getAttached(CarryPanic.FLIGHT);check(flight.duration()>=40&&flight.duration()<=60,"Duration bounded at 2–3 seconds");quadrants.add((int)(flight.yaw()/(Math.PI/2))); + for(int t=0;t<=flight.duration();t++){var v=flight.velocity(flight.start()+t);check(v.horizontalDistance()<=.331&&v.y>=0&&v.y<=.241,"Velocity bounded without cumulative impulses");} + } + check(quadrants.size()==4,"Server random headings cover every quadrant"); + });c.waitFor(m->CarryPanic.active(m.player),100); + c.getInput().holdKey(o->o.keyShift);c.waitTicks(2);c.getInput().holdKey(o->o.keyUse);c.waitTicks(1);c.getInput().releaseKey(o->o.keyUse);c.getInput().releaseKey(o->o.keyShift); + c.waitFor(m->!CarryService.carrying(m.player)&&!CarryPanic.active(m.player),100); + server.runOnServer(s->{check(!player(s).hasAttached(CarryPanic.FLIGHT),"Native drop clears flight immediately");player(s).level().getEntity(parrot).discard();reset(player(s));}); + + int cow=server.computeOnServer(s->carry(player(s),EntityTypes.COW).getId()); + server.runOnServer(s->{var p=player(s);var animal=(LivingEntity)p.level().getEntity(cow);float before=animal.getHealth();p.attack(animal); + check(CarryService.blocksAttack(p,animal)&&animal.getHealth()==before&&!animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&!CarryPanic.active(p),"Terrestrial carry pair stays protected");CarryPanic.start(animal);check(!CarryPanic.active(p),"Ground animal cannot launch player");CarryService.release(animal,true);animal.discard();reset(p);}); + + // Every supported appearance can trigger the shared flight, including the gliding chicken. + server.runOnServer(s->{var p=player(s);for(var type:List.of(EntityTypes.CHICKEN,EntityTypes.BAT,EntityTypes.BEE,EntityTypes.ALLAY,EntityTypes.PARROT,EntityTypes.BLAZE,EntityTypes.BREEZE)){ + var animal=carry(p,type);check(CarryGlide.flyer(animal)&&CarryPanic.canStrike(p,animal),"Carried flying species can be struck: "+type); + check(animal.hurtServer(p.level(),p.damageSources().playerAttack(p),1)&&CarryPanic.active(p),"Accepted damage launches carrier: "+type);CarryService.release(animal,true);animal.discard();check(!p.hasAttached(CarryPanic.FLIGHT),"Release clears each flight"); + }}); + int external=server.computeOnServer(s->carry(player(s),EntityTypes.PARROT).getId()); + server.runOnServer(s->{var p=player(s);var bird=(LivingEntity)p.level().getEntity(external);var zombie=EntityTypes.ZOMBIE.create(p.level(),EntitySpawnReason.COMMAND); + check(bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),1)&&CarryPanic.active(p),"External accepted hit also triggers flight"); + p.getAbilities().flying=true;CarryGlide.tick(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Creative flying invalidates propulsion");p.getAbilities().flying=false; + CarryPanic.start(bird);bird.hurtServer(p.level(),p.damageSources().mobAttack(zombie),100);CarryGlide.tick(p);check(!CarryPanic.active(p),"Death stops propulsion");CarryService.detach(p);bird.discard();reset(p);}); + + int ceiling=server.computeOnServer(s->{var p=player(s);var bird=carry(p,EntityTypes.PARROT);for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().setBlockAndUpdate(new BlockPos(x,4,z),Blocks.STONE.defaultBlockState());return bird.getId();}); + c.waitFor(m->m.level.getEntity(ceiling)!=null&&CarryService.carried(m.level.getEntity(ceiling)),100); + server.runOnServer(s->{var p=player(s);((LivingEntity)p.level().getEntity(ceiling)).hurtServer(p.level(),p.damageSources().playerAttack(p),1);});c.waitTicks(12); + server.runOnServer(s->{var p=player(s);check(p.getY()<2&&p.level().noBlockCollision(p,p.getBoundingBox().deflate(.02)),"Player cannot fly through low ceiling");check(!CarryService.carrying(p)&&!CarryPanic.active(p),"Blocked carried animal is safely released"); + p.level().getEntity(ceiling).discard();for(int x=-3;x<=3;x++)for(int z=-3;z<=3;z++)p.level().removeBlock(new BlockPos(x,4,z),false);reset(p); + var egg=new ItemStack(Items.PARROT_SPAWN_EGG);var size=new CompoundTag();size.putInt("schema",1);size.putInt("per_mille",1000);CustomData.update(DataComponents.CUSTOM_DATA,egg,t->t.put(FamiliarSize.KEY,size));AccessoryAccess.of(p).setItem(AccessoryContainer.FAMILIAR,egg);p.inventoryMenu.broadcastChanges();}); + c.waitTicks(25); + int familiar=server.computeOnServer(s->{var p=player(s);var pet=FamiliarService.active(p);check(pet!=null,"Familiar spawned");pet.setNoAi(true);pet.setPos(p.position().add(0,0,2));check(CarryService.interact(p,pet),"Flying familiar picked up");return pet.getId();}); + c.waitFor(m->m.level.getEntity(familiar)!=null&&CarryService.carried(m.level.getEntity(familiar)),100); + float petHealth=server.computeOnServer(s->FamiliarService.active(player(s)).getHealth());attack(c,familiar);c.waitFor(m->CarryPanic.active(m.player),100); + server.runOnServer(s->{var p=player(s);check(FamiliarService.active(p).getHealth()==petHealth&&CarryPanic.active(p),"Friendly familiar tap launches without hurting it");CarryService.detach(p);check(!p.hasAttached(CarryPanic.FLIGHT),"Lifecycle detach clears synchronized flight");}); + System.out.println("CARRY114_PASS random flight, native ordinary/familiar strikes, seven species, outside hits, ground protection, drop/death/creative cancellation, ceiling collisions, timeout and normal glide"); + }finally{intro.set(false);c.getInput().releaseKey(o->o.keyUp);c.getInput().releaseKey(o->o.keyRight);c.getInput().releaseKey(o->o.keyJump);c.getInput().releaseKey(o->o.keyShift);c.getInput().releaseKey(o->o.keyAttack);c.getInput().releaseKey(o->o.keyUse);} + } + private static void attack(ClientGameTestContext c,int id){ + c.waitFor(m->m.level.getEntity(id).position().distanceTo(CarryService.head(m.player,m.level.getEntity(id)))<.01,100); + c.runOnClient(m->{m.options.setCameraType(CameraType.FIRST_PERSON);m.player.lookAt(net.minecraft.commands.arguments.EntityAnchorArgument.Anchor.EYES,m.level.getEntity(id).getBoundingBox().getCenter());});c.waitTicks(3); + c.runOnClient(m->check(m.hitResult instanceof EntityHitResult hit&&hit.getEntity().getId()==id,"Native cursor can pick own carried flyer: hit="+m.hitResult+", target="+m.level.getEntity(id).getBoundingBox()+", eye="+m.player.getEyePosition()+", view="+m.player.getLookAngle()+", eligible="+CarryPanic.canStrike(m.player,m.level.getEntity(id)))); + c.getInput().holdKey(o->o.keyAttack);c.waitTicks(1);c.getInput().releaseKey(o->o.keyAttack); + } + private static Mob carry(ServerPlayer p,EntityType type){var mob=type.create(p.level(),EntitySpawnReason.COMMAND);mob.setNoAi(true);mob.setPos(p.position().add(0,0,2));p.level().addFreshEntity(mob);check(CarryService.interact(p,mob),"Animal picked up: "+type);return mob;} + private static void reset(ServerPlayer p){CarryService.detach(p);p.teleportTo(.5,1,.5);p.setDeltaMovement(Vec3.ZERO);p.resetFallDistance();p.setShiftKeyDown(false);} + private static ServerPlayer player(MinecraftServer s){return s.getPlayerList().getPlayers().getFirst();} + private static KeyEvent enter(){return new KeyEvent(InputConstants.KEY_RETURN,0,0);} + private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryGlide.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryGlide.java index 926674c..a519fad 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryGlide.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryGlide.java @@ -31,6 +31,7 @@ public final class CarryGlide { } public static boolean active(Player p){return supported(p)&&!p.onGround()&&(p.getDeltaMovement().y<=0||lifting(p));} public static void tick(Player p){ + if(CarryPanic.apply(p))return; boolean lift=lifting(p); if(!lift&&!active(p))return; var velocity=p.getDeltaMovement(); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryPanic.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryPanic.java new file mode 100644 index 0000000..8968606 --- /dev/null +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryPanic.java @@ -0,0 +1,62 @@ +package fr.koka.sanctuary.cosmetics; + +import fr.koka.sanctuary.SanctuaryMod; +import net.fabricmc.fabric.api.attachment.v1.*; +import net.fabricmc.fabric.api.entity.event.v1.ServerLivingEntityEvents; +import net.minecraft.network.RegistryFriendlyByteBuf; +import net.minecraft.network.codec.StreamCodec; +import net.minecraft.network.chat.Component; +import net.minecraft.server.level.ServerPlayer; +import net.minecraft.world.entity.*; +import net.minecraft.world.entity.player.Player; +import net.minecraft.world.phys.Vec3; + +/** Server-chosen, temporary flight; both sides use the same bounded native movement. */ +public final class CarryPanic { + public record Flight(int animal,long start,int duration,float yaw,float phase){ + static final StreamCodec CODEC=StreamCodec.of( + (b,f)->{b.writeVarInt(f.animal);b.writeLong(f.start);b.writeVarInt(f.duration);b.writeFloat(f.yaw);b.writeFloat(f.phase);}, + b->new Flight(b.readVarInt(),b.readLong(),b.readVarInt(),b.readFloat(),b.readFloat())); + public Vec3 velocity(long now){ + double t=Math.clamp(now-start,0,duration),angle=yaw+Math.sin(t*.27+phase)*.65+Math.sin(t*.61)*.18; + double speed=.28+.05*Math.sin(t*.23+phase); + double rise=t<5?.24:.12+.045*Math.sin(t*.32+phase); + double fade=Math.min(1,(duration-t)/8.0); + return new Vec3(-Math.sin(angle)*speed*fade,rise*fade,Math.cos(angle)*speed*fade); + } + } + public static final AttachmentType FLIGHT=AttachmentRegistry.create(SanctuaryMod.id("carry_panic"), + b->b.syncWith(Flight.CODEC,AttachmentSyncPredicate.all())); + private CarryPanic(){} + public static void register(){ + ServerLivingEntityEvents.AFTER_DAMAGE.register((entity,source,base,taken,blocked)->{ + if(taken>0&&!blocked)start(entity); + }); + } + public static boolean canStrike(Entity actor,Entity target){ + return actor instanceof Player p&&!p.isSpectator()&&p.isAlive()&&target instanceof Mob + &&target.getVehicle()==p&&CarryService.carried(target)&&CarryGlide.flyer(target); + } + public static void start(Entity animal){ + if(!(animal.getVehicle() instanceof ServerPlayer p)||!CarryService.carried(animal)||!CarryGlide.flyer(animal)||!CarryGlide.supported(p))return; + long now=p.level().getGameTime();var previous=p.getAttached(FLIGHT); + if(previous!=null&&now-previous.start()<10)return; + var random=p.getRandom(); + var flight=new Flight(animal.getId(),now,40+random.nextInt(21),random.nextFloat()*(float)(Math.PI*2),random.nextFloat()*(float)(Math.PI*2)); + p.setAttached(FLIGHT,flight);apply(p);p.syncVelocity=true; + p.sendOverlayMessage(Component.translatable("sanctuary.carry.panic")); + } + public static boolean active(Player p){ + var flight=p.getAttached(FLIGHT);if(flight==null||!CarryGlide.supported(p))return false; + long elapsed=p.level().getGameTime()-flight.start(); + return elapsed>=0&&elapsede.getId()==flight.animal()&&CarryService.carried(e)&&CarryGlide.flyer(e)); + } + public static boolean apply(Player p){ + if(!active(p)){ + if(!p.level().isClientSide()&&p.hasAttached(FLIGHT))p.removeAttached(FLIGHT); + return false; + } + p.setDeltaMovement(p.getAttached(FLIGHT).velocity(p.level().getGameTime()));p.resetFallDistance();return true; + } + public static void clear(Player p){if(p.hasAttached(FLIGHT))p.removeAttached(FLIGHT);} +} diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryService.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryService.java index 2a63974..201efd8 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryService.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/CarryService.java @@ -46,6 +46,7 @@ public final class CarryService { &&!fr.koka.sanctuary.boats.BoatPassengerInteractions.aboardTogether(a,b) &&!fr.koka.sanctuary.boats.BoatPassengerInteractions.aboardTogether(b,a));} private static Entity root(Entity e){while(carried(e))e=e.getVehicle();return e;} + public static boolean blocksAttack(Entity attacker,Entity target){return pair(attacker,target)&&!CarryPanic.canStrike(attacker,target);} /** One seat per head; the chain includes babies even though they add no carrying penalty. */ private static Entity passenger(Entity e){ return e instanceof Player&&e.getPassengers().size()==1&&carried(e.getFirstPassenger())?e.getFirstPassenger():null; @@ -60,6 +61,7 @@ public final class CarryService { }; } public static void register(){ + CarryPanic.register(); net.fabricmc.fabric.api.networking.v1.PayloadTypeRegistry.serverboundPlay().register(Drop.TYPE,Drop.CODEC); net.fabricmc.fabric.api.networking.v1.ServerPlayNetworking.registerGlobalReceiver(Drop.TYPE,(request,context)->context.server().execute(()->{ if(active(context.player()))for(var passenger:List.copyOf(context.player().getPassengers()))if(carried(passenger))release(passenger,true); @@ -72,7 +74,7 @@ public final class CarryService { if(!success)player.sendOverlayMessage(Component.translatable("sanctuary.carry.unavailable")); return InteractionResult.SUCCESS; }); - AttackEntityCallback.EVENT.register((p,level,hand,target,hit)->pair(p,target)?InteractionResult.FAIL:InteractionResult.PASS); + AttackEntityCallback.EVENT.register((p,level,hand,target,hit)->blocksAttack(p,target)?InteractionResult.FAIL:InteractionResult.PASS); ServerTickEvents.END_SERVER_TICK.register(s->{ for(var rider:new ArrayList<>(RIDES.keySet())){ var ride=RIDES.get(rider);if(ride==null||ride.carrier.level().getServer()!=s)continue; @@ -187,7 +189,7 @@ public final class CarryService { public static boolean mayDismount(Player p){var ride=RIDES.get(p);return ride==null||ride.releasedShift;} public static void removed(Entity rider){ var ride=RIDES.remove(rider);rider.removeAttached(CARRIED); - if(ride!=null){refreshBelow(ride.carrier);ride.carrier.connection.send(new net.minecraft.network.protocol.game.ClientboundSetPassengersPacket(ride.carrier));} + if(ride!=null){CarryPanic.clear(ride.carrier);refreshBelow(ride.carrier);ride.carrier.connection.send(new net.minecraft.network.protocol.game.ClientboundSetPassengersPacket(ride.carrier));} } public static boolean release(Entity rider,boolean place){ var ride=RIDES.get(rider);if(ride==null){rider.removeAttached(CARRIED);return false;} @@ -209,6 +211,7 @@ public final class CarryService { return true; } public static void detach(ServerPlayer p){ + CarryPanic.clear(p); // Put down the upper group before removing its support, without orphaning its riders. for(var e:List.copyOf(p.getPassengers()))if(carried(e))release(e,true); if(RIDES.containsKey(p))release(p,true); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java index 73e76c2..d37a837 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarEntity.java @@ -245,7 +245,7 @@ public final class FamiliarEntity extends PathfinderMob { return super.causeFallDamage(distance,multiplier,source); } @Override public boolean hurtServer(ServerLevel level,DamageSource source,float amount) { - boolean hurt=fr.koka.sanctuary.familiar.FamiliarBattle.hurt(this,source,amount);if(hurt)playTapSound(source);return hurt; + boolean hurt=fr.koka.sanctuary.familiar.FamiliarBattle.hurt(this,source,amount);if(hurt){playTapSound(source);CarryPanic.start(this);}return hurt; } @Override public boolean isIgnoringBlockTriggers() {return true;} @Override public boolean canBeHitByProjectile() {return true;} @@ -316,11 +316,12 @@ public final class FamiliarEntity extends PathfinderMob { } /** Native feedback without damage callbacks, enchantments, aggro or durability. */ public void tap(ServerPlayer player) { - if(hurtTime>0||!isAlive()||player.level()!=level()||player.isSpectator()||CarryService.pair(player,this))return; + if(hurtTime>0||!isAlive()||player.level()!=level()||player.isSpectator()||CarryService.blocksAttack(player,this))return; var source=damageSources().playerAttack(player); if(!HeadEquipment.get(this).isEmpty()){MobHeadActions.activate(this,source,true);} animateHurt(player.getYRot()); ((ServerLevel)level()).broadcastDamageEvent(this,source); + if(CarryPanic.canStrike(player,this)){CarryPanic.start(this);playTapSound(source);return;} double dx=player.getX()-getX(),dz=player.getZ()-getZ(); if(dx*dx+dz*dz<1.0E-5){var look=player.getLookAngle();dx=-look.x;dz=-look.z;} knockback(.4,dx,dz,source,0); diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarService.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarService.java index 4347a09..1e5ff43 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarService.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/cosmetics/FamiliarService.java @@ -29,7 +29,7 @@ public final class FamiliarService { net.fabricmc.fabric.api.event.player.AttackEntityCallback.EVENT.register((player,level,hand,target,hit)->{ if(!(target instanceof FamiliarEntity pet)||player.isSpectator())return net.minecraft.world.InteractionResult.PASS; if(pet.owner()!=null&&fr.koka.sanctuary.familiar.FamiliarDuels.fighting(pet.owner()))return net.minecraft.world.InteractionResult.FAIL; - if(CarryService.pair(player,pet))return net.minecraft.world.InteractionResult.FAIL; + if(CarryService.blocksAttack(player,pet))return net.minecraft.world.InteractionResult.FAIL; if(player instanceof ServerPlayer attacker&&fr.koka.sanctuary.familiar.FamiliarBattle.enemy(attacker,pet))return net.minecraft.world.InteractionResult.PASS; if(player instanceof ServerPlayer p&&p.isWithinEntityInteractionRange(pet,0)&&p.hasLineOfSight(pet))pet.tap(p); return net.minecraft.world.InteractionResult.SUCCESS; diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryFlightConnectionMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryFlightConnectionMixin.java index f914174..9fd5d63 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryFlightConnectionMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryFlightConnectionMixin.java @@ -7,13 +7,13 @@ import org.spongepowered.asm.mixin.*; import org.spongepowered.asm.mixin.injection.*; import org.spongepowered.asm.mixin.injection.callback.CallbackInfo; -/** Sustained lift is legitimate only while the server sees the equipped, carried large dragon. */ +/** Flight exemption is limited to server-validated dragon lift or a carried flyer's brief panic. */ @Mixin(ServerGamePacketListenerImpl.class) public abstract class CarryFlightConnectionMixin { @Shadow public ServerPlayer player; @Shadow private boolean clientIsFloating; @Inject(method="tick",at=@At("HEAD")) private void sanctuary$carriedFlight(CallbackInfo ci){ - if(CarryGlide.lifting(player))clientIsFloating=false; + if(CarryGlide.lifting(player)||fr.koka.sanctuary.cosmetics.CarryPanic.active(player))clientIsFloating=false; } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryGlideMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryGlideMixin.java index a7b6e85..4d50ea0 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryGlideMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryGlideMixin.java @@ -8,6 +8,8 @@ import org.spongepowered.asm.mixin.injection.*; import org.spongepowered.asm.mixin.injection.callback.*; @Mixin(LivingEntity.class) public abstract class CarryGlideMixin { + @ModifyVariable(method="travelInAir",at=@At("HEAD"),argsOnly=true) + private Vec3 sanctuary$panicSteering(Vec3 input){return (Object)this instanceof Player p&&fr.koka.sanctuary.cosmetics.CarryPanic.active(p)?Vec3.ZERO:input;} @Inject(method="travelInAir",at={@At("HEAD"),@At("TAIL")}) private void sanctuary$glide(Vec3 input,CallbackInfo ci){if((Object)this instanceof Player p)CarryGlide.tick(p);} @Inject(method="getEffectiveGravity",at=@At("RETURN"),cancellable=true) diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryLivingMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryLivingMixin.java index 25e87c9..8f5a2af 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryLivingMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryLivingMixin.java @@ -16,6 +16,6 @@ public abstract class CarryLivingMixin { } @Inject(method="hurtServer",at=@At("HEAD"),cancellable=true) private void sanctuary$protectPair(ServerLevel level,DamageSource source,float damage,CallbackInfoReturnable ci){ - if(CarryService.pair((Entity)(Object)this,source.getEntity()))ci.setReturnValue(false); + if(CarryService.blocksAttack(source.getEntity(),(Entity)(Object)this))ci.setReturnValue(false); } } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryPlayerMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryPlayerMixin.java index 8282a45..6da9b3c 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryPlayerMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/CarryPlayerMixin.java @@ -16,5 +16,5 @@ public abstract class CarryPlayerMixin { if(p.getVehicle() instanceof fr.koka.sanctuary.cosmetics.FamiliarEntity pet&&!pet.mayDismount())ci.setReturnValue(false); } @Inject(method="attack",at=@At("HEAD"),cancellable=true) - private void sanctuary$noPassengerAttack(Entity target,CallbackInfo ci){if(CarryService.pair((Entity)(Object)this,target))ci.cancel();} + private void sanctuary$noPassengerAttack(Entity target,CallbackInfo ci){if(CarryService.blocksAttack((Entity)(Object)this,target))ci.cancel();} } diff --git a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/BoatPassengerPickMixin.java b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/BoatPassengerPickMixin.java index bda6db0..94f8405 100644 --- a/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/BoatPassengerPickMixin.java +++ b/mods/sanctuary/src/main/java/fr/koka/sanctuary/mixin/client/BoatPassengerPickMixin.java @@ -15,7 +15,8 @@ public abstract class BoatPassengerPickMixin { at=@At(value="INVOKE",target="Lnet/minecraft/world/entity/Entity;getRootVehicle()Lnet/minecraft/world/entity/Entity;")) private static Entity sanctuary$pickSeatedMob(Entity candidate, Operation original, Entity source) { // Only the player's cursor: projectile collisions and native range/occlusion stay unchanged. - return source instanceof LocalPlayer && BoatPassengerInteractions.aboardTogether(source,candidate) + return source instanceof LocalPlayer && (BoatPassengerInteractions.aboardTogether(source,candidate) + ||fr.koka.sanctuary.cosmetics.CarryPanic.canStrike(source,candidate)) ? candidate : original.call(candidate); } } diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json index a6c814f..23d0277 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/en_us.json @@ -1013,6 +1013,7 @@ "sanctuary.companion.rename_hint": "Rename its egg at an anvil, or use a name tag on your familiar.", "sanctuary.companion.interactions_version_required": "Sanctuary beta.020 is required on both client and server.", "sanctuary.carry.unavailable": "No free space to carry or put down here.", + "sanctuary.carry.panic": "The animal panics and carries you away!", "sanctuary.companion.tooltip_size": "Size: %s", "sanctuary.companion.size.tiny": "Tiny", "sanctuary.companion.size.small": "Small", diff --git a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json index 10f24d1..eeb720c 100644 --- a/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json +++ b/mods/sanctuary/src/main/resources/assets/sanctuary/lang/fr_fr.json @@ -1013,6 +1013,7 @@ "sanctuary.companion.rename_hint": "Renomme son œuf à l’enclume, ou utilise une étiquette sur lui.", "sanctuary.companion.interactions_version_required": "Sanctuary beta.020 est requis côté client et serveur.", "sanctuary.carry.unavailable": "Pas de place libre pour porter ou déposer ici.", + "sanctuary.carry.panic": "L’animal s’affole et vous entraîne !", "sanctuary.companion.tooltip_size": "Taille : %s", "sanctuary.companion.size.tiny": "Minuscule", "sanctuary.companion.size.small": "Petit", diff --git a/packwiz/pack.toml b/packwiz/pack.toml index 08b05e1..fbc5dff 100644 --- a/packwiz/pack.toml +++ b/packwiz/pack.toml @@ -1,6 +1,6 @@ name = "Sanctuary" author = "KOKA99CAB" -version = "beta.113" +version = "beta.114" pack-format = "packwiz:1.1.0" [index]