Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dde7ca39cc | ||
|
|
f637ddfdb8 | ||
|
|
128da7f3d2 | ||
|
|
5bd0dadfdf | ||
|
|
755487c42d | ||
|
|
e5b58c844c | ||
|
|
09df96922e | ||
|
|
0add9e4ef1 | ||
|
|
3eb85d7d72 | ||
|
|
eb3700a57d | ||
|
|
e6c12e87e4 | ||
|
|
ad78a65ed7 | ||
|
|
b95fc1fdff | ||
|
|
79bc0e4c80 | ||
|
|
df60824f37 | ||
|
|
543675340d | ||
|
|
9b1d47d4c7 | ||
|
|
659464d3b4 | ||
|
|
06cd94b24c | ||
|
|
ada0becee5 | ||
|
|
c5fba5b7dd | ||
|
|
c5bfcf990a | ||
|
|
00dc4b1d06 | ||
|
|
76063884af | ||
|
|
915d8a5343 | ||
|
|
028b0cb86f | ||
|
|
2bf00af952 | ||
|
|
1d229333bc | ||
|
|
0c67f63ac7 | ||
|
|
106b348585 | ||
|
|
7668cc9b11 | ||
|
|
93527e6e0b | ||
|
|
ef4ca57a6c | ||
|
|
095221564f | ||
|
|
2bfd5d2bda | ||
|
|
6dabb71600 | ||
|
|
cf1be2d173 | ||
|
|
0169ab5128 | ||
|
|
c96036d86b |
119
CHANGELOG.md
119
CHANGELOG.md
@@ -1,3 +1,122 @@
|
||||
## [4.68.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.68.1...v4.68.2) (2025-10-23)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** prevent passenger delivery render loop causing thrashing ([f637ddf](https://github.com/antialias/soroban-abacus-flashcards/commit/f637ddfdb8a62c85ebf8f08c35927af9ebcdf0d7))
|
||||
|
||||
## [4.68.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.68.0...v4.68.1) (2025-10-23)
|
||||
|
||||
|
||||
### Performance Improvements
|
||||
|
||||
* **complement-race:** increase spring animation responsiveness to reduce lag ([5bd0dad](https://github.com/antialias/soroban-abacus-flashcards/commit/5bd0dadfdf9d6d81d7db4374983e40de00effecb))
|
||||
|
||||
## [4.68.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.1...v4.68.0) (2025-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add react-spring animations to local train for smooth movement ([e5b58c8](https://github.com/antialias/soroban-abacus-flashcards/commit/e5b58c844cacce84b6118b3219b0d1f86e6f74a2))
|
||||
|
||||
## [4.67.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.67.0...v4.67.1) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** fix react-spring interpolation TypeScript errors ([0add9e4](https://github.com/antialias/soroban-abacus-flashcards/commit/0add9e4ef1d69e4e92ffe279cce09c68efa43714))
|
||||
|
||||
## [4.67.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.2...v4.67.0) (2025-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add react-spring animations to ghost trains for smooth movement ([eb3700a](https://github.com/antialias/soroban-abacus-flashcards/commit/eb3700a57d035a142c64b60d5d1b21181d21b69f))
|
||||
|
||||
## [4.66.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.1...v4.66.2) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** fix ghost train position update lag and reload position reset ([ad78a65](https://github.com/antialias/soroban-abacus-flashcards/commit/ad78a65ed7f63509602e79246e3761653ea39a15))
|
||||
|
||||
## [4.66.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.66.0...v4.66.1) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** ensure continuous position broadcasting during train movement ([df60824](https://github.com/antialias/soroban-abacus-flashcards/commit/df60824f37f52e77e69d32c26926a24e1af88e66))
|
||||
|
||||
## [4.66.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.1...v4.66.0) (2025-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** implement per-car adaptive opacity for ghost trains ([9b1d47d](https://github.com/antialias/soroban-abacus-flashcards/commit/9b1d47d4c7bdaf44f3921ff99971dfb3b65442bd))
|
||||
|
||||
## [4.65.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.65.0...v4.65.1) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use sendMove with correct parameters for position updates ([06cd94b](https://github.com/antialias/soroban-abacus-flashcards/commit/06cd94b24cdd9dbd36fb5800c9ba7be194f7eed0))
|
||||
|
||||
## [4.65.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.2...v4.65.0) (2025-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** implement position broadcasting for ghost trains ([c5fba5b](https://github.com/antialias/soroban-abacus-flashcards/commit/c5fba5b7dd0f36fd3bbe596409e01b0d3dbd4fbe))
|
||||
|
||||
## [4.64.2](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.1...v4.64.2) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use individual player positions for ghost trains ([00dc4b1](https://github.com/antialias/soroban-abacus-flashcards/commit/00dc4b1d06a4e1763deb16333a298145cafd9187))
|
||||
|
||||
## [4.64.1](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.64.0...v4.64.1) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** use local player instead of first player for train display ([915d8a5](https://github.com/antialias/soroban-abacus-flashcards/commit/915d8a5343e70a30c7a82bed645e6628fcc08a86))
|
||||
|
||||
## [4.64.0](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.11...v4.64.0) (2025-10-22)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **complement-race:** add ghost trains for multiplayer visibility ([7668cc9](https://github.com/antialias/soroban-abacus-flashcards/commit/7668cc9b113b3eae2acb1b852b0ad48c979e6604))
|
||||
|
||||
## [4.63.11](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.10...v4.63.11) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** actually filter by isActive instead of just id ([ef4ca57](https://github.com/antialias/soroban-abacus-flashcards/commit/ef4ca57a6c3f35d1bddc6a70952f478058fbc6b5))
|
||||
|
||||
## [4.63.10](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.9...v4.63.10) (2025-10-22)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **complement-race:** show only first active player's passengers on train ([2bfd5d2](https://github.com/antialias/soroban-abacus-flashcards/commit/2bfd5d2bda7f7d2d83c69f75600ab461fde15d92))
|
||||
|
||||
## [4.63.9](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.8...v4.63.9) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **homepage:** use app-wide abacus config in interactive flashcards ([cf1be2d](https://github.com/antialias/soroban-abacus-flashcards/commit/cf1be2d1730543bd30836a87d9cbdfd2cf48360e))
|
||||
|
||||
## [4.63.8](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.7...v4.63.8) (2025-10-21)
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **mobile:** restore abacus visibility in "Your Journey" section ([c96036d](https://github.com/antialias/soroban-abacus-flashcards/commit/c96036d86b6de2e25f7ecd3d00dd36221badc3b1))
|
||||
|
||||
## [4.63.7](https://github.com/antialias/soroban-abacus-flashcards/compare/v4.63.6...v4.63.7) (2025-10-21)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
'use client'
|
||||
|
||||
import { useSpring, useSprings, animated, to } from '@react-spring/web'
|
||||
import { useMemo, useRef } from 'react'
|
||||
import type { PlayerState } from '@/arcade-games/complement-race/types'
|
||||
import type { RailroadTrackGenerator } from '../../lib/RailroadTrackGenerator'
|
||||
|
||||
// Overlap threshold: if ghost car is within this distance of any local car, make it ghostly
|
||||
const OVERLAP_THRESHOLD = 20 // % of track length
|
||||
const GHOST_OPACITY = 0.35 // Opacity when overlapping
|
||||
const SOLID_OPACITY = 1.0 // Opacity when separated
|
||||
|
||||
interface GhostTrainProps {
|
||||
player: PlayerState
|
||||
trainPosition: number
|
||||
localTrainCarPositions: number[] // [locomotive, car1, car2, car3]
|
||||
maxCars: number
|
||||
carSpacing: number
|
||||
trackGenerator: RailroadTrackGenerator
|
||||
pathRef: React.RefObject<SVGPathElement>
|
||||
}
|
||||
|
||||
interface CarTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
opacity: number
|
||||
position: number
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate opacity for a ghost car based on distance to nearest local car
|
||||
*/
|
||||
function calculateCarOpacity(ghostCarPosition: number, localCarPositions: number[]): number {
|
||||
// Find minimum distance to any local car
|
||||
const minDistance = Math.min(
|
||||
...localCarPositions.map((localPos) => Math.abs(ghostCarPosition - localPos))
|
||||
)
|
||||
|
||||
// If within threshold, use ghost opacity; otherwise solid
|
||||
return minDistance < OVERLAP_THRESHOLD ? GHOST_OPACITY : SOLID_OPACITY
|
||||
}
|
||||
|
||||
/**
|
||||
* GhostTrain - Renders a semi-transparent train for other players in multiplayer
|
||||
* Uses per-car adaptive opacity: cars are ghostly when overlapping local train,
|
||||
* solid when separated
|
||||
*/
|
||||
export function GhostTrain({
|
||||
player,
|
||||
trainPosition,
|
||||
localTrainCarPositions,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
}: GhostTrainProps) {
|
||||
const ghostRef = useRef<SVGGElement>(null)
|
||||
|
||||
// Calculate target transform for locomotive (used by spring animation)
|
||||
const locomotiveTarget = useMemo<CarTransform | null>(() => {
|
||||
if (!pathRef.current) {
|
||||
return null
|
||||
}
|
||||
|
||||
const pathLength = pathRef.current.getTotalLength()
|
||||
const targetDistance = (trainPosition / 100) * pathLength
|
||||
const point = pathRef.current.getPointAtLength(targetDistance)
|
||||
|
||||
// Calculate tangent for rotation
|
||||
const tangentDelta = 1
|
||||
const tangentDistance = Math.min(targetDistance + tangentDelta, pathLength)
|
||||
const tangentPoint = pathRef.current.getPointAtLength(tangentDistance)
|
||||
const rotation =
|
||||
(Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / Math.PI
|
||||
|
||||
return {
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rotation,
|
||||
position: trainPosition,
|
||||
opacity: calculateCarOpacity(trainPosition, localTrainCarPositions),
|
||||
}
|
||||
}, [trainPosition, localTrainCarPositions, pathRef])
|
||||
|
||||
// Animated spring for smooth locomotive movement
|
||||
const locomotiveSpring = useSpring({
|
||||
x: locomotiveTarget?.x ?? 0,
|
||||
y: locomotiveTarget?.y ?? 0,
|
||||
rotation: locomotiveTarget?.rotation ?? 0,
|
||||
opacity: locomotiveTarget?.opacity ?? 1,
|
||||
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
|
||||
})
|
||||
|
||||
// Calculate target transforms for cars (used by spring animations)
|
||||
const carTargets = useMemo<CarTransform[]>(() => {
|
||||
if (!pathRef.current) {
|
||||
return []
|
||||
}
|
||||
|
||||
const pathLength = pathRef.current.getTotalLength()
|
||||
const cars: CarTransform[] = []
|
||||
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
const carPosition = Math.max(0, trainPosition - (i + 1) * carSpacing)
|
||||
const targetDistance = (carPosition / 100) * pathLength
|
||||
const point = pathRef.current.getPointAtLength(targetDistance)
|
||||
|
||||
// Calculate tangent for rotation
|
||||
const tangentDelta = 1
|
||||
const tangentDistance = Math.min(targetDistance + tangentDelta, pathLength)
|
||||
const tangentPoint = pathRef.current.getPointAtLength(tangentDistance)
|
||||
const rotation =
|
||||
(Math.atan2(tangentPoint.y - point.y, tangentPoint.x - point.x) * 180) / Math.PI
|
||||
|
||||
cars.push({
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rotation,
|
||||
position: carPosition,
|
||||
opacity: calculateCarOpacity(carPosition, localTrainCarPositions),
|
||||
})
|
||||
}
|
||||
|
||||
return cars
|
||||
}, [trainPosition, maxCars, carSpacing, localTrainCarPositions, pathRef])
|
||||
|
||||
// Animated springs for smooth car movement (useSprings for multiple cars)
|
||||
const carSprings = useSprings(
|
||||
carTargets.length,
|
||||
carTargets.map((target) => ({
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
rotation: target.rotation,
|
||||
opacity: target.opacity,
|
||||
config: { tension: 600, friction: 35 }, // Fast/responsive to match local train
|
||||
}))
|
||||
)
|
||||
|
||||
// Don't render if position data isn't ready
|
||||
if (!locomotiveTarget) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<g ref={ghostRef} data-component="ghost-train" data-player-id={player.id}>
|
||||
{/* Ghost locomotive - animated */}
|
||||
<animated.g
|
||||
transform={to(
|
||||
[locomotiveSpring.x, locomotiveSpring.y, locomotiveSpring.rotation],
|
||||
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
|
||||
)}
|
||||
opacity={locomotiveSpring.opacity}
|
||||
>
|
||||
<text
|
||||
data-element="ghost-locomotive"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '100px',
|
||||
filter: `drop-shadow(0 2px 8px ${player.color || 'rgba(100, 100, 255, 0.6)'})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚂
|
||||
</text>
|
||||
|
||||
{/* Player name label - positioned above locomotive */}
|
||||
<text
|
||||
data-element="ghost-label"
|
||||
x={0}
|
||||
y={-60}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '18px',
|
||||
fontWeight: 'bold',
|
||||
fill: player.color || '#6366f1',
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.3))',
|
||||
pointerEvents: 'none',
|
||||
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
|
||||
}}
|
||||
>
|
||||
{player.name || `Player ${player.id.slice(0, 4)}`}
|
||||
</text>
|
||||
|
||||
{/* Score indicator - positioned below locomotive */}
|
||||
<text
|
||||
data-element="ghost-score"
|
||||
x={0}
|
||||
y={50}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: 'bold',
|
||||
fill: 'rgba(255, 255, 255, 0.9)',
|
||||
filter: 'drop-shadow(0 1px 2px rgba(0, 0, 0, 0.5))',
|
||||
pointerEvents: 'none',
|
||||
transform: 'scaleX(-1)', // Counter the parent's scaleX(-1)
|
||||
}}
|
||||
>
|
||||
{player.score}
|
||||
</text>
|
||||
</animated.g>
|
||||
|
||||
{/* Ghost cars - each with individual animated opacity and position */}
|
||||
{carSprings.map((spring, index) => (
|
||||
<animated.g
|
||||
key={`car-${index}`}
|
||||
transform={to(
|
||||
[spring.x, spring.y, spring.rotation],
|
||||
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
|
||||
)}
|
||||
opacity={spring.opacity}
|
||||
>
|
||||
<text
|
||||
data-element={`ghost-car-${index}`}
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
style={{
|
||||
fontSize: '85px',
|
||||
filter: `drop-shadow(0 2px 6px ${player.color || 'rgba(100, 100, 255, 0.4)'})`,
|
||||
pointerEvents: 'none',
|
||||
}}
|
||||
>
|
||||
🚃
|
||||
</text>
|
||||
</animated.g>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import { GameHUD } from './GameHUD'
|
||||
import { RailroadTrackPath } from './RailroadTrackPath'
|
||||
import { TrainAndCars } from './TrainAndCars'
|
||||
import { TrainTerrainBackground } from './TrainTerrainBackground'
|
||||
import { GhostTrain } from './GhostTrain'
|
||||
|
||||
const BoardingPassengerAnimation = memo(({ animation }: { animation: BoardingAnimation }) => {
|
||||
const spring = useSpring({
|
||||
@@ -92,7 +93,7 @@ export function SteamTrainJourney({
|
||||
currentQuestion,
|
||||
currentInput,
|
||||
}: SteamTrainJourneyProps) {
|
||||
const { state } = useComplementRace()
|
||||
const { state, multiplayerState, localPlayerId } = useComplementRace()
|
||||
|
||||
const { getSkyGradient, getTimeOfDayPeriod } = useSteamJourney()
|
||||
const _skyGradient = getSkyGradient()
|
||||
@@ -100,10 +101,10 @@ export function SteamTrainJourney({
|
||||
const { players } = useGameMode()
|
||||
const { profile: _profile } = useUserProfile()
|
||||
|
||||
// Get the first active player's emoji
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.id)
|
||||
const firstActivePlayer = activePlayers[0]
|
||||
const playerEmoji = firstActivePlayer?.emoji ?? '👤'
|
||||
// Get the LOCAL player's emoji (not just the first player!)
|
||||
const activePlayers = Array.from(players.values()).filter((p) => p.isActive)
|
||||
const localPlayer = activePlayers.find((p) => p.isLocal)
|
||||
const playerEmoji = localPlayer?.emoji ?? '👤'
|
||||
|
||||
const svgRef = useRef<SVGSVGElement>(null)
|
||||
const pathRef = useRef<SVGPathElement>(null)
|
||||
@@ -164,9 +165,13 @@ export function SteamTrainJourney({
|
||||
|
||||
// Memoize filtered passenger lists to avoid recalculating on every render
|
||||
// Arcade room multiplayer uses claimedBy/deliveredBy instead of isBoarded/isDelivered
|
||||
// Only show passengers claimed by the LOCAL player
|
||||
const boardedPassengers = useMemo(
|
||||
() => displayPassengers.filter((p) => p.claimedBy !== null && p.deliveredBy === null),
|
||||
[displayPassengers]
|
||||
() =>
|
||||
displayPassengers.filter(
|
||||
(p) => p.claimedBy === localPlayer?.id && p.claimedBy !== null && p.deliveredBy === null
|
||||
),
|
||||
[displayPassengers, localPlayer?.id]
|
||||
)
|
||||
|
||||
const nonDeliveredPassengers = useMemo(
|
||||
@@ -186,6 +191,29 @@ export function SteamTrainJourney({
|
||||
[]
|
||||
)
|
||||
|
||||
// Calculate local train car positions for ghost train overlap detection
|
||||
// Array includes locomotive + all cars: [locomotive, car1, car2, car3]
|
||||
const localTrainCarPositions = useMemo(() => {
|
||||
const positions = [trainPosition] // Locomotive at front
|
||||
for (let i = 0; i < maxCars; i++) {
|
||||
positions.push(Math.max(0, trainPosition - (i + 1) * carSpacing))
|
||||
}
|
||||
return positions
|
||||
}, [trainPosition, maxCars, carSpacing])
|
||||
|
||||
// Get other players for ghost trains (filter out local player)
|
||||
const otherPlayers = useMemo(() => {
|
||||
if (!multiplayerState?.players || !localPlayerId) {
|
||||
return []
|
||||
}
|
||||
|
||||
const filtered = Object.entries(multiplayerState.players)
|
||||
.filter(([playerId, player]) => playerId !== localPlayerId && player.isActive)
|
||||
.map(([_, player]) => player)
|
||||
|
||||
return filtered
|
||||
}, [multiplayerState?.players, localPlayerId])
|
||||
|
||||
if (!trackData) return null
|
||||
|
||||
return (
|
||||
@@ -247,7 +275,21 @@ export function SteamTrainJourney({
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
/>
|
||||
|
||||
{/* Train, cars, and passenger animations */}
|
||||
{/* Ghost trains - other players in multiplayer */}
|
||||
{otherPlayers.map((player) => (
|
||||
<GhostTrain
|
||||
key={player.id}
|
||||
player={player}
|
||||
trainPosition={player.position} // Use each player's individual position
|
||||
localTrainCarPositions={localTrainCarPositions} // For per-car overlap detection
|
||||
maxCars={maxCars}
|
||||
carSpacing={carSpacing}
|
||||
trackGenerator={trackGenerator}
|
||||
pathRef={pathRef}
|
||||
/>
|
||||
))}
|
||||
|
||||
{/* Train, cars, and passenger animations - local player */}
|
||||
<TrainAndCars
|
||||
boardingAnimations={boardingAnimations}
|
||||
disembarkingAnimations={disembarkingAnimations}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
'use client'
|
||||
|
||||
import { memo } from 'react'
|
||||
import { animated, to } from '@react-spring/web'
|
||||
import type { SpringValue } from '@react-spring/web'
|
||||
import type { BoardingAnimation, DisembarkingAnimation } from '../../hooks/usePassengerAnimations'
|
||||
import type { Passenger } from '@/arcade-games/complement-race/types'
|
||||
|
||||
interface TrainCarTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
position: number
|
||||
opacity: number
|
||||
x: SpringValue<number>
|
||||
y: SpringValue<number>
|
||||
rotation: SpringValue<number>
|
||||
position: SpringValue<number>
|
||||
opacity: SpringValue<number>
|
||||
}
|
||||
|
||||
interface TrainTransform {
|
||||
x: number
|
||||
y: number
|
||||
rotation: number
|
||||
x: SpringValue<number>
|
||||
y: SpringValue<number>
|
||||
rotation: SpringValue<number>
|
||||
}
|
||||
|
||||
interface TrainAndCarsProps {
|
||||
@@ -30,7 +32,7 @@ interface TrainAndCarsProps {
|
||||
trainCars: TrainCarTransform[]
|
||||
boardedPassengers: Passenger[]
|
||||
trainTransform: TrainTransform
|
||||
locomotiveOpacity: number
|
||||
locomotiveOpacity: SpringValue<number>
|
||||
playerEmoji: string
|
||||
momentum: number
|
||||
}
|
||||
@@ -72,14 +74,14 @@ export const TrainAndCars = memo(
|
||||
const passenger = boardedPassengers[carIndex]
|
||||
|
||||
return (
|
||||
<g
|
||||
<animated.g
|
||||
key={`train-car-${carIndex}`}
|
||||
data-component="train-car"
|
||||
transform={`translate(${carTransform.x}, ${carTransform.y}) rotate(${carTransform.rotation}) scale(-1, 1)`}
|
||||
transform={to(
|
||||
[carTransform.x, carTransform.y, carTransform.rotation],
|
||||
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
|
||||
)}
|
||||
opacity={carTransform.opacity}
|
||||
style={{
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train car */}
|
||||
<text
|
||||
@@ -114,18 +116,18 @@ export const TrainAndCars = memo(
|
||||
{passenger.avatar}
|
||||
</text>
|
||||
)}
|
||||
</g>
|
||||
</animated.g>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Locomotive - rendered last so it appears on top */}
|
||||
<g
|
||||
<animated.g
|
||||
data-component="locomotive-group"
|
||||
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
|
||||
transform={to(
|
||||
[trainTransform.x, trainTransform.y, trainTransform.rotation],
|
||||
(x, y, rot) => `translate(${x}, ${y}) rotate(${rot}) scale(-1, 1)`
|
||||
)}
|
||||
opacity={locomotiveOpacity}
|
||||
style={{
|
||||
transition: 'opacity 0.5s ease-in',
|
||||
}}
|
||||
>
|
||||
{/* Train locomotive */}
|
||||
<text
|
||||
@@ -191,7 +193,7 @@ export const TrainAndCars = memo(
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</g>
|
||||
</animated.g>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -45,6 +45,7 @@ export function useSteamJourney() {
|
||||
const routeExitThresholdRef = useRef<number>(107) // Default for 1 car: 100 + 7
|
||||
const missedPassengersRef = useRef<Set<string>>(new Set()) // Track which passengers have been logged as missed
|
||||
const pendingBoardingRef = useRef<Set<string>>(new Set()) // Track passengers with pending boarding requests across frames
|
||||
const pendingDeliveryRef = useRef<Set<string>>(new Set()) // Track passengers with pending delivery requests across frames
|
||||
const previousTrainPositionRef = useRef<number>(0) // Track previous position to detect threshold crossings
|
||||
|
||||
// Initialize game start time
|
||||
@@ -65,19 +66,23 @@ export function useSteamJourney() {
|
||||
}
|
||||
}, [state.currentRoute, state.passengers, state.stations, state.maxConcurrentPassengers])
|
||||
|
||||
// Clean up pendingBoardingRef when passengers are claimed/delivered or route changes
|
||||
// Clean up pendingBoardingRef and pendingDeliveryRef when passengers are claimed/delivered or route changes
|
||||
useEffect(() => {
|
||||
// Remove passengers from pending set if they've been claimed or delivered
|
||||
// Remove passengers from pending sets if they've been claimed or delivered
|
||||
state.passengers.forEach((passenger) => {
|
||||
if (passenger.claimedBy !== null || passenger.deliveredBy !== null) {
|
||||
pendingBoardingRef.current.delete(passenger.id)
|
||||
}
|
||||
if (passenger.deliveredBy !== null) {
|
||||
pendingDeliveryRef.current.delete(passenger.id)
|
||||
}
|
||||
})
|
||||
}, [state.passengers])
|
||||
|
||||
// Clear all pending boarding requests when route changes
|
||||
// Clear all pending boarding and delivery requests when route changes
|
||||
useEffect(() => {
|
||||
pendingBoardingRef.current.clear()
|
||||
pendingDeliveryRef.current.clear()
|
||||
missedPassengersRef.current.clear()
|
||||
previousTrainPositionRef.current = 0 // Reset previous position for new route
|
||||
}, [state.currentRoute])
|
||||
@@ -159,6 +164,9 @@ export function useSteamJourney() {
|
||||
currentBoardedPassengers.forEach((passenger) => {
|
||||
if (!passenger || passenger.deliveredBy !== null || passenger.carIndex === null) return
|
||||
|
||||
// Skip if delivery already dispatched (prevents render loop spam)
|
||||
if (pendingDeliveryRef.current.has(passenger.id)) return
|
||||
|
||||
const station = state.stations.find((s) => s.id === passenger.destinationStationId)
|
||||
if (!station) return
|
||||
|
||||
@@ -172,6 +180,10 @@ export function useSteamJourney() {
|
||||
console.log(
|
||||
`🎯 DELIVERY: ${passenger.name} delivered from Car ${passenger.carIndex} to ${station.emoji} ${station.name} (+${points} pts) (trainPos=${trainPosition.toFixed(1)}, carPos=${carPosition.toFixed(1)}, stationPos=${station.position})`
|
||||
)
|
||||
|
||||
// Mark as pending BEFORE dispatch to prevent duplicate delivery attempts across frames
|
||||
pendingDeliveryRef.current.add(passenger.id)
|
||||
|
||||
dispatch({
|
||||
type: 'DELIVER_PASSENGER',
|
||||
passengerId: passenger.id,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
import { useMemo } from 'react'
|
||||
import { useSpring, useSprings } from '@react-spring/web'
|
||||
import type { RailroadTrackGenerator } from '../lib/RailroadTrackGenerator'
|
||||
|
||||
interface TrainTransform {
|
||||
@@ -27,22 +28,24 @@ export function useTrainTransforms({
|
||||
maxCars,
|
||||
carSpacing,
|
||||
}: UseTrainTransformsParams) {
|
||||
const [trainTransform, setTrainTransform] = useState<TrainTransform>({
|
||||
x: 50,
|
||||
y: 300,
|
||||
rotation: 0,
|
||||
})
|
||||
|
||||
// Update train position and rotation
|
||||
useEffect(() => {
|
||||
if (pathRef.current) {
|
||||
const transform = trackGenerator.getTrainTransform(pathRef.current, trainPosition)
|
||||
setTrainTransform(transform)
|
||||
// Calculate target locomotive transform
|
||||
const locomotiveTarget = useMemo<TrainTransform>(() => {
|
||||
if (!pathRef.current) {
|
||||
return { x: 50, y: 300, rotation: 0 }
|
||||
}
|
||||
return trackGenerator.getTrainTransform(pathRef.current, trainPosition)
|
||||
}, [trainPosition, trackGenerator, pathRef])
|
||||
|
||||
// Calculate train car transforms (each car follows behind the locomotive)
|
||||
const trainCars = useMemo((): TrainCarTransform[] => {
|
||||
// Animated spring for smooth locomotive movement
|
||||
const trainTransform = useSpring({
|
||||
x: locomotiveTarget.x,
|
||||
y: locomotiveTarget.y,
|
||||
rotation: locomotiveTarget.rotation,
|
||||
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
|
||||
})
|
||||
|
||||
// Calculate target transforms for train cars (each car follows behind the locomotive)
|
||||
const carTargets = useMemo((): TrainCarTransform[] => {
|
||||
if (!pathRef.current) {
|
||||
return Array.from({ length: maxCars }, () => ({
|
||||
x: 0,
|
||||
@@ -86,8 +89,21 @@ export function useTrainTransforms({
|
||||
})
|
||||
}, [trainPosition, trackGenerator, pathRef, maxCars, carSpacing])
|
||||
|
||||
// Calculate locomotive opacity (fade in/out through tunnels)
|
||||
const locomotiveOpacity = useMemo(() => {
|
||||
// Animated springs for smooth car movement
|
||||
const trainCars = useSprings(
|
||||
carTargets.length,
|
||||
carTargets.map((target) => ({
|
||||
x: target.x,
|
||||
y: target.y,
|
||||
rotation: target.rotation,
|
||||
opacity: target.opacity,
|
||||
position: target.position,
|
||||
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
|
||||
}))
|
||||
)
|
||||
|
||||
// Calculate target locomotive opacity (fade in/out through tunnels)
|
||||
const locomotiveOpacityTarget = useMemo(() => {
|
||||
const fadeInStart = 3
|
||||
const fadeInEnd = 8
|
||||
const fadeOutStart = 92
|
||||
@@ -109,9 +125,15 @@ export function useTrainTransforms({
|
||||
return 1 // Default to fully visible
|
||||
}, [trainPosition])
|
||||
|
||||
// Animated spring for smooth locomotive opacity
|
||||
const locomotiveOpacity = useSpring({
|
||||
opacity: locomotiveOpacityTarget,
|
||||
config: { tension: 600, friction: 35 }, // Fast/responsive to avoid lag
|
||||
})
|
||||
|
||||
return {
|
||||
trainTransform,
|
||||
trainCars,
|
||||
locomotiveOpacity,
|
||||
locomotiveOpacity: locomotiveOpacity.opacity,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,6 +99,8 @@ interface CompatibleGameState {
|
||||
*/
|
||||
interface ComplementRaceContextValue {
|
||||
state: CompatibleGameState // Return adapted state
|
||||
multiplayerState: ComplementRaceState // Raw multiplayer state for rendering other players
|
||||
localPlayerId: string | undefined // Local player ID for filtering
|
||||
dispatch: (action: { type: string; [key: string]: any }) => void // Compatibility layer
|
||||
lastError: string | null
|
||||
startGame: () => void
|
||||
@@ -304,19 +306,35 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
// Get local player ID
|
||||
const localPlayerId = useMemo(() => {
|
||||
return activePlayers.find((id) => {
|
||||
const foundId = activePlayers.find((id) => {
|
||||
const player = players.get(id)
|
||||
return player?.isLocal
|
||||
})
|
||||
return foundId
|
||||
}, [activePlayers, players])
|
||||
|
||||
// Debug logging ref (track last logged values)
|
||||
const lastLogRef = useState({ key: '', count: 0 })[0]
|
||||
|
||||
// Client-side game state (NOT synced to server - purely visual/gameplay)
|
||||
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
|
||||
const [clientPosition, setClientPosition] = useState(0)
|
||||
const [clientPressure, setClientPressure] = useState(0)
|
||||
|
||||
// Track if we've synced position from server (for reconnect/reload scenarios)
|
||||
const hasInitializedPositionRef = useRef(false)
|
||||
|
||||
// Ref to track latest position for broadcasting (avoids recreating interval on every position change)
|
||||
const clientPositionRef = useRef(clientPosition)
|
||||
|
||||
// Refs for throttled logging
|
||||
const lastBroadcastLogRef = useRef({ position: 0, time: 0 })
|
||||
const broadcastCountRef = useRef(0)
|
||||
const lastReceivedPositionsRef = useRef<Record<string, number>>({})
|
||||
|
||||
// Ref to hold sendMove so interval doesn't restart when sendMove changes
|
||||
const sendMoveRef = useRef(sendMove)
|
||||
useEffect(() => {
|
||||
sendMoveRef.current = sendMove
|
||||
}, [sendMove])
|
||||
|
||||
const [clientAIRacers, setClientAIRacers] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
@@ -443,16 +461,50 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
clientAIRacers,
|
||||
])
|
||||
|
||||
// Sync client position from server on reconnect/reload (multiplayer only)
|
||||
useEffect(() => {
|
||||
// Only sync if:
|
||||
// 1. We haven't synced yet
|
||||
// 2. Game is active
|
||||
// 3. We're in sprint mode
|
||||
// 4. We have a local player with a position from server
|
||||
if (
|
||||
!hasInitializedPositionRef.current &&
|
||||
multiplayerState.gamePhase === 'playing' &&
|
||||
multiplayerState.config.style === 'sprint' &&
|
||||
localPlayerId
|
||||
) {
|
||||
const serverPosition = multiplayerState.players[localPlayerId]?.position
|
||||
if (serverPosition !== undefined && serverPosition > 0) {
|
||||
console.log(`[POSITION_SYNC] Restoring position from server: ${serverPosition.toFixed(1)}%`)
|
||||
setClientPosition(serverPosition)
|
||||
hasInitializedPositionRef.current = true
|
||||
}
|
||||
}
|
||||
|
||||
// Reset sync flag when game ends
|
||||
if (multiplayerState.gamePhase !== 'playing') {
|
||||
hasInitializedPositionRef.current = false
|
||||
}
|
||||
}, [
|
||||
multiplayerState.gamePhase,
|
||||
multiplayerState.config.style,
|
||||
multiplayerState.players,
|
||||
localPlayerId,
|
||||
])
|
||||
|
||||
// Initialize game start time when game becomes active
|
||||
useEffect(() => {
|
||||
if (compatibleState.isGameActive && compatibleState.style === 'sprint') {
|
||||
if (gameStartTimeRef.current === 0) {
|
||||
gameStartTimeRef.current = Date.now()
|
||||
lastUpdateRef.current = Date.now()
|
||||
// Reset client state for new game
|
||||
setClientMomentum(10) // Start with gentle push
|
||||
setClientPosition(0)
|
||||
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
|
||||
// Reset client state for new game (only if not restored from server)
|
||||
if (!hasInitializedPositionRef.current) {
|
||||
setClientMomentum(10) // Start with gentle push
|
||||
setClientPosition(0)
|
||||
setClientPressure((10 / 100) * 150) // Initial pressure from starting momentum
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Reset when game ends
|
||||
@@ -548,14 +600,78 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
const currentRoute = multiplayerState.currentRoute
|
||||
// When route changes, reset position and give starting momentum
|
||||
if (currentRoute > 1 && compatibleState.style === 'sprint') {
|
||||
console.log(
|
||||
`[Provider] Route changed to ${currentRoute}, resetting position. Passengers: ${multiplayerState.passengers.length}`
|
||||
)
|
||||
setClientPosition(0)
|
||||
setClientMomentum(10) // Reset to starting momentum (gentle push)
|
||||
}
|
||||
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
|
||||
|
||||
// Keep position ref in sync with latest position
|
||||
useEffect(() => {
|
||||
clientPositionRef.current = clientPosition
|
||||
}, [clientPosition])
|
||||
|
||||
// Log when we receive position updates from other players
|
||||
useEffect(() => {
|
||||
if (!multiplayerState?.players || !localPlayerId) return
|
||||
|
||||
Object.entries(multiplayerState.players).forEach(([playerId, player]) => {
|
||||
if (playerId === localPlayerId || !player.isActive) return
|
||||
|
||||
const lastPos = lastReceivedPositionsRef.current[playerId] ?? -1
|
||||
const currentPos = player.position
|
||||
|
||||
// Log when position changes significantly (>2%)
|
||||
if (Math.abs(currentPos - lastPos) > 2) {
|
||||
console.log(
|
||||
`[POS_RECEIVED] ${player.name}: ${currentPos.toFixed(1)}% (was ${lastPos.toFixed(1)}%, delta=${(currentPos - lastPos).toFixed(1)}%)`
|
||||
)
|
||||
lastReceivedPositionsRef.current[playerId] = currentPos
|
||||
}
|
||||
})
|
||||
}, [multiplayerState?.players, localPlayerId])
|
||||
|
||||
// Broadcast position to server for multiplayer ghost trains
|
||||
useEffect(() => {
|
||||
const isGameActive = multiplayerState.gamePhase === 'playing'
|
||||
const isSprint = multiplayerState.config.style === 'sprint'
|
||||
|
||||
if (!isGameActive || !isSprint || !localPlayerId) {
|
||||
return
|
||||
}
|
||||
|
||||
console.log('[POS_BROADCAST] Starting position broadcast interval')
|
||||
|
||||
// Send position update every 100ms for smoother ghost trains (reads from refs to avoid restarting interval)
|
||||
const interval = setInterval(() => {
|
||||
const currentPos = clientPositionRef.current
|
||||
broadcastCountRef.current++
|
||||
|
||||
// Throttled logging: only log when position changes by >2% or every 5 seconds
|
||||
const now = Date.now()
|
||||
const posDiff = Math.abs(currentPos - lastBroadcastLogRef.current.position)
|
||||
const timeDiff = now - lastBroadcastLogRef.current.time
|
||||
|
||||
if (posDiff > 2 || timeDiff > 5000) {
|
||||
console.log(
|
||||
`[POS_BROADCAST] #${broadcastCountRef.current} pos=${currentPos.toFixed(1)}% (delta=${posDiff.toFixed(1)}%)`
|
||||
)
|
||||
lastBroadcastLogRef.current = { position: currentPos, time: now }
|
||||
}
|
||||
|
||||
sendMoveRef.current({
|
||||
type: 'UPDATE_POSITION',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { position: currentPos },
|
||||
} as ComplementRaceMove)
|
||||
}, 100)
|
||||
|
||||
return () => {
|
||||
console.log(`[POS_BROADCAST] Stopping interval (sent ${broadcastCountRef.current} updates)`)
|
||||
clearInterval(interval)
|
||||
}
|
||||
}, [multiplayerState.gamePhase, multiplayerState.config.style, localPlayerId, viewerId])
|
||||
|
||||
// Keep lastLogRef for future debugging needs
|
||||
// (removed debug logging)
|
||||
|
||||
@@ -769,7 +885,6 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
case 'START_NEW_ROUTE':
|
||||
// Send route progression to server
|
||||
if (action.routeNumber !== undefined) {
|
||||
console.log(`[Provider] Dispatching START_NEW_ROUTE for route ${action.routeNumber}`)
|
||||
sendMove({
|
||||
type: 'START_NEW_ROUTE',
|
||||
playerId: activePlayers[0] || '',
|
||||
@@ -914,6 +1029,8 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
|
||||
const contextValue: ComplementRaceContextValue = {
|
||||
state: compatibleState, // Use transformed state
|
||||
multiplayerState, // Expose raw multiplayer state for ghost trains
|
||||
localPlayerId, // Expose local player ID for filtering
|
||||
dispatch,
|
||||
lastError,
|
||||
startGame,
|
||||
|
||||
@@ -97,6 +97,9 @@ export class ComplementRaceValidator
|
||||
case 'UPDATE_INPUT':
|
||||
return this.validateUpdateInput(state, move.playerId, move.data.input)
|
||||
|
||||
case 'UPDATE_POSITION':
|
||||
return this.validateUpdatePosition(state, move.playerId, move.data.position)
|
||||
|
||||
case 'CLAIM_PASSENGER':
|
||||
return this.validateClaimPassenger(
|
||||
state,
|
||||
@@ -397,6 +400,39 @@ export class ComplementRaceValidator
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
private validateUpdatePosition(
|
||||
state: ComplementRaceState,
|
||||
playerId: string,
|
||||
position: number
|
||||
): ValidationResult {
|
||||
if (state.gamePhase !== 'playing') {
|
||||
return { valid: false, error: 'Game not in playing phase' }
|
||||
}
|
||||
|
||||
const player = state.players[playerId]
|
||||
if (!player) {
|
||||
return { valid: false, error: 'Player not found' }
|
||||
}
|
||||
|
||||
// Validate position is a reasonable number (0-100)
|
||||
if (typeof position !== 'number' || position < 0 || position > 100) {
|
||||
return { valid: false, error: 'Invalid position value' }
|
||||
}
|
||||
|
||||
const newState: ComplementRaceState = {
|
||||
...state,
|
||||
players: {
|
||||
...state.players,
|
||||
[playerId]: {
|
||||
...player,
|
||||
position,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
return { valid: true, newState }
|
||||
}
|
||||
|
||||
// ==========================================================================
|
||||
// Sprint Mode: Passenger Management
|
||||
// ==========================================================================
|
||||
|
||||
@@ -143,6 +143,7 @@ export type ComplementRaceMove = BaseGameMove &
|
||||
// Playing phase
|
||||
| { type: 'SUBMIT_ANSWER'; data: { answer: number; responseTime: number } }
|
||||
| { type: 'UPDATE_INPUT'; data: { input: string } } // Show "thinking" indicator
|
||||
| { type: 'UPDATE_POSITION'; data: { position: number } } // Sprint mode: sync train position
|
||||
| { type: 'CLAIM_PASSENGER'; data: { passengerId: string; carIndex: number } } // Sprint mode: pickup
|
||||
| { type: 'DELIVER_PASSENGER'; data: { passengerId: string } } // Sprint mode: delivery
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { AbacusReact } from '@soroban/abacus-react'
|
||||
import { AbacusReact, useAbacusConfig } from '@soroban/abacus-react'
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { css } from '../../styled-system/css'
|
||||
|
||||
@@ -93,6 +93,8 @@ interface DraggableCardProps {
|
||||
}
|
||||
|
||||
function DraggableCard({ card, containerRef }: DraggableCardProps) {
|
||||
const appConfig = useAbacusConfig()
|
||||
|
||||
// Track position - starts at initial, updates when dragged
|
||||
const [position, setPosition] = useState({ x: card.initialX, y: card.initialY })
|
||||
const [rotation, setRotation] = useState(card.initialRotation) // Now dynamic!
|
||||
@@ -337,7 +339,7 @@ function DraggableCard({ card, containerRef }: DraggableCardProps) {
|
||||
transformOrigin: 'center',
|
||||
})}
|
||||
>
|
||||
<AbacusReact value={card.number} columns={3} beadShape="circle" />
|
||||
<AbacusReact value={card.number} columns={3} beadShape={appConfig.beadShape} />
|
||||
</div>
|
||||
|
||||
{/* Number display */}
|
||||
|
||||
@@ -622,6 +622,7 @@ export function LevelSliderDisplay({
|
||||
borderColor: 'gray.700',
|
||||
overflow: 'hidden',
|
||||
flex: 1,
|
||||
minH: 0, // Allow flex shrinking
|
||||
})}
|
||||
>
|
||||
{/* Level Details (only for Kyu levels) */}
|
||||
@@ -640,16 +641,12 @@ export function LevelSliderDisplay({
|
||||
<div
|
||||
className={css({
|
||||
flex: '0 0 auto',
|
||||
display: 'grid',
|
||||
gridTemplateColumns: {
|
||||
base: 'repeat(2, 1fr)',
|
||||
sm: 'repeat(3, 1fr)',
|
||||
lg: 'repeat(2, 1fr)',
|
||||
},
|
||||
display: { base: 'none', lg: 'grid' },
|
||||
gridTemplateColumns: 'repeat(2, 1fr)',
|
||||
gap: '2',
|
||||
p: '2',
|
||||
w: '100%',
|
||||
maxW: { base: '100%', lg: '400px' },
|
||||
maxW: '400px',
|
||||
alignContent: 'center',
|
||||
justifyItems: 'center',
|
||||
})}
|
||||
@@ -748,7 +745,7 @@ export function LevelSliderDisplay({
|
||||
) : null
|
||||
})()}
|
||||
|
||||
{/* Abacus (right-aligned for Kyu, centered for Dan) */}
|
||||
{/* Abacus (centered on mobile, right-aligned for Kyu on desktop, centered for Dan) */}
|
||||
<div
|
||||
className={css({
|
||||
display: 'flex',
|
||||
@@ -759,6 +756,7 @@ export function LevelSliderDisplay({
|
||||
overflowX: 'auto',
|
||||
overflowY: 'hidden',
|
||||
minW: 0, // Allow flex shrinking
|
||||
minH: 0, // Allow flex shrinking
|
||||
})}
|
||||
>
|
||||
<animated.div
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.63.7",
|
||||
"version": "4.68.2",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user