Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,18 @@ time. Install the updated plugin and matching ServerAssets vehicle YAML,
including `behaviour.train.locomotive: true` on the locomotive. Plugin updates
do not overwrite existing vehicle configurations.

## Track ends

Set `behaviour.train.wheel-bones` to the model bones at the frontmost and rearmost
axle pivots. Their model positions and scale determine where rail support ends.
The whole train stops before either axle runs past an open track end, in either
direction; cars already overhanging can drive back onto the rails. Connected
junctions and loop seams remain traversable.

Install the matching ServerAssets vehicle YAML with this plugin update to check
the outer axles of the locomotive, coal car, passenger car, and flat car. Without
`wheel-bones`, configured bogie pivots are checked; rigid cars retain centre checks.

## Walkable decks

Train cars can have a deck that players walk on, and players standing on it ride
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,7 @@ public class TrainHandler {
private final ThrottleTape.DwellState tapeDwell = new ThrottleTape.DwellState();
// Blocks across the wheels. With it, the move animations turn at the train's speed.
private double wheelDiameter;
private final List<String> wheelBones = new ArrayList<>();
// Two-bogie carriages rest on the rail under each bogie instead of their centre.
private Bogies bogies;
// A roof or other surface players can walk on, and the boxes that make it solid.
Expand All @@ -104,6 +105,7 @@ public class TrainHandler {
public TrainHandler(ConfigurationSection config) {
locomotive = config.getBoolean("locomotive", false);
wheelDiameter = Math.max(0, config.getDouble("wheel-diameter", 0));
wheelBones.addAll(config.getStringList("wheel-bones"));
if (config.contains("bogies")) {
bogies = new Bogies(config.getStringList("bogies"));
}
Expand Down Expand Up @@ -132,6 +134,7 @@ public TrainHandler(ConfigurationSection config) {
public TrainHandler(ActiveVehicle v, TrainHandler another) {
locomotive = another.locomotive;
wheelDiameter = another.wheelDiameter;
wheelBones.addAll(another.wheelBones);
this.v = v;
if (another.bogies != null) {
bogies = new Bogies(v, another.bogies);
Expand Down Expand Up @@ -805,16 +808,52 @@ private void followTrackUnderEntity() {
}

private record CarPlacement(ActiveVehicle vehicle, TrackSpline spline, double s, int sign,
double missingSpacing, TrackPose pose, TrackPose[] bogieRails) {
CarPlacement(ActiveVehicle vehicle, TrackSpline spline, double s, int sign,
double missingSpacing, TrackPose pose) {
this(vehicle, spline, s, sign, missingSpacing, pose, null);
}
double missingSpacing, TrackPose pose, TrackPose[] bogieRails, double[] missingRail) {
}

// The junction route the consist is placed along, so bogies can straddle a junction.
private record Route(TrackRegistry registry, boolean takeBranch, UUID stemId, UUID branchId,
double junctionS, int facingSign, double stemLength, boolean stemLoop, double branchLength) {
/** Distance a support lies beyond real track; branch tips do not reconnect to the stem. */
double missingRail(UUID splineId, double s, double offset) {
TrackSpline spline = registry.get(splineId).orElse(null);
if (spline == null) {
return Math.abs(offset);
}
if (splineId.equals(branchId) && s + offset > spline.length()) {
return s + offset - spline.length();
}
// The leading wheels can reach a thrown turnout before the car's centre
// selects it. In particular, a turnout at the stem end still supplies rail.
if (!splineId.equals(branchId) && Math.abs(offset) > 1e-9) {
int direction = offset > 0 ? 1 : -1;
TrackJunction first = null;
double nearest = Double.POSITIVE_INFINITY;
for (TrackJunction junction : registry.junctionsOn(splineId)) {
boolean selected = takeBranch && junction.branchSplineId != null
&& junction.branchSplineId.equals(branchId);
double distance = TrackJunctionTravel.ahead(s, junction.s, direction,
spline.isLoop(), spline.length());
if ((selected || junction.thrown) && junction.facingSign == direction
&& junction.branchSplineId != null && distance >= -1e-9
&& distance <= Math.abs(offset) && distance < nearest) {
first = junction;
nearest = distance;
}
}
if (first != null) {
TrackSpline branch = registry.get(first.branchSplineId).orElse(null);
if (branch != null) {
return Math.max(0, Math.abs(offset) - nearest - branch.length());
}
}
}
return TrackJunctionTravel.rewind(splineId, s, offset > 0 ? -1 : 1,
Math.abs(offset), takeBranch || splineId.equals(branchId),
stemId == null ? splineId : stemId, branchId, junctionS,
facingSign, stemLength, stemLoop, branchLength).missingSpacing;
}

/** The rail at {@code offset} along the track from {@code s}, following the route. */
TrackPose rail(UUID splineId, double s, double offset) {
TrackJunctionTravel.Pose at = TrackJunctionTravel.rewind(splineId, s, offset > 0 ? -1 : 1,
Expand Down Expand Up @@ -849,6 +888,33 @@ private TrackPose[] bogieRails(Route route, UUID onSpline, double at) {
return rails;
}

// Resolve the outer axle bones in model space, including the current model scale.
// Older configurations retain bogie support checks, or centre checks for rigid cars.
private double[] missingWheelRail(Route route, UUID onSpline, double at) {
double[] missing = new double[2];
if (route == null) {
return missing;
}
if (onBogies()) {
for (double offset : bogies.offsets()) {
int end = offset < 0 ? 0 : 1;
missing[end] = Math.max(missing[end], route.missingRail(onSpline, at, offset));
}
}
for (String name : wheelBones) {
try {
ActiveModel model = v.getModel();
double offset = model.getBone(name).orElseThrow().getBlueprintBone()
.getRotatedGlobalPosition().z() * model.getScale().z();
int end = offset < 0 ? 0 : 1;
missing[end] = Math.max(missing[end], route.missingRail(onSpline, at, offset));
} catch (RuntimeException notLoaded) {
// The model or this bone is not available yet.
}
}
return missing;
}

private void applyPlacements(List<CarPlacement> placements) {
for (CarPlacement placement : placements) {
TrainHandler train = placement.vehicle.getTrainHandler();
Expand Down Expand Up @@ -887,7 +953,8 @@ private List<CarPlacement> planCars() {
stemId, branchId, junctionS, facingSign, stemLength, stemLoop, branchLength);
TrackPose[] locoRails = bogieRails(along, splineId, s);
placements.add(new CarPlacement(v, spline, s, travelSign, 0,
locoRails == null ? spline.sampleAt(s) : bogies.bodyPose(locoRails), locoRails));
locoRails == null ? spline.sampleAt(s) : bogies.bodyPose(locoRails), locoRails,
missingWheelRail(along, splineId, s)));
if (registry == null) {
return placements;
}
Expand Down Expand Up @@ -942,7 +1009,8 @@ private List<CarPlacement> planCars() {
// Keep the sampled pose until connector blueprints and model transforms load.
}
}
placements.add(new CarPlacement(car, carSpline, pose.s, carTravelSign, pose.missingSpacing, carPose, rails));
placements.add(new CarPlacement(car, carSpline, pose.s, carTravelSign, pose.missingSpacing, carPose, rails,
carTrain.missingWheelRail(along, pose.splineId, pose.s)));
} else {
return List.of();
}
Expand Down Expand Up @@ -1205,7 +1273,7 @@ private void splineStep(double ds) {
if (!clearStep(accepted, next, shapes)) {
restoreStep(before);
blocked = true;
trackEnd = compressesConsist(accepted, next);
trackEnd = compressesConsist(accepted, next) || losesWheelSupport(accepted, next);
break;
}
trackEnd = reachesTrackEnd(accepted, next);
Expand Down Expand Up @@ -1266,7 +1334,8 @@ private boolean reachesTrackEnd(List<CarPlacement> previous, List<CarPlacement>

private boolean clearStep(List<CarPlacement> previous, List<CarPlacement> next,
Map<Long, List<BoundingBox>> shapes) {
if (next.isEmpty() || previous.size() != next.size() || compressesConsist(previous, next)) {
if (next.isEmpty() || previous.size() != next.size() || compressesConsist(previous, next)
|| losesWheelSupport(previous, next)) {
return false;
}
for (int i = 0; i < next.size(); i++) {
Expand Down Expand Up @@ -1311,6 +1380,19 @@ private void showWhatStopped(CarPlacement from, CarPlacement to, Map<Long, List<
}
}

private boolean losesWheelSupport(List<CarPlacement> previous, List<CarPlacement> next) {
for (int i = 0; i < Math.min(previous.size(), next.size()); i++) {
// A loaded or newly attached car may already overhang. Let it recover,
// including while clamped cars ahead of it regain their coupling gaps.
for (int end = 0; end < 2; end++) {
if (next.get(i).missingRail[end] > previous.get(i).missingRail[end] + 1e-9) {
return true;
}
}
}
return false;
}

private boolean compressesConsist(List<CarPlacement> previous, List<CarPlacement> next) {
double before = previous.stream().mapToDouble(CarPlacement::missingSpacing).sum();
double after = next.stream().mapToDouble(CarPlacement::missingSpacing).sum();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -329,6 +329,127 @@ void trackEndSetsThrottleToZeroAndAllowsDrivingAway(double start, double speed,
assertEquals(speed > 0 ? -1 : 1, loco.v.getThrottle().getCurrent());
}

@ParameterizedTest
@CsvSource({"97.1, 0.2", "97.1, 8", "22.9, -0.2", "22.9, -8"})
void outerAxlesStopWholeConsistBeforeLeavingRail(double start, double speed) {
TrackSpline track = straightTrack(false);
TrainHandler loco = wheeledConsist(track, start);
loco.v.getThrottle().setThrottle(speed > 0 ? 100 : -100);
loco.v.getAccessPanel().setSpeed(speed);
loco.splineTick();
assertPositions(loco, track, start);
assertEquals(0, loco.v.getThrottle().getCurrent());
assertEquals(0, loco.v.getAccessPanel().getSpeed(), 1e-9);
// Holding into the end cannot creep the outer axle off the rail.
loco.v.getAccessPanel().setSpeed(speed);
loco.splineTick();
assertPositions(loco, track, start);
double away = speed > 0 ? -0.1 : 0.1;
loco.v.getAccessPanel().setSpeed(away);
loco.splineTick();
assertPositions(loco, track, start + away);
}

@Test
void overhangingWheelsCanDriveBackOntoTrack() {
TrackSpline track = straightTrack(false);
TrainHandler loco = wheeledConsist(track, 21);
loco.v.getAccessPanel().setSpeed(-0.1);
loco.splineTick();
assertPositions(loco, track, 21);
loco.v.getAccessPanel().setSpeed(3);
loco.splineTick();
assertPositions(loco, track, 24);
}

@Test
void supportUsesScaledWheelBonesOnAnUncoupledCar() {
TrackSpline track = straightTrack(false);
TrainHandler car = car(List.of("axle_front", "axle_back"));
when(car.v.getModel().getScale()).thenReturn(new Vector3f(2));
car.setSplineId(track.getId());
car.setS(94.3);
car.placeLoadedCars();
car.v.getAccessPanel().setSpeed(0.1);
car.splineTick();
assertEquals(94.3, car.getS(), 1e-8);
}

@Test
void wheelSupportWrapsAcrossLoopSeam() {
TrackSpline track = straightTrack(true);
TrainHandler loco = wheeledConsist(track, 0.1);
loco.v.getAccessPanel().setSpeed(-0.2);
loco.splineTick();
assertPositions(loco, track, track.length() - 0.1);
}

@Test
void outerAxleCannotRunOffBranchTipOntoImaginaryStem() {
TrackSpline stem = straightTrack(false);
TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false,
List.of(new double[]{0, 64, 50}, new double[]{100, 64, 50}));
store.save(branch);
TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), 50,
1, TrackJunction.Side.LEFT, branch.getId(), true);
store.saveJunction("world", junction);
registry.loadFromDisk();
TrainHandler loco = wheeledConsist(branch, 97.1);
loco.applyConsist(new ConsistData(null, null, branch.getId().toString(), 97.1,
1, junction.id.toString(), true));
loco.placeLoadedCars();
loco.v.getAccessPanel().setSpeed(0.2);
loco.splineTick();
assertPositions(loco, branch, 97.1);
}

@Test
void wheelsCanEnterSelectedBranchBeforeCentreReachesStemEnd() {
TrackSpline stem = straightTrack(false);
TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false,
List.of(new double[]{0, 64, 100}, new double[]{0, 64, 200}));
store.save(branch);
store.saveJunction("world", new TrackJunction(UUID.randomUUID(), stem.getId(), 100,
1, TrackJunction.Side.LEFT, branch.getId(), true));
registry.loadFromDisk();
TrainHandler loco = wheeledConsist(stem, 97.1);
loco.v.getAccessPanel().setSpeed(0.2);
loco.splineTick();
assertPositions(loco, stem, 97.3);
}

@Test
void wheelsCrossBranchStartWithoutStopping() {
TrackSpline stem = straightTrack(false);
TrackSpline branch = TrackSpline.fromPoints(UUID.randomUUID(), "world", false,
List.of(new double[]{0, 64, 50}, new double[]{100, 64, 50}));
store.save(branch);
TrackJunction junction = new TrackJunction(UUID.randomUUID(), stem.getId(), 50,
1, TrackJunction.Side.LEFT, branch.getId(), true);
store.saveJunction("world", junction);
registry.loadFromDisk();
TrainHandler loco = wheeledConsist(branch, 22.9);
loco.applyConsist(new ConsistData(null, null, branch.getId().toString(), 22.9,
1, junction.id.toString(), true));
loco.v.getThrottle().setThrottle(-100);
loco.v.getAccessPanel().setSpeed(-0.2);
loco.splineTick();
assertEquals(22.7, loco.getS(), 1e-8);
assertEquals(-100, loco.v.getThrottle().getCurrent());
}

private TrainHandler wheeledConsist(TrackSpline track, double s) {
TrainHandler loco = car(List.of("axle_front", "axle_back"));
TrainHandler first = car(List.of("axle_front", "axle_back"));
TrainHandler last = car(List.of("axle_front", "axle_back"));
loco.setChild(first.v);
first.setChild(last.v);
loco.setSplineId(track.getId());
loco.setS(s);
loco.placeLoadedCars();
return loco;
}

@Test
void wallStopKeepsThrottleSetting() {
TrackSpline track = straightTrack(false);
Expand Down Expand Up @@ -816,6 +937,10 @@ private TrainHandler consist(TrackSpline track, double s) {
}

private TrainHandler car() {
return car(List.of());
}

private TrainHandler car(List<String> wheels) {
ActiveVehicle vehicle = stub(ActiveVehicle.class);
Entity entity = stub(Entity.class);
Location[] location = {new Location(null, 0, 64, 0)};
Expand All @@ -837,7 +962,11 @@ private TrainHandler car() {
when(vehicle.getModel()).thenReturn(model);
Connector front = connector(vehicle, model, "front", 5);
Connector back = connector(vehicle, model, "back", -5);
TrainHandler handler = new TrainHandler(new YamlConfiguration()) {
connector(vehicle, model, "axle_front", 2.875f);
connector(vehicle, model, "axle_back", -2.875f);
YamlConfiguration config = new YamlConfiguration();
config.set("wheel-bones", wheels);
TrainHandler handler = new TrainHandler(config) {
@Override public boolean isAttachable() { return true; }
@Override public boolean canHaveAttached() { return true; }
@Override public Connector getFront() { return front; }
Expand Down
Loading