diff --git a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java index dba6e1e..5ebc8df 100644 --- a/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java +++ b/src/main/java/top/ellan/mahjong/render/layout/TableRenderLayout.java @@ -12,6 +12,7 @@ import java.util.Collections; import java.util.EnumMap; import java.util.List; +import java.util.Objects; public final class TableRenderLayout { private static final double ONE_SIXTEENTH = 1.0D / 16.0D; @@ -76,6 +77,122 @@ public static LayoutPlan precompute(TableRenderSnapshot snapshot) { ); } + /** Reuses immutable center, seat and wall geometry from the immediately preceding layout. */ + public static LayoutPlan precompute( + TableRenderSnapshot snapshot, + TableRenderSnapshot previousSnapshot, + LayoutPlan previousLayout + ) { + if (previousSnapshot == null || previousLayout == null) { + return precompute(snapshot); + } + boolean reusableCenter = sameCenter(snapshot, previousSnapshot); + Point displayCenter; + Point tableCenter; + Point tableVisualAnchor; + double borderSpanX; + double borderSpanZ; + if (reusableCenter) { + displayCenter = previousLayout.displayCenter(); + tableCenter = previousLayout.tableCenter(); + tableVisualAnchor = previousLayout.tableVisualAnchor(); + borderSpanX = previousLayout.borderSpanX(); + borderSpanZ = previousLayout.borderSpanZ(); + } else { + displayCenter = new Point(snapshot.centerX(), snapshot.centerY() + DISPLAY_CENTER_Y_OFFSET, snapshot.centerZ()); + TableBounds bounds = tableBoundsFromTiles(displayCenter); + tableCenter = new Point(bounds.centerX(), displayCenter.y(), bounds.centerZ()); + tableVisualAnchor = new Point(tableCenter.x(), tableCenter.y() + TABLE_VISUAL_Y_OFFSET, tableCenter.z()); + borderSpanX = bounds.width() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + borderSpanZ = bounds.depth() + TABLE_TOP_SIZE_EXPANSION + TABLE_BORDER_THICKNESS; + } + + EnumMap seats = new EnumMap<>(SeatWind.class); + for (SeatWind wind : SEAT_WINDS) { + TableSeatRenderSnapshot seat = snapshot.seat(wind); + if (reusableCenter && sameSeatLayoutInputs(snapshot, previousSnapshot, wind)) { + seats.put(wind, previousLayout.seat(wind)); + } else { + seats.put(wind, precomputeSeat(displayCenter, snapshot, seat)); + } + } + + List wallTiles; + List doraTiles; + if (reusableCenter && sameWallLayoutInputs(snapshot, previousSnapshot)) { + wallTiles = previousLayout.wallTiles(); + doraTiles = previousLayout.doraTiles(); + } else { + List deadWallPlacements = snapshot.started() && snapshot.usesDeadWall() + ? deadWallPlacements(displayCenter, snapshot) + : List.of(); + wallTiles = precomputeWall(displayCenter, snapshot, deadWallPlacements); + doraTiles = precomputeDora(snapshot, deadWallPlacements); + } + + return new LayoutPlan( + displayCenter, + tableCenter, + tableVisualAnchor, + borderSpanX, + borderSpanZ, + seats, + wallTiles, + doraTiles + ); + } + + private static boolean sameCenter(TableRenderSnapshot left, TableRenderSnapshot right) { + return Double.doubleToLongBits(left.centerX()) == Double.doubleToLongBits(right.centerX()) + && Double.doubleToLongBits(left.centerY()) == Double.doubleToLongBits(right.centerY()) + && Double.doubleToLongBits(left.centerZ()) == Double.doubleToLongBits(right.centerZ()); + } + + private static boolean sameSeatLayoutInputs( + TableRenderSnapshot leftSnapshot, + TableRenderSnapshot rightSnapshot, + SeatWind wind + ) { + TableSeatRenderSnapshot left = leftSnapshot.seat(wind); + TableSeatRenderSnapshot right = rightSnapshot.seat(wind); + boolean leftOccupied = left.playerId() != null; + boolean rightOccupied = right.playerId() != null; + if (leftOccupied != rightOccupied) { + return false; + } + if (!leftOccupied) { + return true; + } + return left.hand().size() == right.hand().size() + && left.selectedHandTileIndices().equals(right.selectedHandTileIndices()) + && left.riichiDiscardIndex() == right.riichiDiscardIndex() + && left.stickLayoutCount() == right.stickLayoutCount() + && left.discards().equals(right.discards()) + && left.melds().equals(right.melds()) + && left.cornerSticks().equals(right.cornerSticks()) + && left.riichi() == right.riichi() + && leftSnapshot.openDoorSeat() == rightSnapshot.openDoorSeat(); + } + + private static boolean sameWallLayoutInputs(TableRenderSnapshot left, TableRenderSnapshot right) { + if (left.started() != right.started() + || left.remainingWallCount() != right.remainingWallCount() + || left.kanCount() != right.kanCount() + || left.dicePoints() != right.dicePoints() + || left.breakDicePoints() != right.breakDicePoints() + || left.dealerSeat() != right.dealerSeat() + || left.variant() != right.variant() + || !left.doraIndicators().equals(right.doraIndicators())) { + return false; + } + for (SeatWind wind : SEAT_WINDS) { + if (!Objects.equals(left.seat(wind).melds(), right.seat(wind).melds())) { + return false; + } + } + return true; + } + public static SeatLayoutPlan precomputeSeatOnly(TableRenderSnapshot snapshot, SeatWind wind) { if (snapshot == null || wind == null) { throw new IllegalArgumentException("snapshot and wind are required"); diff --git a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java index 210bd7d..9a48202 100644 --- a/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java +++ b/src/main/java/top/ellan/mahjong/table/core/MahjongTableSession.java @@ -79,6 +79,7 @@ public final class MahjongTableSession implements TableSessionMutator, TableMemb private final TableRenderer renderer = new TableRenderer(); private final TableRenderSnapshotFactory renderSnapshotFactory = new TableRenderSnapshotFactory(); private final TableRegionFingerprintService regionFingerprintService = new TableRegionFingerprintService(); + private final SessionRenderLayoutCache renderLayoutCache = new SessionRenderLayoutCache(); private MahjongRule configuredRule; // These three fields are written on the table's region thread (during // startRound / completeRoundStartInternal / setRoundControllerInternal) @@ -1635,10 +1636,11 @@ public TableRenderSnapshot captureRenderSnapshot(long version, long cancellation } public TableRenderPrecomputeResult precomputeRender(TableRenderSnapshot snapshot) { + TableRenderLayout.LayoutPlan layout = this.renderLayoutCache.precompute(snapshot); return new TableRenderPrecomputeResult( snapshot, this.regionFingerprintService.precomputeRegionFingerprints(this, snapshot), - TableRenderLayout.precompute(snapshot) + layout ); } diff --git a/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java b/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java new file mode 100644 index 0000000..881156c --- /dev/null +++ b/src/main/java/top/ellan/mahjong/table/core/SessionRenderLayoutCache.java @@ -0,0 +1,109 @@ +package top.ellan.mahjong.table.core; + +import top.ellan.mahjong.model.MahjongTile; +import top.ellan.mahjong.model.MahjongVariant; +import top.ellan.mahjong.model.SeatWind; +import top.ellan.mahjong.render.layout.TableRenderLayout; +import top.ellan.mahjong.render.scene.MeldView; +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot; +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot; +import top.ellan.mahjong.riichi.model.ScoringStick; +import java.util.List; + +/** Single-entry, immutable layout cache owned by one table session. */ +final class SessionRenderLayoutCache { + private volatile Entry cached; + + TableRenderLayout.LayoutPlan precompute(TableRenderSnapshot snapshot) { + LayoutKey key = LayoutKey.from(snapshot); + Entry current = this.cached; + if (current != null && current.key().equals(key)) { + return current.layout(); + } + TableRenderLayout.LayoutPlan layout = current == null + ? TableRenderLayout.precompute(snapshot) + : TableRenderLayout.precompute(snapshot, current.snapshot(), current.layout()); + this.cached = new Entry(key, snapshot, layout); + return layout; + } + + void clear() { + this.cached = null; + } + + private record Entry(LayoutKey key, TableRenderSnapshot snapshot, TableRenderLayout.LayoutPlan layout) { + } + + private record LayoutKey( + double centerX, + double centerY, + double centerZ, + boolean started, + int remainingWallCount, + int kanCount, + int dicePoints, + int breakDicePoints, + SeatWind dealerSeat, + SeatWind openDoorSeat, + List doraIndicators, + MahjongVariant variant, + SeatLayoutKey east, + SeatLayoutKey south, + SeatLayoutKey west, + SeatLayoutKey north + ) { + private static LayoutKey from(TableRenderSnapshot snapshot) { + return new LayoutKey( + snapshot.centerX(), + snapshot.centerY(), + snapshot.centerZ(), + snapshot.started(), + snapshot.remainingWallCount(), + snapshot.kanCount(), + snapshot.dicePoints(), + snapshot.breakDicePoints(), + snapshot.dealerSeat(), + snapshot.openDoorSeat(), + snapshot.doraIndicators(), + snapshot.variant(), + SeatLayoutKey.from(snapshot.seat(SeatWind.EAST)), + SeatLayoutKey.from(snapshot.seat(SeatWind.SOUTH)), + SeatLayoutKey.from(snapshot.seat(SeatWind.WEST)), + SeatLayoutKey.from(snapshot.seat(SeatWind.NORTH)) + ); + } + } + + private record SeatLayoutKey( + SeatWind wind, + boolean occupied, + int handSize, + List selectedHandTileIndices, + int riichiDiscardIndex, + int stickLayoutCount, + List discards, + List melds, + List cornerSticks, + boolean riichi + ) { + private static SeatLayoutKey from(TableSeatRenderSnapshot seat) { + if (seat.playerId() == null) { + return new SeatLayoutKey( + seat.wind(), false, 0, List.of(), -1, 0, List.of(), List.of(), List.of(), false + ); + } + return new SeatLayoutKey( + seat.wind(), + true, + seat.hand().size(), + seat.selectedHandTileIndices(), + seat.riichiDiscardIndex(), + seat.stickLayoutCount(), + seat.discards(), + seat.melds(), + seat.cornerSticks(), + seat.riichi() + ); + } + } +} diff --git a/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt b/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt new file mode 100644 index 0000000..da90092 --- /dev/null +++ b/src/test/kotlin/top/ellan/mahjong/table/core/SessionRenderLayoutCacheTest.kt @@ -0,0 +1,200 @@ +package top.ellan.mahjong.table.core + +import top.ellan.mahjong.model.MahjongTile +import top.ellan.mahjong.model.MahjongVariant +import top.ellan.mahjong.model.SeatWind +import top.ellan.mahjong.render.scene.MeldView +import top.ellan.mahjong.render.snapshot.TableRenderSnapshot +import top.ellan.mahjong.render.snapshot.TableSeatRenderSnapshot +import top.ellan.mahjong.riichi.model.ScoringStick +import java.util.EnumMap +import java.util.UUID +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotSame +import kotlin.test.assertSame + +class SessionRenderLayoutCacheTest { + @Test + fun `equivalent layout inputs reuse the immutable plan`() { + val cache = SessionRenderLayoutCache() + assertSame(cache.precompute(snapshot(version = 1)), cache.precompute(snapshot(version = 2))) + } + + @Test + fun `every geometry dependency invalidates the cached plan`() { + val baseline = snapshot() + val changed = + listOf( + snapshot(centerX = 4.0), + snapshot(centerY = 65.0), + snapshot(centerZ = -3.0), + snapshot(started = false), + snapshot(remainingWallCount = 69), + snapshot(kanCount = 2), + snapshot(dicePoints = 8), + snapshot(breakDicePoints = 6), + snapshot(dealerSeat = SeatWind.SOUTH), + snapshot(openDoorSeat = SeatWind.NORTH), + snapshot(doraIndicators = listOf(MahjongTile.P1)), + snapshot(variant = MahjongVariant.GB), + snapshot(hand = defaultHand() + MahjongTile.NORTH), + snapshot(selectedIndices = listOf(3)), + snapshot(riichiDiscardIndex = 2), + snapshot(stickLayoutCount = 4), + snapshot(discards = listOf(MahjongTile.EAST, MahjongTile.WEST)), + snapshot(melds = listOf(defaultMeld(), defaultMeld())), + snapshot(cornerSticks = listOf(ScoringStick.P1000)), + snapshot(riichi = false), + snapshot(occupied = false), + ) + changed.forEachIndexed { index, candidate -> + val cache = SessionRenderLayoutCache() + val first = cache.precompute(baseline) + val incremental = cache.precompute(candidate) + assertNotSame(first, incremental, "dependency index=$index") + assertEquals( + top.ellan.mahjong.render.layout.TableRenderLayout + .precompute(candidate), + incremental, + "incremental layout dependency index=$index", + ) + } + } + + @Test + fun `seat-only changes reuse immutable center and wall geometry`() { + val cache = SessionRenderLayoutCache() + val first = cache.precompute(snapshot(selectedIndices = listOf(4))) + val second = cache.precompute(snapshot(version = 2, selectedIndices = listOf(3))) + + assertNotSame(first, second) + assertSame(first.displayCenter(), second.displayCenter()) + assertSame(first.tableCenter(), second.tableCenter()) + assertSame(first.wallTiles(), second.wallTiles()) + SeatWind.values().forEach { wind -> assertNotSame(first.seat(wind), second.seat(wind)) } + } + + @Test + fun `wall-only changes reuse every immutable seat plan`() { + val cache = SessionRenderLayoutCache() + val first = cache.precompute(snapshot(remainingWallCount = 70)) + val second = cache.precompute(snapshot(version = 2, remainingWallCount = 69)) + + assertNotSame(first.wallTiles(), second.wallTiles()) + SeatWind.values().forEach { wind -> assertSame(first.seat(wind), second.seat(wind)) } + } + + @Test + fun `clear forces recomputation`() { + val cache = SessionRenderLayoutCache() + val input = snapshot() + val first = cache.precompute(input) + cache.clear() + assertNotSame(first, cache.precompute(input)) + } +} + +private fun snapshot( + version: Long = 1, + centerX: Double = 3.0, + centerY: Double = 64.0, + centerZ: Double = -2.0, + started: Boolean = true, + remainingWallCount: Int = 70, + kanCount: Int = 1, + dicePoints: Int = 7, + breakDicePoints: Int = 5, + dealerSeat: SeatWind = SeatWind.EAST, + openDoorSeat: SeatWind = SeatWind.WEST, + doraIndicators: List = listOf(MahjongTile.M1), + variant: MahjongVariant = MahjongVariant.RIICHI, + hand: List = defaultHand(), + selectedIndices: List = listOf(4), + riichiDiscardIndex: Int = 1, + stickLayoutCount: Int = 3, + discards: List = listOf(MahjongTile.EAST, MahjongTile.SOUTH), + melds: List = listOf(defaultMeld()), + cornerSticks: List = listOf(ScoringStick.P100), + riichi: Boolean = true, + occupied: Boolean = true, +): TableRenderSnapshot { + val seats = EnumMap(SeatWind::class.java) + SeatWind.values().forEach { wind -> + seats[wind] = + TableSeatRenderSnapshot( + wind, + if (occupied) UUID(0, wind.index().toLong() + 1) else null, + wind.name, + wind.name, + 25_000, + riichi, + true, + false, + true, + "viewer-${wind.name}", + selectedIndices.lastOrNull() ?: -1, + selectedIndices, + riichiDiscardIndex, + stickLayoutCount, + emptyList(), + hand, + discards, + melds, + emptyList(), + cornerSticks, + ) + } + return TableRenderSnapshot( + version, + 0, + "world", + centerX, + centerY, + centerZ, + started, + false, + false, + remainingWallCount, + kanCount, + dicePoints, + breakDicePoints, + 1, + 0, + dealerSeat, + SeatWind.SOUTH, + openDoorSeat, + "waiting", + "rules", + "center-$version", + null, + null, + doraIndicators, + variant, + seats, + ) +} + +private fun defaultHand(): List = + listOf( + MahjongTile.M1, + MahjongTile.M2, + MahjongTile.M3, + MahjongTile.P1, + MahjongTile.P2, + MahjongTile.P3, + MahjongTile.S1, + MahjongTile.S2, + MahjongTile.S3, + MahjongTile.EAST, + MahjongTile.EAST, + ) + +private fun defaultMeld(): MeldView = + MeldView( + listOf(MahjongTile.P7, MahjongTile.P7, MahjongTile.P7), + listOf(false, false, false), + 1, + 90, + null, + )