diff --git a/PLAYTESTING.md b/PLAYTESTING.md index 55fdf12..2648c40 100644 --- a/PLAYTESTING.md +++ b/PLAYTESTING.md @@ -149,6 +149,7 @@ Verifica specifica delle strutture aggiunte e aggiornate di recente. - [ ] Ragni (`minecraft:spider` / `minecraft:cave_spider`) - [ ] **Casse con Loot:** Le casse accanto agli spawner contengono il loot della tabella `twin_towers.json` (ferro, oro, smeraldi, diamanti, mele d'oro, libri incantati, falafel). - [ ] Nessun blocco di arenaria compare nella struttura. +- [ ] **Aereo ogni minuto:** con un giocatore entro 160 blocchi, ogni 60 s parte un aereo da fuori della struttura (48-96 blocchi oltre il bordo), diretto al centro di una delle due torri scelta a caso; colpisce la torre ed esplode. Mai più di un aereo in volo per struttura; se non colpisce sparisce dopo 30 s. Esplosioni, fuoco e rottami restano dentro la torre colpita. Comandi: `/israelsim event plane_crash start|stop`. ### 3.2 Striscia di Gaza (`israel_simulator:gaza_strip`) * **Comando:** `/locate structure israel_simulator:gaza_strip` (solo nel bioma `mediterranean_coast`) diff --git a/README.md b/README.md index 90d3438..2776ba4 100644 --- a/README.md +++ b/README.md @@ -132,7 +132,7 @@ When walking through Tel Aviv, players traverse **6 functional districts**, each - **First Amendment** *(LEGENDARY)*: High-tier artifact granting speech and immunity effects. - **Hava Nagila Music Disc** *(LEGENDARY)*: Custom music disc with full jukebox audio integration. - **Bibi Boss Battle**: Satirical boss entity with custom AI goals, armed security guards, speech sound effects, and unique endgame drops. -- **Other encounters**: further satirical bosses and minibosses, Mossad agents, a plane-crash world event and regional wildlife (gazelle, ibex, hyrax). See the [wiki](wiki/Home.md). +- **Other encounters**: further satirical bosses and minibosses, Mossad agents, a plane-crash world event (every minute a plane flies at one of the `twin_towers` when a player is within 160 blocks) and regional wildlife (gazelle, ibex, hyrax). See the [wiki](wiki/Home.md). --- diff --git a/src/main/java/com/israelsimulator/entity/event/PlaneCrashEntity.java b/src/main/java/com/israelsimulator/entity/event/PlaneCrashEntity.java index e8d4953..0340f63 100644 --- a/src/main/java/com/israelsimulator/entity/event/PlaneCrashEntity.java +++ b/src/main/java/com/israelsimulator/entity/event/PlaneCrashEntity.java @@ -15,7 +15,10 @@ import net.minecraft.world.entity.Entity; import net.minecraft.world.entity.EntityType; import net.minecraft.world.entity.MoverType; +import net.minecraft.world.level.BlockGetter; import net.minecraft.world.level.ClipContext; +import net.minecraft.world.level.Explosion; +import net.minecraft.world.level.ExplosionDamageCalculator; import net.minecraft.world.level.Level; import net.minecraft.world.level.block.Blocks; import net.minecraft.world.level.block.state.BlockState; @@ -192,7 +195,7 @@ public void tick() { // Timeout: if plane doesn't hit any solid block after 30 seconds (600 ticks), despawn cleanly if (flightTicks >= MAX_FLIGHT_TICKS) { - TwinTowersPlaneEvent.stopEvent(); + TwinTowersPlaneEvent.onPlaneGone(this.getUUID()); this.discard(); } } @@ -287,22 +290,22 @@ private void detonatePlaneCrash(ServerLevel serverLevel) { Vec3 deepCore = nosePos.add(dir.scale(10.0)); // 1. Massive 10x multi-point fuel-air airliner explosion tearing through the facade and interior - serverLevel.explode(this, wallImpact.x, wallImpact.y, wallImpact.z, 28.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, nosePos.x, nosePos.y, nosePos.z, 26.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, midPos.x, midPos.y, midPos.z, 25.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, deepCore.x, deepCore.y, deepCore.z, 25.0F, true, Level.ExplosionInteraction.BLOCK); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y, wallImpact.z, 28.0F); + explodeInTower(serverLevel, nosePos.x, nosePos.y, nosePos.z, 26.0F); + explodeInTower(serverLevel, midPos.x, midPos.y, midPos.z, 25.0F); + explodeInTower(serverLevel, deepCore.x, deepCore.y, deepCore.z, 25.0F); // 2. Wing-span fuel tank explosions tearing across the entire facade - serverLevel.explode(this, wallImpact.x, wallImpact.y, wallImpact.z + 7.0, 22.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, wallImpact.x, wallImpact.y, wallImpact.z - 7.0, 22.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, wallImpact.x, wallImpact.y, wallImpact.z + 14.0, 20.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, wallImpact.x, wallImpact.y, wallImpact.z - 14.0, 20.0F, true, Level.ExplosionInteraction.BLOCK); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y, wallImpact.z + 7.0, 22.0F); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y, wallImpact.z - 7.0, 22.0F); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y, wallImpact.z + 14.0, 20.0F); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y, wallImpact.z - 14.0, 20.0F); // 3. Multi-floor vertical rupture (obliterating 8-10 floors above and below) - serverLevel.explode(this, wallImpact.x, wallImpact.y + 8.0, wallImpact.z, 22.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, wallImpact.x, wallImpact.y - 7.0, wallImpact.z, 22.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, wallImpact.x, wallImpact.y + 16.0, wallImpact.z, 20.0F, true, Level.ExplosionInteraction.BLOCK); - serverLevel.explode(this, wallImpact.x, wallImpact.y - 14.0, wallImpact.z, 18.0F, true, Level.ExplosionInteraction.BLOCK); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y + 8.0, wallImpact.z, 22.0F); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y - 7.0, wallImpact.z, 22.0F); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y + 16.0, wallImpact.z, 20.0F); + explodeInTower(serverLevel, wallImpact.x, wallImpact.y - 14.0, wallImpact.z, 18.0F); // 4. Catastrophic visual effects (massive mushroom cloud, firestorm and lava embers) serverLevel.sendParticles(ParticleTypes.EXPLOSION_EMITTER, wallImpact.x, wallImpact.y, wallImpact.z, 15, 6.0, 6.0, 6.0, 0.0); @@ -321,7 +324,7 @@ private void detonatePlaneCrash(ServerLevel serverLevel) { for (int oz = -10; oz <= 10; oz++) { if (serverLevel.getRandom().nextFloat() < 0.35F) { BlockPos firePos = centerImpact.offset(ox, oy, oz); - if (serverLevel.getBlockState(firePos).isAir() && !serverLevel.getBlockState(firePos.below()).isAir()) { + if (isInsideTower(firePos) && serverLevel.getBlockState(firePos).isAir() && !serverLevel.getBlockState(firePos.below()).isAir()) { serverLevel.setBlock(firePos, Blocks.FIRE.defaultBlockState(), 3); } } @@ -339,7 +342,33 @@ private void detonatePlaneCrash(ServerLevel serverLevel) { serverLevel.playSound(null, wallImpact.x, wallImpact.y, wallImpact.z, SoundEvents.GENERIC_EXPLODE.value(), SoundSource.BLOCKS, 20.0F, 1.1F); - TwinTowersPlaneEvent.onPlaneCrashImpact(serverLevel, nosePos); + TwinTowersPlaneEvent.onPlaneCrashImpact(serverLevel, nosePos, this.getUUID()); + } + + /** True when {@code pos} lies inside the targeted tower; nothing outside it may be broken or set on fire. */ + public boolean isInsideTower(BlockPos pos) { + if (towerMin.equals(towerMax)) { + return false; // no tower known (e.g. plane reloaded from disk): never touch blocks + } + return pos.getX() >= Math.min(towerMin.getX(), towerMax.getX()) && pos.getX() <= Math.max(towerMin.getX(), towerMax.getX()) + && pos.getY() >= Math.min(towerMin.getY(), towerMax.getY()) && pos.getY() <= Math.max(towerMin.getY(), towerMax.getY()) + && pos.getZ() >= Math.min(towerMin.getZ(), towerMax.getZ()) && pos.getZ() <= Math.max(towerMin.getZ(), towerMax.getZ()); + } + + /** Explosion that damages entities normally but only breaks (and ignites) blocks inside the targeted tower. */ + private void explodeInTower(ServerLevel serverLevel, double x, double y, double z, float radius) { + ExplosionDamageCalculator towerOnly = new ExplosionDamageCalculator() { + @Override + public boolean shouldBlockExplode(Explosion explosion, BlockGetter level, BlockPos pos, BlockState state, float power) { + return isInsideTower(pos); + } + }; + serverLevel.explode(this, null, towerOnly, x, y, z, radius, true, Level.ExplosionInteraction.BLOCK); + } + + @Override + public boolean shouldBeSaved() { + return false; // a plane reloaded from disk would have lost its flight path } @Override @@ -354,6 +383,9 @@ private void placeWreckageBlocks(ServerLevel level) { for (PlaneModelData.BlockEntry entry : blocks) { if (count++ < 500) { BlockPos target = center.offset(entry.dx(), entry.dy(), entry.dz()); + if (!isInsideTower(target)) { + continue; + } BlockState current = level.getBlockState(target); if (current.isAir() || current.is(Blocks.GLASS) || current.is(Blocks.STAINED_GLASS.gray()) || current.is(Blocks.CONCRETE.lightGray())) { diff --git a/src/main/java/com/israelsimulator/event/world/plane/PlaneCrashCommands.java b/src/main/java/com/israelsimulator/event/world/plane/PlaneCrashCommands.java index 9071547..161b501 100644 --- a/src/main/java/com/israelsimulator/event/world/plane/PlaneCrashCommands.java +++ b/src/main/java/com/israelsimulator/event/world/plane/PlaneCrashCommands.java @@ -32,7 +32,7 @@ public static void register(CommandDispatcher dispatcher) { ctx.getSource().sendFailure(Component.literal("§cNessun evento aereo attivo al momento.")); return 0; } - TwinTowersPlaneEvent.stopEvent(); + TwinTowersPlaneEvent.stopEvent(ctx.getSource().getLevel()); ctx.getSource().sendSuccess(() -> Component.literal("§aEvento aereo arrestato."), true); return 1; })))) @@ -46,7 +46,7 @@ public static void register(CommandDispatcher dispatcher) { private static int start(CommandContext ctx, BlockPos pos) { ServerLevel level = ctx.getSource().getLevel(); if (!TwinTowersPlaneEvent.startEvent(level, pos)) { - ctx.getSource().sendFailure(Component.literal("§cImpossibile avviare l'evento: un evento è già in corso.")); + ctx.getSource().sendFailure(Component.literal("§cImpossibile avviare l'evento: nessuna twin_towers caricata vicino, aereo già in volo o nessun punto di partenza caricato.")); return 0; } ctx.getSource().sendSuccess(() -> Component.literal("§6Allarme antiaereo attivato! Aereo in arrivo verso le coordinate " + pos.toShortString()), true); diff --git a/src/main/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEvent.java b/src/main/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEvent.java index 6253924..c640503 100644 --- a/src/main/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEvent.java +++ b/src/main/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEvent.java @@ -4,55 +4,87 @@ import com.israelsimulator.registry.ModEntities; import com.israelsimulator.registry.ModSoundEvents; import com.israelsimulator.world.structure.ModStructures; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; import java.util.UUID; import net.minecraft.core.BlockPos; +import net.minecraft.core.SectionPos; +import net.minecraft.core.registries.Registries; import net.minecraft.network.chat.Component; import net.minecraft.network.protocol.game.ClientboundSetSubtitleTextPacket; import net.minecraft.network.protocol.game.ClientboundSetTitleTextPacket; import net.minecraft.network.protocol.game.ClientboundSetTitlesAnimationPacket; import net.minecraft.server.level.ServerLevel; import net.minecraft.server.level.ServerPlayer; -import net.minecraft.sounds.SoundEvents; import net.minecraft.sounds.SoundSource; +import net.minecraft.util.Mth; +import net.minecraft.util.RandomSource; +import net.minecraft.world.entity.Entity; +import net.minecraft.world.level.ChunkPos; import net.minecraft.world.level.Level; -import net.minecraft.world.level.biome.Biomes; +import net.minecraft.world.level.block.Mirror; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.chunk.ChunkAccess; +import net.minecraft.world.level.chunk.LevelChunk; +import net.minecraft.world.level.chunk.status.ChunkStatus; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.level.levelgen.structure.PoolElementStructurePiece; +import net.minecraft.world.level.levelgen.structure.Structure; +import net.minecraft.world.level.levelgen.structure.StructurePiece; import net.minecraft.world.level.levelgen.structure.StructureStart; +import net.minecraft.world.level.levelgen.structure.templatesystem.StructureTemplate; import net.minecraft.world.phys.Vec3; /** - * Event controller for the Twin Towers Plane Crash in the Plains (GAME_DESIGN.md §41–44). - * Periodically triggers with a chance when a player is inside the Twin Towers in the plains. - * Sounds air raid siren, counts down, launches the plane, and detonates TNT inside the elevator shaft. + * Twin Towers plane event (GAME_DESIGN.md §41–44). + * + *

Every minute (1200 ticks), every generated {@code twin_towers} structure with a player within + * {@link #PLAYER_RADIUS} blocks gets one plane. The plane spawns on a ring outside the whole structure, + * aimed straight at the center of one of the two towers (picked at random), and explodes when it hits + * the facade. Only one plane per structure is in the air at a time. Explosions, fire and wreckage only + * touch blocks inside the targeted tower (see {@link PlaneCrashEntity}). */ public final class TwinTowersPlaneEvent { public static final int SCHEDULE_INTERVAL = 1200; // 60 seconds - public static final float SCHEDULE_CHANCE = 0.35F; // 35% chance per interval - public static final int COUNTDOWN_SECONDS = 10; - - // Twin towers dimensions from twin_towers.nbt / towers.schem (74 x 200 x 71) + public static final int PLAYER_RADIUS = 160; + public static final int ALERT_RADIUS = 256; + /** Ticks the plane needs to reach the tower from its spawn point (~10 s). */ + public static final int APPROACH_TICKS = 200; + public static final double RING_MARGIN_MIN = 48.0; + public static final double RING_MARGIN_MAX = 96.0; + public static final double HEIGHT_FRACTION_MIN = 0.40; + public static final double HEIGHT_FRACTION_MAX = 0.85; + + // Twin towers template size from twin_towers.nbt (74 x 200 x 71) public static final int TOWER_WIDTH_X = 74; public static final int TOWER_HEIGHT = 195; public static final int TOWER_LENGTH_Z = 71; - // Manually set or tracked towers origin position in the world + /** + * Footprints of the two towers in template-local coordinates (unrotated), measured from twin_towers.nbt: + * {minX, minY, minZ, maxX, maxY, maxZ}. + */ + static final int[][] TOWER_LOCAL_BOXES = { + {5, 0, 5, 29, 177, 29}, + {33, 0, 42, 57, 178, 66} + }; + + // Manually set origin for towers built by hand (/israelsim towers setpos); unrotated template layout. private static BlockPos towersOrigin = null; - // Runtime state - private static boolean active = false; - private static UUID activePlaneId = null; - private static int countdownTicks = -1; - private static int lastCountdownSecond = -1; - private static BlockPos targetElevatorShaft = null; - private static BlockPos targetTowerMin = null; - private static BlockPos targetTowerMax = null; - private static Vec3 targetImpactPos = Vec3.ZERO; - private static long lastTriggerTick = -1L; + /** Active plane per structure, keyed by the structure start chunk (or the manual origin). */ + private static final Map ACTIVE_PLANES = new HashMap<>(); private TwinTowersPlaneEvent() {} + /** Target geometry of one structure: the whole structure box plus the two tower boxes. */ + public record Towers(long key, BoundingBox structureBox, BoundingBox[] towers) {} + public static boolean isActive() { - return active; + return !ACTIVE_PLANES.isEmpty(); } public static void setTowersOrigin(BlockPos origin) { @@ -63,218 +95,310 @@ public static BlockPos getTowersOrigin() { return towersOrigin; } - /** - * Periodic tick called from ModGameEvents onLevelTick. - */ + /** Called every level tick from ModGameEvents. */ public static void tick(ServerLevel level) { - // Twin Towers are in the Overworld; prevent ticking multiple times per game tick in Nether/End if (level.dimension() != Level.OVERWORLD) { return; } - - long now = level.getGameTime(); - - if (active) { - tickActiveEvent(level); + if (level.getGameTime() % SCHEDULE_INTERVAL != 0) { return; } - - // Try schedule every 1200 ticks - if (now % SCHEDULE_INTERVAL == 0) { - trySchedule(level, now); + cleanup(level); + for (Towers towers : findTowersNearPlayers(level)) { + launchPlane(level, towers); } } - private static void trySchedule(ServerLevel level, long now) { - if (active) { - return; - } - if (lastTriggerTick > 0 && now - lastTriggerTick < 6000L) { // 5 minutes cooldown - return; - } + /** Manual trigger used by {@code /israelsim event plane_crash start}. */ + public static boolean startEvent(ServerLevel level, BlockPos nearPos) { + cleanup(level); + Towers towers = findTowersNear(level, nearPos); + return towers != null && launchPlane(level, towers); + } - for (ServerPlayer player : level.players()) { - if (isPlayerInsideTowers(level, player)) { - if (level.getRandom().nextFloat() < SCHEDULE_CHANCE) { - startEvent(level, player.blockPosition()); - lastTriggerTick = now; - return; + // ---------------------------------------------------------------- structure lookup + + private static List findTowersNearPlayers(ServerLevel level) { + Structure structure = twinTowersStructure(level); + Map found = new HashMap<>(); + if (structure != null) { + for (ServerPlayer player : level.players()) { + if (player.isSpectator()) continue; + collectStarts(level, structure, player.getBlockX(), player.getBlockZ(), found); + } + } + // keep only structures really within PLAYER_RADIUS of some player + List result = new ArrayList<>(); + for (Towers t : found.values()) { + for (ServerPlayer player : level.players()) { + if (!player.isSpectator() && horizontalDistanceToBox(t.structureBox(), player.getX(), player.getZ()) <= PLAYER_RADIUS) { + result.add(t); + break; } } } + return result; } - public static boolean isPlayerInsideTowers(ServerLevel level, ServerPlayer player) { - BlockPos pos = player.blockPosition(); - - // 1. Check generated world structure if player is inside it - StructureStart start = level.structureManager().getStructureWithPieceAt(pos, - h -> h.is(ModStructures.TWIN_TOWERS)); - if (start != null && start.isValid() && !start.getPieces().isEmpty()) { - var box = start.getBoundingBox(); - towersOrigin = new BlockPos(box.minX(), box.minY(), box.minZ()); - return true; + private static Towers findTowersNear(ServerLevel level, BlockPos pos) { + Structure structure = twinTowersStructure(level); + if (structure != null) { + Map found = new HashMap<>(); + collectStarts(level, structure, pos.getX(), pos.getZ(), found); + Towers best = null; + double bestDist = Double.MAX_VALUE; + for (Towers t : found.values()) { + double d = horizontalDistanceToBox(t.structureBox(), pos.getX() + 0.5, pos.getZ() + 0.5); + if (d < bestDist) { + bestDist = d; + best = t; + } + } + if (best != null) { + return best; + } } - - // 2. Check manually configured or cached origin bounding box if (towersOrigin != null) { - return pos.getX() >= towersOrigin.getX() && pos.getX() <= towersOrigin.getX() + TOWER_WIDTH_X - && pos.getY() >= towersOrigin.getY() && pos.getY() <= towersOrigin.getY() + TOWER_HEIGHT - && pos.getZ() >= towersOrigin.getZ() && pos.getZ() <= towersOrigin.getZ() + TOWER_LENGTH_Z; + return fromLayout(Long.MIN_VALUE, towersOrigin, Rotation.NONE); } + return null; + } - return false; + private static Structure twinTowersStructure(ServerLevel level) { + return level.registryAccess().lookupOrThrow(Registries.STRUCTURE).getValue(ModStructures.TWIN_TOWERS); } - public static boolean startEvent(ServerLevel level, BlockPos nearPos) { - if (active) return false; - - // Auto-locate structure or use base pos - StructureStart start = level.structureManager().getStructureWithPieceAt(nearPos, - h -> h.is(ModStructures.TWIN_TOWERS)); - BlockPos base; - if (start != null && start.isValid() && !start.getPieces().isEmpty()) { - var box = start.getBoundingBox(); - base = new BlockPos(box.minX(), box.minY(), box.minZ()); - towersOrigin = base; - } else if (towersOrigin != null) { - base = towersOrigin; - } else { - base = new BlockPos(nearPos.getX() - 35, nearPos.getY(), nearPos.getZ() - 35); - towersOrigin = base; + /** Collects twin_towers starts referenced by loaded chunks within PLAYER_RADIUS of (x, z). Never loads chunks. */ + private static void collectStarts(ServerLevel level, Structure structure, int x, int z, Map out) { + int radius = SectionPos.blockToSectionCoord(PLAYER_RADIUS) + 1; + int ccx = SectionPos.blockToSectionCoord(x); + int ccz = SectionPos.blockToSectionCoord(z); + for (int dx = -radius; dx <= radius; dx++) { + for (int dz = -radius; dz <= radius; dz++) { + LevelChunk chunk = level.getChunkSource().getChunkNow(ccx + dx, ccz + dz); + if (chunk == null) continue; + for (long ref : chunk.getReferencesForStructure(structure)) { + if (out.containsKey(ref)) continue; + ChunkPos startPos = ChunkPos.unpack(ref); + ChunkAccess startChunk = level.getChunk(startPos.x(), startPos.z(), ChunkStatus.STRUCTURE_STARTS, false); + if (startChunk == null) continue; + StructureStart start = startChunk.getStartForStructure(structure); + if (start == null || !start.isValid() || start.getPieces().isEmpty()) continue; + Towers towers = fromStart(ref, start); + if (towers != null) out.put(ref, towers); + } + } } + } - // Randomly select North Tower or South Tower - boolean targetNorthTower = level.getRandom().nextBoolean(); - // Random impact height between floor 25 and floor 80 of the tower - double impactY = base.getY() + 55.0 + level.getRandom().nextDouble() * 85.0; - - if (targetNorthTower) { - targetTowerMin = base; - targetTowerMax = base.offset(35, TOWER_HEIGHT, 35); - targetElevatorShaft = base.offset(17, 0, 17); - targetImpactPos = new Vec3(base.getX() + 17.5, impactY, base.getZ() + 17.5); + static Towers fromStart(long key, StructureStart start) { + for (StructurePiece piece : start.getPieces()) { + if (piece instanceof PoolElementStructurePiece pool) { + Towers t = fromLayout(key, pool.getPosition(), pool.getRotation()); + return new Towers(key, start.getBoundingBox(), t.towers()); + } + } + // Unknown piece type: split the structure box in two halves along its longer side + BoundingBox box = start.getBoundingBox(); + BoundingBox a, b; + if (box.getXSpan() >= box.getZSpan()) { + int mid = (box.minX() + box.maxX()) / 2; + a = new BoundingBox(box.minX(), box.minY(), box.minZ(), mid, box.maxY(), box.maxZ()); + b = new BoundingBox(mid + 1, box.minY(), box.minZ(), box.maxX(), box.maxY(), box.maxZ()); } else { - targetTowerMin = base.offset(30, 0, 35); - targetTowerMax = base.offset(74, TOWER_HEIGHT, 71); - targetElevatorShaft = base.offset(51, 0, 53); - targetImpactPos = new Vec3(base.getX() + 52.0, impactY, base.getZ() + 53.0); + int mid = (box.minZ() + box.maxZ()) / 2; + a = new BoundingBox(box.minX(), box.minY(), box.minZ(), box.maxX(), box.maxY(), mid); + b = new BoundingBox(box.minX(), box.minY(), mid + 1, box.maxX(), box.maxY(), box.maxZ()); } + return new Towers(key, box, new BoundingBox[] {a, b}); + } - active = true; - countdownTicks = COUNTDOWN_SECONDS * 20; - lastCountdownSecond = -1; + /** Tower boxes in world coordinates for a template placed at {@code position} with {@code rotation}. */ + static Towers fromLayout(long key, BlockPos position, Rotation rotation) { + BoundingBox[] towers = new BoundingBox[TOWER_LOCAL_BOXES.length]; + for (int i = 0; i < TOWER_LOCAL_BOXES.length; i++) { + int[] l = TOWER_LOCAL_BOXES[i]; + BlockPos c0 = StructureTemplate.transform(new BlockPos(l[0], l[1], l[2]), Mirror.NONE, rotation, BlockPos.ZERO); + BlockPos c1 = StructureTemplate.transform(new BlockPos(l[3], l[4], l[5]), Mirror.NONE, rotation, BlockPos.ZERO); + towers[i] = BoundingBox.fromCorners(position.offset(c0), position.offset(c1)); + } + BlockPos s0 = position; + BlockPos s1 = position.offset(StructureTemplate.transform( + new BlockPos(TOWER_WIDTH_X - 1, TOWER_HEIGHT, TOWER_LENGTH_Z - 1), Mirror.NONE, rotation, BlockPos.ZERO)); + return new Towers(key, BoundingBox.fromCorners(s0, s1), towers); + } - // Broadcast Air Raid Warning & sound siren - broadcastAirRaidAlert(level, base); + static double horizontalDistanceToBox(BoundingBox box, double x, double z) { + double dx = Math.max(0.0, Math.max(box.minX() - x, x - (box.maxX() + 1))); + double dz = Math.max(0.0, Math.max(box.minZ() - z, z - (box.maxZ() + 1))); + return Math.sqrt(dx * dx + dz * dz); + } - // Spawn plane immediately so it flies towards the towers during the 10-second countdown! - spawnIncomingPlane(level); + // ---------------------------------------------------------------- spawning + /** Target point: horizontal center of the tower, at 40%–85% of its height. */ + static Vec3 towerTarget(BoundingBox tower, double heightFraction) { + double cx = (tower.minX() + tower.maxX() + 1) / 2.0; + double cz = (tower.minZ() + tower.maxZ() + 1) / 2.0; + double y = tower.minY() + tower.getYSpan() * heightFraction; + return new Vec3(cx, y, cz); + } + + /** Spawn distance from the structure's horizontal center: half-diagonal of its box plus 48..96 blocks. */ + static double ringDistance(BoundingBox structureBox, double margin01) { + double hx = structureBox.getXSpan() / 2.0; + double hz = structureBox.getZSpan() / 2.0; + return Math.sqrt(hx * hx + hz * hz) + Mth.lerp(margin01, RING_MARGIN_MIN, RING_MARGIN_MAX); + } + + /** Whether the horizontal segment from {@code a} to {@code b} crosses {@code box} grown by {@code pad} blocks. */ + static boolean segmentCrossesBox2D(Vec3 a, Vec3 b, BoundingBox box, double pad) { + double minX = box.minX() - pad, maxX = box.maxX() + 1 + pad; + double minZ = box.minZ() - pad, maxZ = box.maxZ() + 1 + pad; + double t0 = 0.0, t1 = 1.0; + double[] p = {a.x, a.z}; + double[] d = {b.x - a.x, b.z - a.z}; + double[] lo = {minX, minZ}; + double[] hi = {maxX, maxZ}; + for (int i = 0; i < 2; i++) { + if (Math.abs(d[i]) < 1.0E-9) { + if (p[i] < lo[i] || p[i] > hi[i]) return false; + } else { + double ta = (lo[i] - p[i]) / d[i]; + double tb = (hi[i] - p[i]) / d[i]; + t0 = Math.max(t0, Math.min(ta, tb)); + t1 = Math.min(t1, Math.max(ta, tb)); + if (t0 > t1) return false; + } + } return true; } - private static void tickActiveEvent(ServerLevel level) { - if (countdownTicks > 0) { - int secs = (countdownTicks + 19) / 20; - if (secs != lastCountdownSecond) { - lastCountdownSecond = secs; - sendCountdownTitle(level, secs); - - // Periodic Air Raid Siren sound every 2 seconds - if (secs % 2 == 0) { - BlockPos soundPos = targetTowerMin != null ? targetTowerMin.offset(17, 100, 17) : BlockPos.ZERO; - level.playSound(null, soundPos.getX(), soundPos.getY(), soundPos.getZ(), - ModSoundEvents.AIR_RAID_SIREN.get(), SoundSource.NEUTRAL, 8.0F, 1.0F); - } + private static boolean launchPlane(ServerLevel level, Towers towers) { + UUID existing = ACTIVE_PLANES.get(towers.key()); + if (existing != null) { + Entity e = level.getEntity(existing); + if (e != null && e.isAlive()) { + return false; // one plane per structure at a time } + ACTIVE_PLANES.remove(towers.key()); + } - countdownTicks--; - if (countdownTicks == 0) { - sendCountdownTitle(level, 0); + RandomSource random = level.getRandom(); + int towerIndex = random.nextInt(towers.towers().length); + BoundingBox tower = towers.towers()[towerIndex]; + Vec3 target = towerTarget(tower, Mth.lerp(random.nextDouble(), HEIGHT_FRACTION_MIN, HEIGHT_FRACTION_MAX)); + + BoundingBox sb = towers.structureBox(); + double centerX = (sb.minX() + sb.maxX() + 1) / 2.0; + double centerZ = (sb.minZ() + sb.maxZ() + 1) / 2.0; + + // Prefer an approach that does not cross the other tower (wingspan ~36 blocks), else any loaded spot. + Vec3 spawn = null; + Vec3 fallback = null; + for (int attempt = 0; attempt < 16 && spawn == null; attempt++) { + double angle = random.nextDouble() * Math.PI * 2.0; + double dist = ringDistance(sb, random.nextDouble()); + double y = Math.min(target.y + random.nextDouble() * 15.0, level.getMaxY() - 10.0); + Vec3 candidate = new Vec3(centerX + Math.cos(angle) * dist, y, centerZ + Math.sin(angle) * dist); + if (!level.isPositionEntityTicking(BlockPos.containing(candidate))) { + continue; + } + boolean blocked = false; + for (int i = 0; i < towers.towers().length; i++) { + if (i != towerIndex && segmentCrossesBox2D(candidate, target, towers.towers()[i], 18.0)) { + blocked = true; + break; + } + } + if (blocked) { + if (fallback == null) fallback = candidate; + } else { + spawn = candidate; } } + if (spawn == null) { + spawn = fallback; + } + if (spawn == null) { + return false; // no loaded spot around the structure + } + + double speed = spawn.distanceTo(target) / APPROACH_TICKS; + PlaneCrashEntity plane = new PlaneCrashEntity(ModEntities.PLANE_CRASH.get(), level); + BlockPos towerMin = new BlockPos(tower.minX(), tower.minY(), tower.minZ()); + BlockPos towerMax = new BlockPos(tower.maxX(), tower.maxY(), tower.maxZ()); + BlockPos shaft = BlockPos.containing((tower.minX() + tower.maxX()) / 2.0, tower.minY(), (tower.minZ() + tower.maxZ()) / 2.0); + plane.initFlight(spawn, target, towerMin, towerMax, shaft, speed); + if (!level.addFreshEntity(plane)) { + return false; + } + ACTIVE_PLANES.put(towers.key(), plane.getUUID()); + + level.playSound(null, spawn.x, spawn.y, spawn.z, ModSoundEvents.PLANE_JET.get(), SoundSource.NEUTRAL, 6.0F, 0.8F); + level.playSound(null, target.x, target.y, target.z, ModSoundEvents.AIR_RAID_SIREN.get(), SoundSource.NEUTRAL, 8.0F, 1.0F); + broadcastAlert(level, target); + return true; } - private static void broadcastAirRaidAlert(ServerLevel level, BlockPos base) { + private static void broadcastAlert(ServerLevel level, Vec3 target) { Component title = Component.literal("§c§l⚠ ALLARME ANTIAEREO ⚠"); Component subtitle = Component.literal("§eAereo in avvicinamento alle torri! Evacuare!"); - - for (ServerPlayer player : level.players()) { + for (ServerPlayer player : nearbyPlayers(level, target)) { player.connection.send(new ClientboundSetTitlesAnimationPacket(5, 40, 10)); player.connection.send(new ClientboundSetTitleTextPacket(title)); player.connection.send(new ClientboundSetSubtitleTextPacket(subtitle)); - player.sendSystemMessage(Component.literal("§4[EMERGENZA] §cSirena di allarme antiaereo attivata! Rilevato velivolo in rotta di collisione!")); } - - level.playSound(null, base.getX() + 35, base.getY() + 100, base.getZ() + 35, - ModSoundEvents.AIR_RAID_SIREN.get(), SoundSource.NEUTRAL, 10.0F, 1.0F); } - private static void sendCountdownTitle(ServerLevel level, int seconds) { - Component title = Component.literal("§c§l⚠ ALLARME ANTIAEREO ⚠"); - Component subtitle = Component.literal("§6Impatto stimato in: §e§l" + seconds + "s"); - Component overlay = Component.literal("§c§lALLARME ANTIAEREO §7- §6Impatto in: §e§l" + seconds + "s"); + private static List nearbyPlayers(ServerLevel level, Vec3 pos) { + List out = new ArrayList<>(); for (ServerPlayer player : level.players()) { - player.connection.send(new ClientboundSetTitlesAnimationPacket(0, 20, 0)); - player.connection.send(new ClientboundSetTitleTextPacket(title)); - player.connection.send(new ClientboundSetSubtitleTextPacket(subtitle)); - player.sendOverlayMessage(overlay); + double dx = player.getX() - pos.x; + double dz = player.getZ() - pos.z; + if (dx * dx + dz * dz <= (double) ALERT_RADIUS * ALERT_RADIUS) { + out.add(player); + } } - BlockPos soundCenter = targetTowerMin != null ? targetTowerMin.offset(17, 100, 17) : (towersOrigin != null ? towersOrigin : BlockPos.ZERO); - level.playSound(null, soundCenter.getX(), soundCenter.getY(), soundCenter.getZ(), - SoundEvents.NOTE_BLOCK_HAT.value(), SoundSource.PLAYERS, 2.0F, 1.4F); + return out; } - private static void spawnIncomingPlane(ServerLevel level) { - // Random spawn distance between 100 and 140 blocks away - double distance = 100.0 + level.getRandom().nextDouble() * 40.0; - double speed = distance / (COUNTDOWN_SECONDS * 20.0); // Exactly reaches tower area at countdown ~0s - - // Completely random 360-degree approach angle around the target tower - double angle = level.getRandom().nextDouble() * 2.0 * Math.PI; - double spawnX = targetImpactPos.x + Math.cos(angle) * distance; - double spawnZ = targetImpactPos.z + Math.sin(angle) * distance; - // Slight random altitude variation (diving or climbing approach) - double spawnY = targetImpactPos.y + (level.getRandom().nextDouble() * 20.0 - 5.0); - - Vec3 spawnPos = new Vec3(spawnX, spawnY, spawnZ); - - PlaneCrashEntity plane = new PlaneCrashEntity(ModEntities.PLANE_CRASH.get(), level); - plane.initFlight(spawnPos, targetImpactPos, targetTowerMin, targetTowerMax, targetElevatorShaft, speed); - - level.addFreshEntity(plane); - activePlaneId = plane.getUUID(); - - level.playSound(null, spawnPos.x, spawnPos.y, spawnPos.z, - ModSoundEvents.PLANE_JET.get(), SoundSource.NEUTRAL, 6.0F, 0.8F); + private static void cleanup(ServerLevel level) { + ACTIVE_PLANES.values().removeIf(id -> { + Entity e = level.getEntity(id); + return e == null || !e.isAlive(); + }); } - /** - * Called when the plane crashes directly into the tower facade and detonates. - */ - public static void onPlaneCrashImpact(ServerLevel level, Vec3 impactPos) { + // ---------------------------------------------------------------- callbacks from the plane + + /** Called when the plane crashes into the tower facade and detonates. */ + public static void onPlaneCrashImpact(ServerLevel level, Vec3 impactPos, UUID planeId) { + ACTIVE_PLANES.values().remove(planeId); Component title = Component.literal("§4§l⚠ IMPATTO AVVENUTO ⚠"); Component subtitle = Component.literal("§cL'aereo si è schiantato contro la torre!"); - for (ServerPlayer player : level.players()) { + for (ServerPlayer player : nearbyPlayers(level, impactPos)) { player.connection.send(new ClientboundSetTitlesAnimationPacket(0, 40, 15)); player.connection.send(new ClientboundSetTitleTextPacket(title)); player.connection.send(new ClientboundSetSubtitleTextPacket(subtitle)); - player.sendOverlayMessage(Component.literal("§4§lIMPATTO! §cL'aereo si è schiantato contro la torre!")); - player.sendSystemMessage(Component.literal("§4§l[DISASTRO] §cL'aereo si è schiantato contro la torre distruggendo la facciata e la struttura!")); } + } + + /** Called when the plane despawns without hitting anything. */ + public static void onPlaneGone(UUID planeId) { + ACTIVE_PLANES.values().remove(planeId); + } - // Event finished - active = false; - countdownTicks = -1; - lastCountdownSecond = -1; - activePlaneId = null; + /** Removes every tracked plane from the world ({@code /israelsim event plane_crash stop}). */ + public static void stopEvent(ServerLevel level) { + for (UUID id : List.copyOf(ACTIVE_PLANES.values())) { + Entity e = level.getEntity(id); + if (e != null) e.discard(); + } + ACTIVE_PLANES.clear(); } public static void stopEvent() { - active = false; - countdownTicks = -1; - lastCountdownSecond = -1; - activePlaneId = null; + ACTIVE_PLANES.clear(); } } diff --git a/src/test/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEventTest.java b/src/test/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEventTest.java index fdbd0e8..efeebb2 100644 --- a/src/test/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEventTest.java +++ b/src/test/java/com/israelsimulator/event/world/plane/TwinTowersPlaneEventTest.java @@ -3,6 +3,9 @@ import com.israelsimulator.entity.event.PlaneCrashEntity; import com.israelsimulator.entity.event.PlaneModelData; import net.minecraft.core.BlockPos; +import net.minecraft.world.level.block.Rotation; +import net.minecraft.world.level.levelgen.structure.BoundingBox; +import net.minecraft.world.phys.Vec3; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -45,5 +48,53 @@ void testMaxFlightDuration() { assertEquals(30 * 20, PlaneCrashEntity.MAX_FLIGHT_TICKS); assertEquals(600, PlaneCrashEntity.MAX_FLIGHT_TICKS); } -} + @Test + @DisplayName("Tower boxes follow the template rotation and stay inside the structure") + void testTowerBoxesRotate() { + BlockPos origin = new BlockPos(1000, 70, -500); + for (Rotation rotation : Rotation.values()) { + TwinTowersPlaneEvent.Towers t = TwinTowersPlaneEvent.fromLayout(1L, origin, rotation); + assertEquals(2, t.towers().length); + for (BoundingBox tower : t.towers()) { + assertEquals(25, tower.getXSpan(), rotation + " tower is 25 blocks wide"); + assertEquals(25, tower.getZSpan(), rotation + " tower is 25 blocks deep"); + assertTrue(t.structureBox().isInside(tower.minX(), tower.minY(), tower.minZ()), rotation + " inside structure"); + assertTrue(t.structureBox().isInside(tower.maxX(), tower.maxY(), tower.maxZ()), rotation + " inside structure"); + } + assertFalse(t.towers()[0].intersects(t.towers()[1]), rotation + " towers are separate"); + } + TwinTowersPlaneEvent.Towers none = TwinTowersPlaneEvent.fromLayout(1L, origin, Rotation.NONE); + assertEquals(new BoundingBox(1005, 70, -495, 1029, 247, -471), none.towers()[0]); + } + + @Test + @DisplayName("Target is the tower's horizontal center between 40% and 85% of its height") + void testTowerTarget() { + BoundingBox tower = new BoundingBox(0, 70, 0, 24, 247, 24); + Vec3 low = TwinTowersPlaneEvent.towerTarget(tower, TwinTowersPlaneEvent.HEIGHT_FRACTION_MIN); + Vec3 high = TwinTowersPlaneEvent.towerTarget(tower, TwinTowersPlaneEvent.HEIGHT_FRACTION_MAX); + assertEquals(12.5, low.x, 1e-9); + assertEquals(12.5, low.z, 1e-9); + assertEquals(70 + 178 * 0.40, low.y, 1e-9); + assertEquals(70 + 178 * 0.85, high.y, 1e-9); + } + @Test + @DisplayName("Plane spawns outside the whole structure, 48-96 blocks beyond its half-diagonal") + void testSpawnRingOutsideStructure() { + BoundingBox box = new BoundingBox(0, 70, 0, 73, 265, 70); + double halfDiagonal = Math.sqrt(37.0 * 37.0 + 35.5 * 35.5); + assertEquals(halfDiagonal + 48.0, TwinTowersPlaneEvent.ringDistance(box, 0.0), 1e-9); + assertEquals(halfDiagonal + 96.0, TwinTowersPlaneEvent.ringDistance(box, 1.0), 1e-9); + assertTrue(TwinTowersPlaneEvent.horizontalDistanceToBox(box, 37.0 + TwinTowersPlaneEvent.ringDistance(box, 0.0), 35.5) >= 48.0); + } + + @Test + @DisplayName("Approach path check detects a crossing of the other tower") + void testSegmentCrossesBox() { + BoundingBox other = new BoundingBox(40, 0, 40, 64, 200, 64); + Vec3 target = new Vec3(12.5, 100, 12.5); + assertTrue(TwinTowersPlaneEvent.segmentCrossesBox2D(new Vec3(150, 110, 150), target, other, 18.0)); + assertFalse(TwinTowersPlaneEvent.segmentCrossesBox2D(new Vec3(-120, 110, 12.5), target, other, 18.0)); + } +} diff --git a/test.md b/test.md index f91f62a..b9f17af 100644 --- a/test.md +++ b/test.md @@ -853,3 +853,9 @@ Non provato dall'utente in gioco. - Funzionamento atteso: circa +50% di frequenza (una possibile posizione ogni 26×26 chunk invece di 32×32: 1024/676 ≈ 1,51). - Soluzione: nello structure set `twin_towers` spaziatura da 32 a 26 chunk e separazione da 12 a 10 (sempre minore della spaziatura). Nessuna costante Java o test fissava questi valori; la guardia anti-sovrapposizione legge la spaziatura dallo structure set. - Verifica: solo la CI di GitHub, nessun test in gioco. Non provato dall'utente in gioco. + +## Aereo sulle twin_towers ogni minuto +- Problema riscontrato: l'aereo partiva solo con il giocatore dentro le torri, col 35% di probabilità e 5 minuti di attesa; i punti delle torri erano fissi e sbagliati se la struttura era ruotata, e le esplosioni rompevano anche il terreno intorno. +- Funzionamento atteso: ogni minuto, per ogni twin_towers con un giocatore entro 160 blocchi, parte un aereo da un punto a caso fuori dalla struttura (metà diagonale + 48-96 blocchi, quota del bersaglio + 0-15), puntato dritto al centro di una delle due torri scelta a caso, a un'altezza tra il 40% e l'85% della torre. Un solo aereo in volo per struttura; se non colpisce sparisce dopo 30 s. Sirena e titolo d'allarme solo ai giocatori vicini (256 blocchi). +- Soluzione: `TwinTowersPlaneEvent` ora cerca le twin_towers nei chunk caricati vicino ai giocatori (senza caricare chunk), calcola le due torri dal template ruotato (`PoolElementStructurePiece`), sceglie un punto di partenza caricato evitando la rotta che attraversa l'altra torre. `PlaneCrashEntity` rompe e incendia solo blocchi dentro la torre colpita (calcolatore d'esplosione personalizzato) e non viene salvato su disco. +- Verifica: solo la CI di GitHub, nessun test in gioco. Non provato dall'utente in gioco.