Prvni verze

This commit is contained in:
bv
2026-09-01 20:29:31 +02:00
commit cffd3e4a76
114 changed files with 5138 additions and 0 deletions
@@ -0,0 +1,71 @@
package com.titanicmc.titanic;
import net.minecraftforge.common.ForgeConfigSpec;
/**
* Common (server-authoritative) config for the Titanic mod.
*/
public class Config {
public static final ForgeConfigSpec SPEC;
public static final ForgeConfigSpec.IntValue WRECK_RARITY;
public static final ForgeConfigSpec.IntValue DRY_DOCK_IRON_REQUIRED;
public static final ForgeConfigSpec.IntValue DRY_DOCK_GLASS_REQUIRED;
public static final ForgeConfigSpec.IntValue DRY_DOCK_GLASS_PANES_REQUIRED;
public static final ForgeConfigSpec.IntValue DRY_DOCK_REPEATERS_REQUIRED;
public static final ForgeConfigSpec.IntValue DRY_DOCK_COMPARATORS_REQUIRED;
public static final ForgeConfigSpec.IntValue DRY_DOCK_CHESTS_REQUIRED;
public static final ForgeConfigSpec.IntValue DRY_DOCK_PLANKS_REQUIRED;
public static final ForgeConfigSpec.IntValue DRY_DOCK_REDSTONE_BLOCKS_REQUIRED;
public static final ForgeConfigSpec.IntValue MAX_SHIP_BLOCKS;
public static final ForgeConfigSpec.DoubleValue RAISE_SPEED;
public static final ForgeConfigSpec.DoubleValue BOILER_HEAT_PER_FUEL;
public static final ForgeConfigSpec.DoubleValue ENGINE_POWER_PER_HEAT;
static {
ForgeConfigSpec.Builder builder = new ForgeConfigSpec.Builder();
builder.push("world_generation");
WRECK_RARITY = builder
.comment("1 in N ocean chunks (approximately) will attempt to place a Titanic wreck.")
.defineInRange("wreckRarity", 800, 1, 100000);
builder.pop();
builder.push("ships");
MAX_SHIP_BLOCKS = builder
.comment("Safety limit on how many connected blocks may be assembled into one ship.")
.defineInRange("maxShipBlocks", 20000, 100, 200000);
RAISE_SPEED = builder
.comment("Upward speed (blocks/tick) applied while a wreck is being raised to the surface.")
.defineInRange("raiseSpeed", 0.08, 0.001, 1.0);
BOILER_HEAT_PER_FUEL = builder
.comment("Heat generated per fuel-tick burned in a Boiler.")
.defineInRange("boilerHeatPerFuel", 0.02, 0.0001, 10.0);
ENGINE_POWER_PER_HEAT = builder
.comment("Engine power applied to the ship per unit of accumulated heat, when a Crankshaft is adjacent.")
.defineInRange("enginePowerPerHeat", 4000.0, 1.0, 1000000.0);
builder.pop();
builder.push("dry_dock_repair");
DRY_DOCK_IRON_REQUIRED = builder.comment("Iron ingots required to repair the hull (3 * 64 per the design doc).")
.defineInRange("ironIngots", 192, 1, 100000);
DRY_DOCK_GLASS_REQUIRED = builder.comment("Glass blocks required for portholes/windows.")
.defineInRange("glassBlocks", 64, 1, 100000);
DRY_DOCK_GLASS_PANES_REQUIRED = builder.comment("Glass panes required.")
.defineInRange("glassPanes", 64, 1, 100000);
DRY_DOCK_REPEATERS_REQUIRED = builder.comment("Redstone repeaters required (engine control linkages).")
.defineInRange("repeaters", 16, 1, 100000);
DRY_DOCK_COMPARATORS_REQUIRED = builder.comment("Redstone comparators required (gauges/telegraph).")
.defineInRange("comparators", 20, 1, 100000);
DRY_DOCK_CHESTS_REQUIRED = builder.comment("Chests required (cargo holds / boiler feed bunkers).")
.defineInRange("chests", 64, 1, 100000);
DRY_DOCK_PLANKS_REQUIRED = builder.comment("Planks required (deck planking).")
.defineInRange("planks", 50, 1, 100000);
DRY_DOCK_REDSTONE_BLOCKS_REQUIRED = builder.comment("Redstone blocks required (power for the engine telegraph).")
.defineInRange("redstoneBlocks", 10, 1, 100000);
builder.pop();
SPEC = builder.build();
}
}
@@ -0,0 +1,61 @@
package com.titanicmc.titanic;
import com.mojang.logging.LogUtils;
import com.titanicmc.titanic.event.ForgeEventHandler;
import com.titanicmc.titanic.registry.ModBlockEntities;
import com.titanicmc.titanic.registry.ModBlocks;
import com.titanicmc.titanic.registry.ModCreativeTabs;
import com.titanicmc.titanic.registry.ModEntities;
import com.titanicmc.titanic.registry.ModFeatures;
import com.titanicmc.titanic.registry.ModItems;
import com.titanicmc.titanic.ship.TitanicShipControl;
import kotlin.Unit;
import net.minecraftforge.common.MinecraftForge;
import net.minecraftforge.eventbus.api.IEventBus;
import net.minecraftforge.fml.common.Mod;
import net.minecraftforge.fml.config.ModConfig;
import net.minecraftforge.fml.javafmlmod.FMLJavaModLoadingContext;
import org.slf4j.Logger;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
/**
* Titanic - raise the wreck, repair her in dry dock, and sail her again.
* <p>
* Ship physics (buoyancy, steering, collisions) are provided by the Valkyrien Skies 2
* library mod. This mod never manually shuffles blocks around to fake movement; once a
* structure is "assembled" via {@code org.valkyrienskies.mod.common.assembly.ShipAssembler}
* it becomes a real physics object that VS2 simulates every tick.
*/
@Mod(TitanicMod.MODID)
public class TitanicMod {
public static final String MODID = "titanic";
public static final Logger LOGGER = LogUtils.getLogger();
public TitanicMod(FMLJavaModLoadingContext context) {
IEventBus modEventBus = context.getModEventBus();
ModItems.ITEMS.register(modEventBus);
ModBlocks.BLOCKS.register(modEventBus);
ModBlockEntities.BLOCK_ENTITIES.register(modEventBus);
ModEntities.ENTITIES.register(modEventBus);
ModFeatures.FEATURES.register(modEventBus);
ModCreativeTabs.TABS.register(modEventBus);
context.registerConfig(ModConfig.Type.COMMON, Config.SPEC);
MinecraftForge.EVENT_BUS.register(new ForgeEventHandler());
// Required by Valkyrien Skies 2: any class attached to a ship via
// LoadedServerShip#setAttachment must first be registered with its attachment
// registry, or setAttachment throws IllegalArgumentException. This mod's mods.toml
// declares an AFTER ordering dependency on valkyrienskies, so its core (and this
// registry) is guaranteed to already be initialized here. TitanicShipControl carries
// only in-memory runtime state (raise/engine state, a live ship reference) that should
// never be persisted, hence the transient serializer.
VSGameUtilsKt.getVsCore().registerAttachment(TitanicShipControl.class, builder -> {
builder.useTransientSerializer();
return Unit.INSTANCE;
});
}
}
@@ -0,0 +1,65 @@
package com.titanicmc.titanic.block;
import com.titanicmc.titanic.blockentity.BoilerBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import org.jetbrains.annotations.Nullable;
/** A huge coal-fired boiler. See {@link BoilerBlockEntity} for behaviour. */
public class BoilerBlock extends BaseEntityBlock {
public BoilerBlock(Properties properties) {
super(properties);
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) {
if (level.isClientSide) return InteractionResult.SUCCESS;
if (!(level.getBlockEntity(pos) instanceof BoilerBlockEntity boiler)) return InteractionResult.PASS;
ItemStack held = player.getItemInHand(hand);
if (!held.isEmpty() && boiler.tryFeed(held)) {
player.displayClientMessage(Component.translatable("message.titanic.boiler_fed"), true);
return InteractionResult.CONSUME;
}
player.displayClientMessage(Component.translatable(
boiler.isBurning() ? "message.titanic.boiler_burning" : "message.titanic.boiler_needs_fuel"), true);
return InteractionResult.CONSUME;
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new BoilerBlockEntity(pos, state);
}
@Nullable
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
if (level.isClientSide) return null;
return (lvl, pos, st, be) -> {
if (be instanceof BoilerBlockEntity boiler) {
boiler.serverTick((ServerLevel) lvl);
}
};
}
}
@@ -0,0 +1,51 @@
package com.titanicmc.titanic.block;
import com.titanicmc.titanic.entity.SeatEntity;
import com.titanicmc.titanic.registry.ModEntities;
import net.minecraft.core.BlockPos;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.phys.AABB;
import net.minecraft.world.phys.BlockHitResult;
import java.util.List;
/**
* A simple dining-room / cabin chair. Right-click to sit down; sneak to get back up.
* Works fine on an assembled ship since VS2 handles entities riding blocks-on-ships.
*/
public class ChairBlock extends Block {
public ChairBlock(Properties properties) {
super(properties);
}
@Override
public InteractionResult use(net.minecraft.world.level.block.state.BlockState state, Level level, BlockPos pos,
Player player, InteractionHand hand, BlockHitResult hit) {
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
if (player.isPassenger() || player.isShiftKeyDown()) {
return InteractionResult.PASS;
}
AABB searchBox = new AABB(pos).inflate(0.2);
List<SeatEntity> existing = level.getEntitiesOfClass(SeatEntity.class, searchBox);
SeatEntity seat = existing.isEmpty() ? null : existing.get(0);
if (seat == null) {
seat = ModEntities.SEAT.get().create(level);
if (seat == null) return InteractionResult.PASS;
seat.setPos(pos.getX() + 0.5, pos.getY() + 0.2, pos.getZ() + 0.5);
level.addFreshEntity(seat);
}
player.startRiding(seat, true);
return InteractionResult.CONSUME;
}
}
@@ -0,0 +1,14 @@
package com.titanicmc.titanic.block;
import net.minecraft.world.level.block.Block;
/**
* Purely a structural marker: a {@link BoilerBlock} only converts its heat into ship
* propulsion while at least one Crankshaft is directly adjacent to it. This forces the
* player to actually build out an "engine room" rather than dropping a single block.
*/
public class CrankshaftBlock extends Block {
public CrankshaftBlock(Properties properties) {
super(properties);
}
}
@@ -0,0 +1,78 @@
package com.titanicmc.titanic.block;
import com.titanicmc.titanic.blockentity.HelmBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.context.BlockPlaceContext;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.HorizontalDirectionalBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.phys.BlockHitResult;
import org.jetbrains.annotations.Nullable;
/** The real ship's wheel, installed automatically once dry dock repairs are finished. */
public class HelmBlock extends BaseEntityBlock {
public HelmBlock(Properties properties) {
super(properties);
registerDefaultState(this.stateDefinition.any().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH));
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(BlockStateProperties.HORIZONTAL_FACING);
}
@Nullable
@Override
public BlockState getStateForPlacement(BlockPlaceContext context) {
return defaultBlockState().setValue(BlockStateProperties.HORIZONTAL_FACING, context.getHorizontalDirection().getOpposite());
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) {
if (level.isClientSide) return InteractionResult.SUCCESS;
if (!(level.getBlockEntity(pos) instanceof HelmBlockEntity helm)) return InteractionResult.PASS;
if (helm.sit(player)) {
return InteractionResult.CONSUME;
}
player.displayClientMessage(Component.translatable("message.titanic.helm_not_seaworthy"), true);
return InteractionResult.CONSUME;
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new HelmBlockEntity(pos, state);
}
@Nullable
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
if (level.isClientSide) return null;
return (lvl, pos, st, be) -> {
if (be instanceof HelmBlockEntity helm) {
helm.serverTick();
}
};
}
}
@@ -0,0 +1,24 @@
package com.titanicmc.titanic.block;
import net.minecraft.core.Direction;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
/**
* Generated on the wreck's bridge during world generation, marking where the real helm will
* be installed once dry dock repairs are finished. Never placeable by the player.
*/
public class HelmSocketBlock extends Block {
public HelmSocketBlock(Properties properties) {
super(properties);
registerDefaultState(this.stateDefinition.any().setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.NORTH));
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(BlockStateProperties.HORIZONTAL_FACING);
}
}
@@ -0,0 +1,106 @@
package com.titanicmc.titanic.block;
import com.titanicmc.titanic.blockentity.LiftingFrameBlockEntity;
import com.titanicmc.titanic.registry.ModBlockEntities;
import com.titanicmc.titanic.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityTicker;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.StateDefinition;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.block.state.properties.IntegerProperty;
import net.minecraft.world.phys.BlockHitResult;
import org.jetbrains.annotations.Nullable;
/**
* The recovery "box" a player builds and lowers over the sunken wreck. It has four eyes;
* right-clicking it with a Steel Cable threads one eye at a time. Once all four are threaded
* the frame is "rigged" and can be linked to a {@link WinchBlock} on a surface vessel to
* start the raise sequence.
*/
public class LiftingFrameBlock extends BaseEntityBlock {
public static final IntegerProperty CABLES = IntegerProperty.create("cables", 0, 4);
public LiftingFrameBlock(Properties properties) {
super(properties);
registerDefaultState(this.stateDefinition.any().setValue(CABLES, 0));
}
@Override
protected void createBlockStateDefinition(StateDefinition.Builder<Block, BlockState> builder) {
builder.add(CABLES);
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) {
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
BlockEntity be = level.getBlockEntity(pos);
if (!(be instanceof LiftingFrameBlockEntity frame)) {
return InteractionResult.PASS;
}
ItemStack held = player.getItemInHand(hand);
int cables = state.getValue(CABLES);
if (cables < 4 && held.is(ModItems.STEEL_CABLE.get())) {
held.shrink(1);
level.setBlock(pos, state.setValue(CABLES, cables + 1), 3);
player.displayClientMessage(
Component.translatable("message.titanic.frame_threaded", cables + 1, 4), true);
if (cables + 1 == 4) {
player.displayClientMessage(Component.translatable("message.titanic.frame_rigged"), true);
}
return InteractionResult.CONSUME;
}
if (cables >= 4) {
player.displayClientMessage(
Component.translatable(frame.isLinkedToWinch()
? "message.titanic.frame_already_linked"
: "message.titanic.frame_needs_winch"), true);
} else {
player.displayClientMessage(
Component.translatable("message.titanic.frame_needs_cable", 4 - cables), true);
}
return InteractionResult.CONSUME;
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new LiftingFrameBlockEntity(pos, state);
}
@Nullable
@Override
public <T extends BlockEntity> BlockEntityTicker<T> getTicker(Level level, BlockState state, BlockEntityType<T> type) {
if (level.isClientSide) return null;
return (lvl, pos, st, blockEntity) -> {
if (blockEntity instanceof LiftingFrameBlockEntity frame) {
frame.serverTick((ServerLevel) lvl);
}
};
}
}
@@ -0,0 +1,50 @@
package com.titanicmc.titanic.block;
import com.titanicmc.titanic.blockentity.RepairPanelBlockEntity;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.BaseEntityBlock;
import net.minecraft.world.level.block.RenderShape;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
import org.jetbrains.annotations.Nullable;
/**
* Generated inside the wreck's bridge. Right-click while holding a repair material to hand
* it in. See {@link RepairPanelBlockEntity} for the full repair logic.
*/
public class RepairPanelBlock extends BaseEntityBlock {
public RepairPanelBlock(Properties properties) {
super(properties);
}
@Override
public RenderShape getRenderShape(BlockState state) {
return RenderShape.MODEL;
}
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) {
if (level.isClientSide) return InteractionResult.SUCCESS;
if (!(level.getBlockEntity(pos) instanceof RepairPanelBlockEntity panel)) return InteractionResult.PASS;
ItemStack held = player.getItemInHand(hand);
panel.handleDeliver((ServerLevel) level, player, held);
return InteractionResult.CONSUME;
}
@Nullable
@Override
public BlockEntity newBlockEntity(BlockPos pos, BlockState state) {
return new RepairPanelBlockEntity(pos, state);
}
}
@@ -0,0 +1,62 @@
package com.titanicmc.titanic.block;
import com.titanicmc.titanic.blockentity.LiftingFrameBlockEntity;
import com.titanicmc.titanic.registry.ModItems;
import net.minecraft.core.BlockPos;
import net.minecraft.network.chat.Component;
import net.minecraft.world.InteractionHand;
import net.minecraft.world.InteractionResult;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.Level;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.BlockHitResult;
/**
* Placed on the player's recovery vessel above the water. Right-clicking with a Steel Cable
* searches straight down for a fully rigged {@link LiftingFrameBlock} and, if found, links the
* two together, starting the automatic raise sequence.
*/
public class WinchBlock extends Block {
private static final int MAX_SEARCH_DEPTH = 256;
public WinchBlock(Properties properties) {
super(properties);
}
@Override
public InteractionResult use(BlockState state, Level level, BlockPos pos, Player player, InteractionHand hand,
BlockHitResult hit) {
if (level.isClientSide) {
return InteractionResult.SUCCESS;
}
ItemStack held = player.getItemInHand(hand);
if (!held.is(ModItems.STEEL_CABLE.get())) {
player.displayClientMessage(Component.translatable("message.titanic.winch_needs_cable"), true);
return InteractionResult.CONSUME;
}
BlockPos.MutableBlockPos search = pos.mutable();
for (int i = 0; i < MAX_SEARCH_DEPTH; i++) {
search.move(0, -1, 0);
BlockEntity be = level.getBlockEntity(search);
if (be instanceof LiftingFrameBlockEntity frame) {
if (!frame.isRigged(level.getBlockState(search))) {
player.displayClientMessage(Component.translatable("message.titanic.frame_not_rigged"), true);
return InteractionResult.CONSUME;
}
held.shrink(1);
frame.linkToWinch(pos);
player.displayClientMessage(Component.translatable("message.titanic.raise_started"), true);
return InteractionResult.CONSUME;
}
}
player.displayClientMessage(Component.translatable("message.titanic.no_frame_found"), true);
return InteractionResult.CONSUME;
}
}
@@ -0,0 +1,122 @@
package com.titanicmc.titanic.blockentity;
import com.titanicmc.titanic.Config;
import com.titanicmc.titanic.block.CrankshaftBlock;
import com.titanicmc.titanic.registry.ModBlockEntities;
import com.titanicmc.titanic.ship.ShipUtil;
import com.titanicmc.titanic.ship.TitanicShipControl;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.Container;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraftforge.common.ForgeHooks;
import org.valkyrienskies.core.api.ships.LoadedServerShip;
/**
* Burns fuel to build up heat. Heat only turns into ship propulsion while a
* {@link CrankshaftBlock} is directly adjacent - this represents the engine room
* requiring both giant boilers and their crankshafts to actually drive the propellers.
* <p>
* Fuel can be fed directly by right-clicking with a burnable item, or the boiler will
* automatically pull one burnable item per cycle from any directly adjacent chest.
*/
public class BoilerBlockEntity extends BlockEntity {
private int burnTimeRemaining = 0;
public BoilerBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.BOILER.get(), pos, state);
}
/** Attempts to feed the boiler with the given item. Returns true if it was consumed. */
public boolean tryFeed(ItemStack stack) {
int burnTime = ForgeHooks.getBurnTime(stack, null);
if (burnTime <= 0) {
return false;
}
stack.shrink(1);
burnTimeRemaining += burnTime;
setChanged();
return true;
}
public void serverTick(ServerLevel level) {
if (burnTimeRemaining <= 0) {
tryAutoFeedFromChest(level);
}
if (burnTimeRemaining <= 0) {
return;
}
burnTimeRemaining--;
if (!hasAdjacentCrankshaft(level)) {
return;
}
LoadedServerShip ship = ShipUtil.getLoadedShipManagingPos(level, getBlockPos());
if (ship == null) {
return;
}
TitanicShipControl control = ship.getAttachment(TitanicShipControl.class);
if (control == null || !control.isRepaired()) {
// Un-repaired hulls can't safely run their engines yet.
return;
}
double power = Config.BOILER_HEAT_PER_FUEL.get() * Config.ENGINE_POWER_PER_HEAT.get();
control.addEnginePower(power);
setChanged();
}
private boolean hasAdjacentCrankshaft(ServerLevel level) {
for (Direction direction : Direction.values()) {
if (level.getBlockState(getBlockPos().relative(direction)).getBlock() instanceof CrankshaftBlock) {
return true;
}
}
return false;
}
private void tryAutoFeedFromChest(ServerLevel level) {
for (Direction direction : Direction.values()) {
BlockEntity neighbor = level.getBlockEntity(getBlockPos().relative(direction));
if (!(neighbor instanceof Container container)) {
continue;
}
for (int slot = 0; slot < container.getContainerSize(); slot++) {
ItemStack stack = container.getItem(slot);
if (stack.isEmpty()) continue;
int burnTime = ForgeHooks.getBurnTime(stack, null);
if (burnTime > 0) {
container.removeItem(slot, 1);
burnTimeRemaining += burnTime;
setChanged();
return;
}
}
}
}
public boolean isBurning() {
return burnTimeRemaining > 0;
}
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
tag.putInt("BurnTime", burnTimeRemaining);
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
burnTimeRemaining = tag.getInt("BurnTime");
}
}
@@ -0,0 +1,70 @@
package com.titanicmc.titanic.blockentity;
import com.titanicmc.titanic.registry.ModBlockEntities;
import com.titanicmc.titanic.ship.ShipUtil;
import com.titanicmc.titanic.ship.TitanicShipControl;
import net.minecraft.commands.arguments.EntityAnchorArgument;
import net.minecraft.core.BlockPos;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.phys.Vec3;
import org.valkyrienskies.core.api.ships.LoadedServerShip;
import org.valkyrienskies.mod.common.ValkyrienSkiesMod;
import org.valkyrienskies.mod.common.entity.ShipMountingEntity;
/**
* The real, working ship's wheel. Right-click it to sit down and steer, just like the
* wheelhouse of the real Titanic: forward/back on the wheel throttles the engines, left/right
* turns the rudder.
*/
public class HelmBlockEntity extends BlockEntity {
private ShipMountingEntity seat;
public HelmBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.HELM.get(), pos, state);
}
public void serverTick() {
LoadedServerShip ship = getShip();
if (ship == null) return;
TitanicShipControl control = ship.getAttachment(TitanicShipControl.class);
if (control == null) {
// Normally a Helm only exists after the Repair Panel finishes repairs (which
// already marks the control as repaired). This is just a safety net for ships
// that got a Helm some other way (e.g. placed directly in creative mode).
control = new TitanicShipControl();
control.markRepaired();
ship.setAttachment(TitanicShipControl.class, control);
}
control.ship = ship;
}
private LoadedServerShip getShip() {
return ShipUtil.getLoadedShipManagingPos((net.minecraft.server.level.ServerLevel) level, getBlockPos());
}
public boolean sit(Player player) {
LoadedServerShip ship = getShip();
if (ship == null) {
return false;
}
if (seat == null || !seat.isAlive()) {
seat = ValkyrienSkiesMod.SHIP_MOUNTING_ENTITY_TYPE.create(level);
if (seat == null) return false;
var facing = getBlockState().getValue(BlockStateProperties.HORIZONTAL_FACING);
Vec3 offset = Vec3.atLowerCornerOf(getBlockPos()).add(0.5, 1.1, 0.5).add(facing.getOpposite().getStepX() * 0.6, 0, facing.getOpposite().getStepZ() * 0.6);
seat.moveTo(offset.x, offset.y, offset.z);
seat.lookAt(EntityAnchorArgument.Anchor.EYES, offset.add(facing.getStepX(), 0, facing.getStepZ()));
seat.setController(true);
((net.minecraft.server.level.ServerLevel) level).addFreshEntityWithPassengers(seat);
}
return player.startRiding(seat, true);
}
}
@@ -0,0 +1,71 @@
package com.titanicmc.titanic.blockentity;
import com.titanicmc.titanic.registry.ModBlockEntities;
import com.titanicmc.titanic.ship.ShipUtil;
import net.minecraft.core.BlockPos;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
/**
* Tracks the rigging state of a {@link com.titanicmc.titanic.block.LiftingFrameBlock}.
* Once linked to a winch, {@link #serverTick(ServerLevel)} drives the raise sequence by
* asking {@link ShipUtil} to assemble the wreck into a Valkyrien Skies ship and apply a
* temporary lifting force until it breaches the surface.
*/
public class LiftingFrameBlockEntity extends BlockEntity {
private boolean linkedToWinch = false;
private BlockPos winchPos = null;
private boolean raiseStarted = false;
public LiftingFrameBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.LIFTING_FRAME.get(), pos, state);
}
public boolean isRigged(BlockState state) {
return state.getValue(com.titanicmc.titanic.block.LiftingFrameBlock.CABLES) >= 4;
}
public boolean isLinkedToWinch() {
return linkedToWinch;
}
public void linkToWinch(BlockPos winch) {
this.linkedToWinch = true;
this.winchPos = winch.immutable();
setChanged();
}
public void serverTick(ServerLevel level) {
if (raiseStarted || !linkedToWinch || winchPos == null) {
return;
}
if (!isRigged(getBlockState())) {
return;
}
raiseStarted = true;
ShipUtil.beginWreckRaise(level, getBlockPos());
}
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
tag.putBoolean("LinkedToWinch", linkedToWinch);
tag.putBoolean("RaiseStarted", raiseStarted);
if (winchPos != null) {
tag.putLong("WinchPos", winchPos.asLong());
}
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
linkedToWinch = tag.getBoolean("LinkedToWinch");
raiseStarted = tag.getBoolean("RaiseStarted");
if (tag.contains("WinchPos")) {
winchPos = BlockPos.of(tag.getLong("WinchPos"));
}
}
}
@@ -0,0 +1,211 @@
package com.titanicmc.titanic.blockentity;
import com.titanicmc.titanic.Config;
import com.titanicmc.titanic.registry.ModBlockEntities;
import com.titanicmc.titanic.registry.ModBlocks;
import com.titanicmc.titanic.registry.ModItems;
import com.titanicmc.titanic.ship.ShipUtil;
import com.titanicmc.titanic.ship.TitanicShipControl;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.chat.Component;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.tags.ItemTags;
import net.minecraft.world.entity.player.Player;
import net.minecraft.world.item.ItemStack;
import net.minecraft.world.item.Items;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import org.valkyrienskies.core.api.ships.LoadedServerShip;
import java.util.List;
import java.util.Set;
/**
* Generated as part of the wreck's bridge structure, so it automatically becomes part of the
* ship at assembly time. Players hand in dry dock repair materials here; every delivery
* proportionally patches the hull's damaged blocks and broken portholes. The final part,
* the ship's wheel, finishes the job by installing a real, working {@link com.titanicmc.titanic.block.HelmBlock}.
*/
public class RepairPanelBlockEntity extends BlockEntity {
private enum Material {
IRON, GLASS, PANES, REPEATERS, COMPARATORS, CHESTS, PLANKS, REDSTONE_BLOCKS
}
private final int[] delivered = new int[Material.values().length];
private boolean wheelDelivered = false;
private int hullTotal = -1;
private int portholeTotal = -1;
public RepairPanelBlockEntity(BlockPos pos, BlockState state) {
super(ModBlockEntities.REPAIR_PANEL.get(), pos, state);
}
public void handleDeliver(ServerLevel level, Player player, ItemStack stack) {
if (stack.is(ModItems.SHIPS_WHEEL_PART.get())) {
deliverWheel(level, player);
return;
}
Material material = classify(stack);
if (material == null) {
player.displayClientMessage(Component.translatable("message.titanic.repair_wrong_item"), true);
return;
}
int required = requiredFor(material);
int have = delivered[material.ordinal()];
if (have >= required) {
player.displayClientMessage(Component.translatable("message.titanic.repair_material_done"), true);
return;
}
int amount = Math.min(stack.getCount(), required - have);
stack.shrink(amount);
delivered[material.ordinal()] += amount;
setChanged();
player.displayClientMessage(Component.translatable("message.titanic.repair_delivered",
delivered[material.ordinal()], required), true);
applyRepairProgress(level);
}
private void deliverWheel(ServerLevel level, Player player) {
if (!isMaterialsComplete()) {
player.displayClientMessage(Component.translatable("message.titanic.repair_needs_materials_first"), true);
return;
}
if (wheelDelivered) {
player.displayClientMessage(Component.translatable("message.titanic.repair_already_done"), true);
return;
}
wheelDelivered = true;
setChanged();
finishRepair(level, player);
}
private boolean isMaterialsComplete() {
for (Material material : Material.values()) {
if (delivered[material.ordinal()] < requiredFor(material)) {
return false;
}
}
return true;
}
private void applyRepairProgress(ServerLevel level) {
int totalRequired = 0;
int totalDelivered = 0;
for (Material material : Material.values()) {
totalRequired += requiredFor(material);
totalDelivered += delivered[material.ordinal()];
}
double progress = totalRequired == 0 ? 1.0 : (double) totalDelivered / (double) totalRequired;
Set<BlockPos> shipBlocks = ShipUtil.collectConnectedBlocks(level, getBlockPos(), Config.MAX_SHIP_BLOCKS.get());
if (hullTotal < 0) {
hullTotal = ShipUtil.findBlocksOfType(level, shipBlocks, ModBlocks.DAMAGED_HULL.get()).size();
}
if (portholeTotal < 0) {
portholeTotal = ShipUtil.findBlocksOfType(level, shipBlocks, ModBlocks.BROKEN_PORTHOLE.get()).size();
}
convertRemaining(level, shipBlocks, ModBlocks.DAMAGED_HULL.get(), ModBlocks.SHIP_HULL.get().defaultBlockState(),
hullTotal, progress);
convertRemaining(level, shipBlocks, ModBlocks.BROKEN_PORTHOLE.get(), Blocks.GLASS_PANE.defaultBlockState(),
portholeTotal, progress);
}
private void convertRemaining(ServerLevel level, Set<BlockPos> shipBlocks, net.minecraft.world.level.block.Block from,
BlockState toState, int total, double progress) {
if (total <= 0) return;
List<BlockPos> remaining = ShipUtil.findBlocksOfType(level, shipBlocks, from);
int alreadyConverted = total - remaining.size();
int shouldBeConverted = (int) Math.floor(progress * total);
int toConvertNow = Math.max(0, shouldBeConverted - alreadyConverted);
for (int i = 0; i < toConvertNow && i < remaining.size(); i++) {
level.setBlock(remaining.get(i), toState, 3);
}
}
private void finishRepair(ServerLevel level, Player player) {
Set<BlockPos> shipBlocks = ShipUtil.collectConnectedBlocks(level, getBlockPos(), Config.MAX_SHIP_BLOCKS.get());
List<BlockPos> sockets = ShipUtil.findBlocksOfType(level, shipBlocks, ModBlocks.HELM_SOCKET.get());
for (BlockPos socketPos : sockets) {
Direction facing = level.getBlockState(socketPos).hasProperty(BlockStateProperties.HORIZONTAL_FACING)
? level.getBlockState(socketPos).getValue(BlockStateProperties.HORIZONTAL_FACING)
: Direction.NORTH;
level.setBlock(socketPos, ModBlocks.HELM.get().defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, facing), 3);
}
LoadedServerShip ship = ShipUtil.getLoadedShipManagingPos(level, getBlockPos());
if (ship != null) {
TitanicShipControl control = ship.getAttachment(TitanicShipControl.class);
if (control == null) {
control = new TitanicShipControl();
ship.setAttachment(TitanicShipControl.class, control);
}
control.markRepaired();
}
level.players().forEach(p -> p.displayClientMessage(Component.translatable("message.titanic.repair_complete"), false));
}
private static Material classify(ItemStack stack) {
if (stack.is(Items.IRON_INGOT)) return Material.IRON;
if (stack.is(Items.GLASS)) return Material.GLASS;
if (stack.is(Items.GLASS_PANE)) return Material.PANES;
if (stack.is(Items.REPEATER)) return Material.REPEATERS;
if (stack.is(Items.COMPARATOR)) return Material.COMPARATORS;
if (stack.getItem() instanceof net.minecraft.world.item.BlockItem blockItem
&& blockItem.getBlock() instanceof net.minecraft.world.level.block.ChestBlock) {
return Material.CHESTS;
}
if (stack.is(ItemTags.PLANKS)) return Material.PLANKS;
if (stack.is(Items.REDSTONE_BLOCK)) return Material.REDSTONE_BLOCKS;
return null;
}
private static int requiredFor(Material material) {
return switch (material) {
case IRON -> Config.DRY_DOCK_IRON_REQUIRED.get();
case GLASS -> Config.DRY_DOCK_GLASS_REQUIRED.get();
case PANES -> Config.DRY_DOCK_GLASS_PANES_REQUIRED.get();
case REPEATERS -> Config.DRY_DOCK_REPEATERS_REQUIRED.get();
case COMPARATORS -> Config.DRY_DOCK_COMPARATORS_REQUIRED.get();
case CHESTS -> Config.DRY_DOCK_CHESTS_REQUIRED.get();
case PLANKS -> Config.DRY_DOCK_PLANKS_REQUIRED.get();
case REDSTONE_BLOCKS -> Config.DRY_DOCK_REDSTONE_BLOCKS_REQUIRED.get();
};
}
@Override
protected void saveAdditional(CompoundTag tag) {
super.saveAdditional(tag);
tag.putIntArray("Delivered", delivered);
tag.putBoolean("WheelDelivered", wheelDelivered);
tag.putInt("HullTotal", hullTotal);
tag.putInt("PortholeTotal", portholeTotal);
}
@Override
public void load(CompoundTag tag) {
super.load(tag);
int[] loaded = tag.getIntArray("Delivered");
System.arraycopy(loaded, 0, delivered, 0, Math.min(loaded.length, delivered.length));
wheelDelivered = tag.getBoolean("WheelDelivered");
hullTotal = tag.contains("HullTotal") ? tag.getInt("HullTotal") : -1;
portholeTotal = tag.contains("PortholeTotal") ? tag.getInt("PortholeTotal") : -1;
}
}
@@ -0,0 +1,59 @@
package com.titanicmc.titanic.entity;
import net.minecraft.nbt.CompoundTag;
import net.minecraft.network.syncher.SynchedEntityData;
import net.minecraft.world.entity.Entity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.level.Level;
/**
* Invisible, non-solid mount used so a player can "sit" on a chair. Spawned on demand by
* {@link com.titanicmc.titanic.block.ChairBlock} and removed automatically once nobody is
* riding it.
*/
public class SeatEntity extends Entity {
public SeatEntity(EntityType<?> type, Level level) {
super(type, level);
this.noPhysics = true;
this.blocksBuilding = false;
}
@Override
public void tick() {
super.tick();
if (!level().isClientSide && this.getPassengers().isEmpty() && this.tickCount > 0) {
this.discard();
}
}
@Override
protected void defineSynchedData() {
// No synced data needed.
}
@Override
protected void readAdditionalSaveData(CompoundTag tag) {
// Nothing to persist.
}
@Override
protected void addAdditionalSaveData(CompoundTag tag) {
// Nothing to persist.
}
@Override
public boolean isPickable() {
return false;
}
@Override
public boolean isPushable() {
return false;
}
@Override
public boolean shouldBeSaved() {
return false;
}
}
@@ -0,0 +1,19 @@
package com.titanicmc.titanic.event;
import com.titanicmc.titanic.ship.ShipRaiseScheduler;
import net.minecraftforge.event.TickEvent;
import net.minecraftforge.eventbus.api.SubscribeEvent;
/**
* Cross-cutting game event handling. Registered on the Forge event bus in
* {@link com.titanicmc.titanic.TitanicMod}.
*/
public class ForgeEventHandler {
@SubscribeEvent
public void onServerTick(TickEvent.ServerTickEvent event) {
if (event.phase == TickEvent.Phase.END) {
ShipRaiseScheduler.tick();
}
}
}
@@ -0,0 +1,33 @@
package com.titanicmc.titanic.registry;
import com.titanicmc.titanic.TitanicMod;
import com.titanicmc.titanic.blockentity.BoilerBlockEntity;
import com.titanicmc.titanic.blockentity.HelmBlockEntity;
import com.titanicmc.titanic.blockentity.LiftingFrameBlockEntity;
import com.titanicmc.titanic.blockentity.RepairPanelBlockEntity;
import net.minecraft.world.level.block.entity.BlockEntityType;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
public class ModBlockEntities {
public static final DeferredRegister<BlockEntityType<?>> BLOCK_ENTITIES =
DeferredRegister.create(ForgeRegistries.BLOCK_ENTITY_TYPES, TitanicMod.MODID);
public static final RegistryObject<BlockEntityType<LiftingFrameBlockEntity>> LIFTING_FRAME =
BLOCK_ENTITIES.register("lifting_frame", () -> BlockEntityType.Builder.of(
LiftingFrameBlockEntity::new, ModBlocks.LIFTING_FRAME.get()).build(null));
public static final RegistryObject<BlockEntityType<RepairPanelBlockEntity>> REPAIR_PANEL =
BLOCK_ENTITIES.register("repair_panel", () -> BlockEntityType.Builder.of(
RepairPanelBlockEntity::new, ModBlocks.REPAIR_PANEL.get()).build(null));
public static final RegistryObject<BlockEntityType<HelmBlockEntity>> HELM =
BLOCK_ENTITIES.register("helm", () -> BlockEntityType.Builder.of(
HelmBlockEntity::new, ModBlocks.HELM.get()).build(null));
public static final RegistryObject<BlockEntityType<BoilerBlockEntity>> BOILER =
BLOCK_ENTITIES.register("boiler", () -> BlockEntityType.Builder.of(
BoilerBlockEntity::new, ModBlocks.BOILER.get()).build(null));
}
@@ -0,0 +1,23 @@
package com.titanicmc.titanic.registry;
import com.titanicmc.titanic.TitanicMod;
import net.minecraft.core.registries.Registries;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.tags.TagKey;
import net.minecraft.world.level.block.Block;
public class ModBlockTags {
private ModBlockTags() {
}
/**
* Every block that can legitimately be part of a generated Titanic wreck/ship: the hull
* plating itself plus all the furniture/fixture blocks {@link com.titanicmc.titanic.worldgen.WreckFeature}
* places inside it. Used to bound {@link com.titanicmc.titanic.ship.ShipUtil#collectConnectedBlocks}
* so a flood fill starting on the wreck can never spill out into the surrounding seafloor
* terrain the wreck happens to be resting on/against.
*/
public static final TagKey<Block> SHIP_BLOCKS = TagKey.create(Registries.BLOCK,
new ResourceLocation(TitanicMod.MODID, "ship_blocks"));
}
@@ -0,0 +1,105 @@
package com.titanicmc.titanic.registry;
import com.titanicmc.titanic.TitanicMod;
import com.titanicmc.titanic.block.BoilerBlock;
import com.titanicmc.titanic.block.ChairBlock;
import com.titanicmc.titanic.block.CrankshaftBlock;
import com.titanicmc.titanic.block.HelmBlock;
import com.titanicmc.titanic.block.HelmSocketBlock;
import com.titanicmc.titanic.block.LiftingFrameBlock;
import com.titanicmc.titanic.block.RepairPanelBlock;
import com.titanicmc.titanic.block.WinchBlock;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.SoundType;
import net.minecraft.world.level.block.state.BlockBehaviour;
import net.minecraft.world.level.material.MapColor;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
public class ModBlocks {
public static final DeferredRegister<Block> BLOCKS = DeferredRegister.create(ForgeRegistries.BLOCKS, TitanicMod.MODID);
/** Rusted, holed hull plating - generated by the wreck, converted to {@link #SHIP_HULL} on repair. */
public static final RegistryObject<Block> DAMAGED_HULL = BLOCKS.register("damaged_hull",
() -> new Block(BlockBehaviour.Properties.of()
.mapColor(MapColor.COLOR_BROWN)
.strength(4.0f, 20.0f)
.sound(SoundType.NETHERITE_BLOCK)
.requiresCorrectToolForDrops()));
/** Freshly patched hull plating - what {@link #DAMAGED_HULL} becomes once repaired. */
public static final RegistryObject<Block> SHIP_HULL = BLOCKS.register("ship_hull",
() -> new Block(BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(5.0f, 20.0f)
.sound(SoundType.METAL)
.requiresCorrectToolForDrops()));
/** A shattered porthole - generated by the wreck, converted to a glass pane on repair. */
public static final RegistryObject<Block> BROKEN_PORTHOLE = BLOCKS.register("broken_porthole",
() -> new Block(BlockBehaviour.Properties.of()
.mapColor(MapColor.COLOR_LIGHT_GRAY)
.strength(1.0f)
.sound(SoundType.GLASS)
.noOcclusion()));
/** Marks where the real helm will be installed once repairs finish. World-gen only. */
public static final RegistryObject<Block> HELM_SOCKET = BLOCKS.register("helm_socket",
() -> new HelmSocketBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOD)
.strength(3.0f)
.sound(SoundType.WOOD)
.noOcclusion()));
/** The real, working ship's wheel. */
public static final RegistryObject<Block> HELM = BLOCKS.register("helm",
() -> new HelmBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOD)
.strength(3.5f)
.sound(SoundType.WOOD)
.noOcclusion()));
public static final RegistryObject<Block> LIFTING_FRAME = BLOCKS.register("lifting_frame",
() -> new LiftingFrameBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(5.0f)
.sound(SoundType.METAL)
.noOcclusion()));
public static final RegistryObject<Block> WINCH = BLOCKS.register("winch",
() -> new WinchBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(5.0f)
.sound(SoundType.METAL)));
public static final RegistryObject<Block> REPAIR_PANEL = BLOCKS.register("repair_panel",
() -> new RepairPanelBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.COLOR_LIGHT_BLUE)
.strength(4.0f)
.sound(SoundType.METAL)
.lightLevel(state -> 6)));
public static final RegistryObject<Block> BOILER = BLOCKS.register("boiler",
() -> new BoilerBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.COLOR_BLACK)
.strength(6.0f, 20.0f)
.sound(SoundType.METAL)
.lightLevel(state -> 3)
.requiresCorrectToolForDrops()));
public static final RegistryObject<Block> CRANKSHAFT = BLOCKS.register("crankshaft",
() -> new CrankshaftBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.METAL)
.strength(6.0f, 20.0f)
.sound(SoundType.METAL)
.requiresCorrectToolForDrops()));
public static final RegistryObject<Block> CHAIR = BLOCKS.register("chair",
() -> new ChairBlock(BlockBehaviour.Properties.of()
.mapColor(MapColor.WOOD)
.strength(2.5f)
.sound(SoundType.WOOD)
.noOcclusion()));
}
@@ -0,0 +1,33 @@
package com.titanicmc.titanic.registry;
import com.titanicmc.titanic.TitanicMod;
import net.minecraft.core.registries.Registries;
import net.minecraft.network.chat.Component;
import net.minecraft.world.item.CreativeModeTab;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.RegistryObject;
public class ModCreativeTabs {
public static final DeferredRegister<CreativeModeTab> TABS =
DeferredRegister.create(Registries.CREATIVE_MODE_TAB, TitanicMod.MODID);
public static final RegistryObject<CreativeModeTab> TITANIC_TAB = TABS.register("titanic",
() -> CreativeModeTab.builder()
.title(Component.translatable("itemGroup.titanic"))
.icon(() -> ModItems.HELM.get().getDefaultInstance())
.displayItems((parameters, output) -> {
output.accept(ModItems.STEEL_INGOT.get());
output.accept(ModItems.STEEL_CABLE.get());
output.accept(ModItems.SHIPS_WHEEL_PART.get());
output.accept(ModItems.SHIP_HULL.get());
output.accept(ModItems.LIFTING_FRAME.get());
output.accept(ModItems.WINCH.get());
output.accept(ModItems.REPAIR_PANEL.get());
output.accept(ModItems.BOILER.get());
output.accept(ModItems.CRANKSHAFT.get());
output.accept(ModItems.CHAIR.get());
output.accept(ModItems.HELM.get());
})
.build());
}
@@ -0,0 +1,23 @@
package com.titanicmc.titanic.registry;
import com.titanicmc.titanic.TitanicMod;
import com.titanicmc.titanic.entity.SeatEntity;
import net.minecraft.world.entity.EntityType;
import net.minecraft.world.entity.MobCategory;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
public class ModEntities {
public static final DeferredRegister<EntityType<?>> ENTITIES =
DeferredRegister.create(ForgeRegistries.ENTITY_TYPES, TitanicMod.MODID);
public static final RegistryObject<EntityType<SeatEntity>> SEAT = ENTITIES.register("seat",
() -> EntityType.Builder.<SeatEntity>of(SeatEntity::new, MobCategory.MISC)
.sized(0.1f, 0.1f)
.noSummon()
.noSave()
.clientTrackingRange(6)
.build("seat"));
}
@@ -0,0 +1,19 @@
package com.titanicmc.titanic.registry;
import com.mojang.serialization.Codec;
import com.titanicmc.titanic.TitanicMod;
import com.titanicmc.titanic.worldgen.WreckFeature;
import net.minecraft.world.level.levelgen.feature.Feature;
import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
public class ModFeatures {
public static final DeferredRegister<Feature<?>> FEATURES =
DeferredRegister.create(ForgeRegistries.FEATURES, TitanicMod.MODID);
public static final RegistryObject<Feature<NoneFeatureConfiguration>> WRECK =
FEATURES.register("wreck", () -> new WreckFeature(NoneFeatureConfiguration.CODEC));
}
@@ -0,0 +1,48 @@
package com.titanicmc.titanic.registry;
import com.titanicmc.titanic.TitanicMod;
import net.minecraft.world.item.BlockItem;
import net.minecraft.world.item.Item;
import net.minecraftforge.registries.DeferredRegister;
import net.minecraftforge.registries.ForgeRegistries;
import net.minecraftforge.registries.RegistryObject;
public class ModItems {
public static final DeferredRegister<Item> ITEMS = DeferredRegister.create(ForgeRegistries.ITEMS, TitanicMod.MODID);
/** Smelted from iron; used to craft steel cables and hull-repair adjacent recipes. */
public static final RegistryObject<Item> STEEL_INGOT = ITEMS.register("steel_ingot",
() -> new Item(new Item.Properties()));
/**
* Threaded through a Lifting Frame's four eyes and then joined to a Winch to raise the wreck.
* Consumed on use.
*/
public static final RegistryObject<Item> STEEL_CABLE = ITEMS.register("steel_cable",
() -> new Item(new Item.Properties()));
/**
* The final part delivered to the Repair Panel. Consuming it slots a real,
* controllable helm into the wreck's bridge and finishes the repair.
*/
public static final RegistryObject<Item> SHIPS_WHEEL_PART = ITEMS.register("ships_wheel",
() -> new Item(new Item.Properties()));
public static final RegistryObject<Item> SHIP_HULL = ITEMS.register("ship_hull",
() -> new BlockItem(ModBlocks.SHIP_HULL.get(), new Item.Properties()));
public static final RegistryObject<Item> LIFTING_FRAME = ITEMS.register("lifting_frame",
() -> new BlockItem(ModBlocks.LIFTING_FRAME.get(), new Item.Properties()));
public static final RegistryObject<Item> WINCH = ITEMS.register("winch",
() -> new BlockItem(ModBlocks.WINCH.get(), new Item.Properties()));
public static final RegistryObject<Item> REPAIR_PANEL = ITEMS.register("repair_panel",
() -> new BlockItem(ModBlocks.REPAIR_PANEL.get(), new Item.Properties()));
public static final RegistryObject<Item> BOILER = ITEMS.register("boiler",
() -> new BlockItem(ModBlocks.BOILER.get(), new Item.Properties()));
public static final RegistryObject<Item> CRANKSHAFT = ITEMS.register("crankshaft",
() -> new BlockItem(ModBlocks.CRANKSHAFT.get(), new Item.Properties()));
public static final RegistryObject<Item> CHAIR = ITEMS.register("chair",
() -> new BlockItem(ModBlocks.CHAIR.get(), new Item.Properties()));
public static final RegistryObject<Item> HELM = ITEMS.register("helm",
() -> new BlockItem(ModBlocks.HELM.get(), new Item.Properties()));
}
@@ -0,0 +1,79 @@
package com.titanicmc.titanic.ship;
import com.titanicmc.titanic.TitanicMod;
import net.minecraft.server.level.ServerLevel;
import org.valkyrienskies.core.api.ships.LoadedServerShip;
import org.valkyrienskies.core.api.world.ServerShipWorld;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
import java.util.ArrayList;
import java.util.Iterator;
import java.util.List;
/**
* Freshly {@link ShipUtil#assembleWreck assembled} ships are not always a
* {@link LoadedServerShip} on the very tick they're created - Valkyrien Skies relocates the
* collected blocks into a brand new ship storage area, and that area only becomes "loaded"
* once VS2 finishes registering it, which can take a tick or more.
* <p>
* Rather than giving up immediately when that happens, pending raises are tracked here by the
* ship's stable {@code long} id (not by {@link net.minecraft.core.BlockPos}, since the seed
* position's block may itself have just been physically relocated as part of assembly) and
* retried every server tick until the ship reports itself loaded, or a generous timeout is hit.
*/
public final class ShipRaiseScheduler {
/** ~30 seconds at 20 TPS. VS2 normally finishes loading within a tick or two. */
private static final int MAX_WAIT_TICKS = 600;
private static final List<Pending> PENDING = new ArrayList<>();
private ShipRaiseScheduler() {
}
/** Queues an already-assembled-but-not-yet-loaded ship to begin raising once it loads. */
public static void schedule(ServerLevel level, long shipId, double targetWorldY) {
PENDING.add(new Pending(level, shipId, targetWorldY));
}
/** Call once per server tick (see {@link com.titanicmc.titanic.event.ForgeEventHandler}). */
public static void tick() {
if (PENDING.isEmpty()) {
return;
}
Iterator<Pending> it = PENDING.iterator();
while (it.hasNext()) {
Pending pending = it.next();
ServerShipWorld shipWorld = VSGameUtilsKt.getShipObjectWorld(pending.level.getServer());
LoadedServerShip ship = shipWorld.getLoadedShips().getById(pending.shipId);
if (ship != null) {
TitanicMod.LOGGER.info(
"Ship {} finished loading after {} ticks - beginning raise to y={}",
pending.shipId, pending.ticksWaited, pending.targetWorldY);
ShipUtil.beginRaiseSafely(pending.level, ship, pending.targetWorldY);
it.remove();
continue;
}
pending.ticksWaited++;
if (pending.ticksWaited > MAX_WAIT_TICKS) {
TitanicMod.LOGGER.warn(
"Gave up waiting for assembled ship {} to finish loading for raising after {} ticks",
pending.shipId, pending.ticksWaited);
it.remove();
}
}
}
private static final class Pending {
final ServerLevel level;
final long shipId;
final double targetWorldY;
int ticksWaited = 0;
Pending(ServerLevel level, long shipId, double targetWorldY) {
this.level = level;
this.shipId = shipId;
this.targetWorldY = targetWorldY;
}
}
}
@@ -0,0 +1,211 @@
package com.titanicmc.titanic.ship;
import com.titanicmc.titanic.Config;
import com.titanicmc.titanic.TitanicMod;
import com.titanicmc.titanic.registry.ModBlockTags;
import net.minecraft.core.BlockPos;
import net.minecraft.server.level.ServerLevel;
import net.minecraft.server.level.ServerPlayer;
import net.minecraft.world.level.block.Block;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.phys.AABB;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import org.joml.primitives.AABBdc;
import org.valkyrienskies.core.api.ships.LoadedServerShip;
import org.valkyrienskies.core.api.ships.ServerShip;
import org.valkyrienskies.mod.common.VSGameUtilsKt;
import org.valkyrienskies.mod.common.assembly.ShipAssembler;
/**
* All direct interop with the Valkyrien Skies 2 API lives here, so any future API changes
* only need to be fixed in one place.
* <p>
* Important VS2 quirk this class relies on: once a structure is assembled into a ship, every
* one of its blocks keeps living at its original ("storage") {@link BlockPos} for as long as
* the ship exists, even though the ship visually moves elsewhere in the world. Any BlockPos
* handed to us through normal block/interaction callbacks (place, use, block entity ticking)
* is always already in that stable storage space - Valkyrien Skies transparently remaps
* rendering, physics and click-raycasts for us. That means code elsewhere in this mod never
* needs to special-case "is this block on a ship" positions; it can use them exactly like any
* other {@link BlockPos}.
*/
public final class ShipUtil {
private ShipUtil() {
}
/**
* Returns the ship that manages storage of the block at {@code pos}, or {@code null} if
* that position isn't part of any assembled ship.
*/
public static ServerShip getShipManagingPos(ServerLevel level, BlockPos pos) {
return VSGameUtilsKt.getShipManagingPos(level, pos);
}
/**
* Same as {@link #getShipManagingPos}, but only returns a result if the ship is currently
* loaded (required to read/write ship attachments like {@link TitanicShipControl}).
*/
public static LoadedServerShip getLoadedShipManagingPos(ServerLevel level, BlockPos pos) {
ServerShip ship = getShipManagingPos(level, pos);
return ship instanceof LoadedServerShip loaded ? loaded : null;
}
/**
* Flood-fills every block tagged {@link ModBlockTags#SHIP_BLOCKS} connected to {@code seed}
* (up to the configured safety limit). Used both to gather the initial wreck for assembly
* and to re-scan an already assembled ship's blocks (e.g. to find remaining damaged blocks).
* <p>
* Only blocks that are actually part of a Titanic wreck/ship qualify - anything else (air,
* fluids, and, importantly, the natural seafloor terrain a wreck rests on/against) acts as a
* boundary the fill does not cross. Without this restriction a seed touching bare stone,
* sand, or gravel would keep flooding through the entire connected landmass until it hit
* {@code maxBlocks}, producing a huge, malformed "ship" instead of just the wreck.
* <p>
* {@code seed} itself is always accepted and expanded from regardless of that tag, even if
* it isn't a wreck block - callers seed this from player-placed attachment points like the
* {@link com.titanicmc.titanic.block.LiftingFrameBlock} lowered over the wreck, which need
* to bridge into the tagged wreck blocks touching them rather than being rejected outright.
*/
public static Set<BlockPos> collectConnectedBlocks(ServerLevel level, BlockPos seed, int maxBlocks) {
Set<BlockPos> collected = new HashSet<>();
Set<Long> visited = new HashSet<>();
ArrayDeque<BlockPos> queue = new ArrayDeque<>();
BlockPos immutableSeed = seed.immutable();
queue.add(immutableSeed);
visited.add(immutableSeed.asLong());
while (!queue.isEmpty() && collected.size() < maxBlocks) {
BlockPos pos = queue.poll();
BlockState state = level.getBlockState(pos);
boolean isSeed = pos.equals(immutableSeed);
if (!isSeed && !state.is(ModBlockTags.SHIP_BLOCKS)) {
continue;
}
if (state.isAir() || !state.getFluidState().isEmpty()) {
continue;
}
collected.add(pos);
for (int dx = -1; dx <= 1; dx++) {
for (int dy = -1; dy <= 1; dy++) {
for (int dz = -1; dz <= 1; dz++) {
if (dx == 0 && dy == 0 && dz == 0) continue;
BlockPos next = pos.offset(dx, dy, dz);
long key = next.asLong();
if (visited.add(key)) {
queue.add(next);
}
}
}
}
}
return collected;
}
/** Filters a previously collected block set down to positions currently holding {@code block}. */
public static List<BlockPos> findBlocksOfType(ServerLevel level, Set<BlockPos> allBlocks, Block block) {
List<BlockPos> found = new ArrayList<>();
for (BlockPos pos : allBlocks) {
if (level.getBlockState(pos).is(block)) {
found.add(pos);
}
}
return found;
}
/**
* Flood-fills every {@link ModBlockTags#SHIP_BLOCKS}-tagged block connected to {@code seed}
* and hands the collected region to Valkyrien Skies so it becomes one real, physically
* simulated ship.
*/
public static ServerShip assembleWreck(ServerLevel level, BlockPos seed) {
Set<BlockPos> collected = collectConnectedBlocks(level, seed, Config.MAX_SHIP_BLOCKS.get());
if (collected.isEmpty()) {
return null;
}
TitanicMod.LOGGER.info("Assembling Titanic hull with {} blocks", collected.size());
return ShipAssembler.assembleToShip(level, collected, 1.0);
}
/** Kicks off the raise sequence: assembles the wreck and applies lifting force until it surfaces. */
public static void beginWreckRaise(ServerLevel level, BlockPos seed) {
ServerShip assembled = getShipManagingPos(level, seed);
if (assembled == null) {
assembled = assembleWreck(level, seed);
}
if (assembled == null) {
TitanicMod.LOGGER.warn("Failed to assemble wreck at {} for raising", seed);
return;
}
double targetWorldY = level.getSeaLevel() + 1;
TitanicMod.LOGGER.info("Wreck assembled as ship id={} (already loaded={}), raise target y={}",
assembled.getId(), assembled instanceof LoadedServerShip, targetWorldY);
if (assembled instanceof LoadedServerShip ship) {
beginRaiseSafely(level, ship, targetWorldY);
} else {
// A freshly assembled ship isn't always immediately Loaded - VS2 relocates the
// collected blocks into a new ship storage area that can take a tick or more to
// finish registering. Retry by stable ship id until it comes online instead of
// failing outright (see ShipRaiseScheduler for why id, not BlockPos, is used).
ShipRaiseScheduler.schedule(level, assembled.getId(), targetWorldY);
}
}
/**
* Pushes any player standing on/near the ship safely clear before starting the raise, then
* begins it.
* <p>
* The raise repeatedly re-asserts the ship's position directly (see
* {@link TitanicShipControl}), which was observed to violently launch a player standing on
* the hull ~100 blocks away over the course of the raise - each of those position
* corrections appears to trigger Minecraft's own "entity is stuck inside a block" pushback
* against the player, and dozens of small pushes over a few seconds add up fast. Moving
* players safely above the ship first avoids that class of collision entirely.
*/
public static void beginRaiseSafely(ServerLevel level, LoadedServerShip ship, double targetWorldY) {
pushPlayersClear(level, ship);
getOrCreateControl(ship).beginRaise(targetWorldY);
}
/** Teleports any player inside/near the ship's world AABB to a safe spot directly above it. */
private static void pushPlayersClear(ServerLevel level, LoadedServerShip ship) {
AABBdc shipAabb = ship.getWorldAABB();
AABB searchArea = new AABB(
shipAabb.minX(), shipAabb.minY(), shipAabb.minZ(),
shipAabb.maxX(), shipAabb.maxY(), shipAabb.maxZ())
.inflate(4.0);
double safeY = shipAabb.maxY() + 10.0;
for (ServerPlayer player : level.getEntitiesOfClass(ServerPlayer.class, searchArea)) {
if (player.getY() < safeY) {
player.teleportTo(player.getX(), safeY, player.getZ());
player.setDeltaMovement(0.0, 0.0, 0.0);
player.fallDistance = 0.0f;
}
}
}
/** Gets or creates the {@link TitanicShipControl} attachment for a loaded ship. */
public static TitanicShipControl getOrCreateControl(LoadedServerShip ship) {
TitanicShipControl control = ship.getAttachment(TitanicShipControl.class);
if (control == null) {
control = new TitanicShipControl();
ship.setAttachment(TitanicShipControl.class, control);
}
// Keep this in sync unconditionally (cheap) so a valid LoadedServerShip reference is
// always available for direct transform manipulation (see TitanicShipControl.onServerTick),
// not just once a Helm block entity happens to refresh it.
control.ship = ship;
return control;
}
}
@@ -0,0 +1,230 @@
package com.titanicmc.titanic.ship;
import com.titanicmc.titanic.TitanicMod;
import org.joml.Vector3d;
import org.joml.Vector3dc;
import org.valkyrienskies.core.api.ships.LoadedServerShip;
import org.valkyrienskies.core.api.ships.PhysShip;
import org.valkyrienskies.core.api.ships.ServerTickListener;
import org.valkyrienskies.core.api.ships.ShipPhysicsListener;
import org.valkyrienskies.core.api.ships.properties.ShipTransform;
import org.valkyrienskies.core.api.world.PhysLevel;
import org.valkyrienskies.mod.api.SeatedControllingPlayer;
/**
* Per-ship "brain" attached to every assembled Titanic hull. Handles three responsibilities:
* <ul>
* <li>Raising: steps the hull's position directly upward each tick until it breaches the
* surface (see {@link #onServerTick}).</li>
* <li>Buoyancy assist: while not yet repaired, the wreck is full of holes and shouldn't
* realistically float - we cancel out net gravity so it doesn't sink while under tow.</li>
* <li>Propulsion: Boilers (with an adjacent Crankshaft) feed {@link #addEnginePower(double)}
* every tick; that power is turned into forward thrust in the direction the helm is
* facing, and steering torque from the helm's turn input.</li>
* </ul>
*/
public final class TitanicShipControl implements ShipPhysicsListener, ServerTickListener {
private static final double GRAVITY = 10.0;
/**
* Linear damping applied to vertical velocity every physics tick (see {@link #physTick}).
* Keeps any velocity the hull picks up (from collisions, residual momentum, etc.) from
* building up and coasting the hull upward/downward forever once gravity is cancelled below.
*/
private static final double VERTICAL_DAMPING = 2.0;
/**
* Blocks climbed per server tick while raising (20 ticks/sec, so ~3 blocks/sec).
* <p>
* Raising is driven by directly stepping the ship's transform via
* {@link org.valkyrienskies.core.api.ships.ServerShip#unsafeSetTransform} rather than by
* applying physics forces. Force-based raising (mass * constant acceleration, every physics
* tick) proved wildly unreliable for hulls this large: observed overshoot ranged from a few
* blocks to 600+ blocks past the target in under a second, wildly out of proportion to the
* force actually intended - almost certainly some multiplier/timestep mismatch specific to
* hulls with this many blocks/this much mass. Direct, small, per-tick position steps sidestep
* that whole class of problem entirely.
*/
private static final double RAISE_SPEED_PER_TICK = 0.15;
private volatile boolean raising = false;
private volatile double raiseTargetWorldY = 62.0;
private volatile boolean repaired = false;
private double pendingEnginePower = 0.0;
private double enginePower = 0.0;
private int raiseTickCounter = 0;
private boolean loggedInvalidMass = false;
/**
* Our own authoritative X/Y/Z for the ship while raising, forced onto it every tick via
* {@link org.valkyrienskies.core.api.ships.ServerShip#unsafeSetTransform} regardless of
* whatever position VS2's physics/collision engine may have produced in between our ticks.
* <p>
* The read-back position ({@code getTransform()}) was observed to become wildly corrupted
* mid-raise (Y jumping to values in the tens of thousands, even decreasing between ticks,
* which our own upward-only stepping could never do) - a bug in VS2 itself when a
* freshly-assembled hull's collision is resolved against the terrain/water it was just cut
* out of. Trusting that read-back to decide "where to step from next" let the corruption
* compound. Tracking our own value here and always re-asserting it sidesteps that entirely:
* whatever VS2 does to the ship between our ticks is simply overwritten back to where we
* expect it to be on the very next tick.
*/
private double raiseX = Double.NaN;
private double raiseY = Double.NaN;
private double raiseZ = Double.NaN;
/** Refreshed every game tick by the Helm block entity so physTick can find the seated player. */
public volatile LoadedServerShip ship;
public void beginRaise(double targetWorldY) {
this.raising = true;
this.raiseTargetWorldY = targetWorldY;
// NaN sentinel: onServerTick seeds these from the ship's real position on its first
// raising tick, then owns them exclusively from there on (see field javadoc above).
this.raiseX = Double.NaN;
this.raiseY = Double.NaN;
this.raiseZ = Double.NaN;
TitanicMod.LOGGER.info("TitanicShipControl.beginRaise: target y={}", targetWorldY);
}
public boolean isRaising() {
return raising;
}
public void markRepaired() {
this.repaired = true;
}
public boolean isRepaired() {
return repaired;
}
/** Called by a Boiler/Crankshaft pair every game tick to contribute propulsion power. */
public void addEnginePower(double power) {
pendingEnginePower += power;
}
@Override
public void onServerTick() {
enginePower = pendingEnginePower;
pendingEnginePower = 0.0;
if (!raising) {
return;
}
LoadedServerShip currentShip = this.ship;
if (currentShip == null) {
return;
}
ShipTransform transform = currentShip.getTransform();
if (Double.isNaN(raiseY)) {
// First tick of this raise - seed our own tracked position from the real one once.
Vector3dc startPos = transform.getPosition();
raiseX = startPos.x();
raiseY = startPos.y();
raiseZ = startPos.z();
}
if (raiseY >= raiseTargetWorldY) {
raising = false;
TitanicMod.LOGGER.info("Raising complete at y={}", raiseY);
return;
}
raiseY = Math.min(raiseY + RAISE_SPEED_PER_TICK, raiseTargetWorldY);
Vector3d newPos = new Vector3d(raiseX, raiseY, raiseZ);
ShipTransform newTransform =
transform.copy(newPos, transform.getPositionInModel(), transform.getRotation(), transform.getScaling());
currentShip.unsafeSetTransform(newTransform);
if (raiseTickCounter++ % 20 == 0) {
TitanicMod.LOGGER.info("Raising: y={} target={} (actual ship-reported y={})",
raiseY, raiseTargetWorldY, transform.getPosition().y());
}
}
@Override
public void physTick(PhysShip physShip, PhysLevel physLevel) {
double mass = physShip.getMass();
if (!(mass > 0.0) || !Double.isFinite(mass)) {
// A ship's inertia data can momentarily be unset/invalid right as it finishes
// loading. Applying forces computed from a bogus mass (0, NaN, Infinity) would
// inject garbage into the physics engine, which can corrupt or silently delete the
// ship - skip this tick entirely rather than risk that.
if (!loggedInvalidMass) {
TitanicMod.LOGGER.warn("TitanicShipControl.physTick: skipping tick(s), invalid mass={}", mass);
loggedInvalidMass = true;
}
return;
}
// Valkyrien Skies has its own built-in fluid buoyancy simulation, which was fighting
// our own hand-tuned gravity-cancel/raise force below - both were pushing the hull
// upward at once, and depending on how much of the hull happened to be submerged that
// combination produced wildly inconsistent, sometimes huge, overshoot (observed
// anywhere from a few blocks to 150+ blocks past the raise target). We want full manual
// control over vertical motion here, so disable VS2's automatic buoyancy/fluid drag
// entirely and let our own forces be the only thing acting on it.
physShip.setBuoyantFactor(0.0);
physShip.setDoFluidDrag(false);
// NOTE: marking the ship static while raising was tried here to stop VS2's own
// physics/collision engine from fighting our unsafeSetTransform calls. It backfired in a
// different way than expected: our position updates DID appear to take effect visually
// for the player (the hull was seen rising for a few seconds), but the physics engine's
// own internal position never actually moved (confirmed via logging - it stayed pinned
// at the pre-raise value for the entire raise) and un-staticing at the end snapped
// everything back to that stale position, which is what looked like the ship
// "disappearing" right after the visible rise. VS2's own reference usage of
// unsafeSetTransform (its ShipCreatorItem) never touches setStatic either, so we don't
// either - we stay dynamic and simply re-assert our own tracked position every tick in
// onServerTick (see the raiseX/Y/Z field javadoc), unconditionally overwriting whatever
// the physics engine did to it in between. Now that beginRaiseSafely moves nearby
// players out of the way first, the engine's own uncontrolled collision response during
// that tug-of-war can no longer launch anyone even if it still happens.
Vector3dc centerOfMass = physShip.getCenterOfMass();
// While actively raising, position is driven directly in onServerTick via
// unsafeSetTransform - don't also fight it with gravity-cancel force here.
if (!raising && !repaired) {
// Cancel gravity so the patched-up hull doesn't sink under tow.
physShip.applyWorldForce(new Vector3d(0.0, mass * GRAVITY, 0.0), centerOfMass);
}
// Always bleed off vertical velocity a little so any residual momentum (from
// collisions, buoyancy remnants, etc.) doesn't coast the hull upward/downward forever
// once other vertical forces are balanced out.
double verticalVelocity = physShip.getVelocity().y();
physShip.applyWorldForce(new Vector3d(0.0, -mass * verticalVelocity * VERTICAL_DAMPING, 0.0), centerOfMass);
LoadedServerShip currentShip = this.ship;
SeatedControllingPlayer controller =
currentShip != null ? currentShip.getAttachment(SeatedControllingPlayer.class) : null;
if (enginePower > 0.0001 && controller != null) {
Vector3d forwardDirection = new Vector3d(
controller.getSeatInDirection().getStepX(),
0.0,
controller.getSeatInDirection().getStepZ());
physShip.getTransform().getShipToWorldRotation().transform(forwardDirection);
forwardDirection.normalize();
Vector3d thrust = forwardDirection.mul(enginePower * controller.getForwardImpulse());
physShip.applyWorldForce(thrust, centerOfMass);
float turn = controller.getLeftImpulse();
if (Math.abs(turn) > 0.0001) {
double turnStrength = enginePower * 0.02 * turn;
physShip.applyWorldTorque(new Vector3d(0.0, turnStrength, 0.0));
}
}
// Always damp angular velocity a little so the hull doesn't spin forever.
Vector3dc omega = physShip.getAngularVelocity();
physShip.applyWorldTorque(new Vector3d(omega).mul(-mass * 0.02));
}
}
@@ -0,0 +1,173 @@
package com.titanicmc.titanic.worldgen;
import com.mojang.serialization.Codec;
import com.titanicmc.titanic.registry.ModBlocks;
import net.minecraft.core.BlockPos;
import net.minecraft.core.Direction;
import net.minecraft.resources.ResourceLocation;
import net.minecraft.util.RandomSource;
import net.minecraft.world.level.WorldGenLevel;
import net.minecraft.world.level.block.Blocks;
import net.minecraft.world.level.block.entity.BlockEntity;
import net.minecraft.world.level.block.state.BlockState;
import net.minecraft.world.level.block.state.properties.BlockStateProperties;
import net.minecraft.world.level.levelgen.feature.Feature;
import net.minecraft.world.level.levelgen.feature.FeaturePlaceContext;
import net.minecraft.world.level.levelgen.feature.configurations.NoneFeatureConfiguration;
import net.minecraftforge.fml.ModList;
import net.minecraftforge.registries.ForgeRegistries;
/**
* Procedurally builds a stylised, single-piece Titanic wreck lying on the seafloor. This is
* deliberately a simplified silhouette (a tapered hull with a bow, a few interior rooms, and
* marked bridge/engine areas) rather than a 1:1 scale replica - it's generated entirely in
* code, with no schematic/NBT structure file and no player-visible "assembly" of separate
* pieces.
*/
public class WreckFeature extends Feature<NoneFeatureConfiguration> {
private static final int LENGTH = 48;
private static final int WIDTH = 9;
private static final int HEIGHT = 12;
private static final int BOW_TAPER = LENGTH / 4;
public WreckFeature(Codec<NoneFeatureConfiguration> codec) {
super(codec);
}
@Override
public boolean place(FeaturePlaceContext<NoneFeatureConfiguration> context) {
WorldGenLevel level = context.level();
RandomSource random = context.random();
BlockPos origin = anchorToChunk(context.origin());
int centerZ = WIDTH / 2;
for (int x = 0; x < LENGTH; x++) {
int halfWidth = halfWidthAt(x);
boolean bowTip = x == 0;
boolean sternWall = x == LENGTH - 1;
for (int y = 0; y < HEIGHT; y++) {
boolean keel = y == 0;
boolean deck = y == HEIGHT - 1;
for (int z = centerZ - halfWidth; z <= centerZ + halfWidth; z++) {
boolean sideWall = z == centerZ - halfWidth || z == centerZ + halfWidth;
boolean shell = bowTip || sternWall || keel || deck || sideWall;
BlockPos pos = origin.offset(x, y, z - centerZ);
if (!shell) {
// Hollow interior.
level.setBlock(pos, Blocks.WATER.defaultBlockState(), 2);
continue;
}
if (deck && random.nextFloat() < 0.06f) {
// A few holes punched through the deck.
level.setBlock(pos, Blocks.WATER.defaultBlockState(), 2);
continue;
}
if (sideWall && !bowTip && !sternWall && y == HEIGHT / 2 && z % 3 == 0) {
level.setBlock(pos, ModBlocks.BROKEN_PORTHOLE.get().defaultBlockState(), 2);
continue;
}
if (shell && random.nextFloat() < 0.1f && !keel) {
// Damage: leave a gap in the hull plating.
continue;
}
level.setBlock(pos, ModBlocks.DAMAGED_HULL.get().defaultBlockState(), 2);
}
}
}
buildEngineRoom(level, origin, centerZ, random);
buildGalleyAndCabins(level, origin, centerZ, random);
buildBridge(level, origin, centerZ);
return true;
}
/**
* The placement modifiers ({@code in_square} + {@code heightmap}) hand us an origin whose
* X/Z can land anywhere within the chunk currently being decorated. Since the hull is
* {@link #LENGTH} (48) blocks long in X - three chunks' worth - building it forward from
* that arbitrary in-chunk X would routinely write into chunks more than one chunk away from
* the decorating chunk, which is exactly what trips vanilla's "Detected setBlock in a far
* chunk" sanity check (a plain {@link Feature}'s {@link WorldGenLevel} is only guaranteed
* safe for writes within one chunk of the one being decorated).
* <p>
* To stay inside that safe window regardless of where in its chunk the origin happens to
* fall, the hull is anchored to the decorating chunk's own boundary instead of to the
* origin's exact X: it always starts one full chunk before that chunk and runs for exactly
* three chunks (48 blocks), i.e. spanning the decorating chunk minus-one through plus-one -
* precisely the safe range - every time. Only X is adjusted; Y (from the heightmap sample)
* and Z (the hull is only {@link #WIDTH} blocks wide, well under one chunk) are left as-is.
*/
private static BlockPos anchorToChunk(BlockPos origin) {
int chunkStartX = origin.getX() - Math.floorMod(origin.getX(), 16);
return new BlockPos(chunkStartX - 16, origin.getY(), origin.getZ());
}
private static int halfWidthAt(int x) {
int half = WIDTH / 2;
if (x < BOW_TAPER) {
int taperedHalf = Math.round(half * ((x + 1f) / BOW_TAPER));
return Math.max(1, taperedHalf);
}
return half;
}
private void buildEngineRoom(WorldGenLevel level, BlockPos origin, int centerZ, RandomSource random) {
int startX = LENGTH / 4 + 2;
int floorY = 1;
for (int i = 0; i < 3; i++) {
int x = startX + i * 2;
BlockPos boilerPos = origin.offset(x, floorY, 0);
level.setBlock(boilerPos, ModBlocks.BOILER.get().defaultBlockState(), 2);
level.setBlock(boilerPos.relative(Direction.SOUTH), ModBlocks.CRANKSHAFT.get().defaultBlockState(), 2);
}
}
private void buildGalleyAndCabins(WorldGenLevel level, BlockPos origin, int centerZ, RandomSource random) {
int x = (int) (LENGTH * 0.62);
int floorY = 1;
BlockState cookingBlock = getFarmersDelightCookingBlock();
level.setBlock(origin.offset(x, floorY, -2), cookingBlock, 2);
level.setBlock(origin.offset(x, floorY, 2), ModBlocks.CHAIR.get().defaultBlockState(), 2);
level.setBlock(origin.offset(x + 1, floorY, 2), ModBlocks.CHAIR.get().defaultBlockState(), 2);
int cabinX = (int) (LENGTH * 0.72);
level.setBlock(origin.offset(cabinX, floorY, -1), Blocks.RED_BED.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST)
.setValue(BlockStateProperties.BED_PART, net.minecraft.world.level.block.state.properties.BedPart.FOOT), 2);
level.setBlock(origin.offset(cabinX + 1, floorY, -1), Blocks.RED_BED.defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.EAST)
.setValue(BlockStateProperties.BED_PART, net.minecraft.world.level.block.state.properties.BedPart.HEAD), 2);
}
private void buildBridge(WorldGenLevel level, BlockPos origin, int centerZ) {
int x = LENGTH - 6;
int floorY = HEIGHT - 3;
level.setBlock(origin.offset(x, floorY, 0), ModBlocks.HELM_SOCKET.get().defaultBlockState()
.setValue(BlockStateProperties.HORIZONTAL_FACING, Direction.WEST), 2);
level.setBlock(origin.offset(x - 1, floorY, 0), ModBlocks.REPAIR_PANEL.get().defaultBlockState(), 2);
}
private static BlockState getFarmersDelightCookingBlock() {
if (ModList.get().isLoaded("farmersdelight")) {
var block = ForgeRegistries.BLOCKS.getValue(new ResourceLocation("farmersdelight", "cooking_pot"));
if (block != null) {
return block.defaultBlockState();
}
}
return Blocks.FURNACE.defaultBlockState();
}
}