Compare commits
8 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
79bc0e4c80 | ||
|
|
df60824f37 | ||
|
|
543675340d | ||
|
|
9b1d47d4c7 | ||
|
|
659464d3b4 | ||
|
|
06cd94b24c | ||
|
|
ada0becee5 | ||
|
|
c5fba5b7dd |
28
CHANGELOG.md
28
CHANGELOG.md
@@ -1,3 +1,31 @@
|
||||
## [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)
|
||||
|
||||
|
||||
|
||||
@@ -4,24 +4,62 @@ import { useEffect, 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
|
||||
* Shows opponent positions in real-time during steam sprint races
|
||||
* Uses per-car adaptive opacity: cars are ghostly when overlapping local train,
|
||||
* solid when separated
|
||||
*/
|
||||
export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: GhostTrainProps) {
|
||||
export function GhostTrain({
|
||||
player,
|
||||
trainPosition,
|
||||
localTrainCarPositions,
|
||||
maxCars,
|
||||
carSpacing,
|
||||
trackGenerator,
|
||||
pathRef,
|
||||
}: GhostTrainProps) {
|
||||
const ghostRef = useRef<SVGGElement>(null)
|
||||
|
||||
// Calculate train transform using same logic as local player
|
||||
const trainTransform = useMemo(() => {
|
||||
// Calculate ghost train locomotive transform and opacity
|
||||
const locomotiveTransform = useMemo<CarTransform | null>(() => {
|
||||
if (!pathRef.current) {
|
||||
return { x: 0, y: 0, rotation: 0, opacity: 0 }
|
||||
return null
|
||||
}
|
||||
|
||||
const pathLength = pathRef.current.getTotalLength()
|
||||
@@ -39,85 +77,144 @@ export function GhostTrain({ player, trainPosition, trackGenerator, pathRef }: G
|
||||
x: point.x,
|
||||
y: point.y,
|
||||
rotation,
|
||||
opacity: 0.35, // Ghost effect - 35% opacity
|
||||
position: trainPosition,
|
||||
opacity: calculateCarOpacity(trainPosition, localTrainCarPositions),
|
||||
}
|
||||
}, [trainPosition, pathRef])
|
||||
}, [trainPosition, localTrainCarPositions, pathRef])
|
||||
|
||||
// Calculate ghost train car transforms (each car behind locomotive)
|
||||
const carTransforms = 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])
|
||||
|
||||
// Log only once when this ghost train first renders
|
||||
const hasLoggedRef = useRef(false)
|
||||
useEffect(() => {
|
||||
if (!hasLoggedRef.current && trainTransform.opacity > 0) {
|
||||
if (!hasLoggedRef.current && locomotiveTransform) {
|
||||
console.log('[GhostTrain] rendering:', player.name, 'at position:', trainPosition.toFixed(1))
|
||||
hasLoggedRef.current = true
|
||||
}
|
||||
}, [trainTransform.opacity, player.name, trainPosition])
|
||||
}, [locomotiveTransform, player.name, trainPosition])
|
||||
|
||||
// Don't render if position data isn't ready
|
||||
if (trainTransform.opacity === 0) {
|
||||
if (!locomotiveTransform) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<g
|
||||
ref={ghostRef}
|
||||
data-component="ghost-train"
|
||||
data-player-id={player.id}
|
||||
transform={`translate(${trainTransform.x}, ${trainTransform.y}) rotate(${trainTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={trainTransform.opacity}
|
||||
style={{
|
||||
transition: 'opacity 0.3s ease-in',
|
||||
}}
|
||||
>
|
||||
<g ref={ghostRef} data-component="ghost-train" data-player-id={player.id}>
|
||||
{/* Ghost locomotive */}
|
||||
<text
|
||||
data-element="ghost-locomotive"
|
||||
x={0}
|
||||
y={0}
|
||||
textAnchor="middle"
|
||||
<g
|
||||
transform={`translate(${locomotiveTransform.x}, ${locomotiveTransform.y}) rotate(${locomotiveTransform.rotation}) scale(-1, 1)`}
|
||||
opacity={locomotiveTransform.opacity}
|
||||
style={{
|
||||
fontSize: '100px',
|
||||
filter: `drop-shadow(0 2px 8px ${player.color || 'rgba(100, 100, 255, 0.6)'})`,
|
||||
pointerEvents: 'none',
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
}}
|
||||
>
|
||||
🚂
|
||||
</text>
|
||||
<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 train */}
|
||||
<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>
|
||||
{/* 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 train */}
|
||||
<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>
|
||||
{/* 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>
|
||||
</g>
|
||||
|
||||
{/* Ghost cars - each with individual opacity */}
|
||||
{carTransforms.map((car, index) => (
|
||||
<g
|
||||
key={`car-${index}`}
|
||||
transform={`translate(${car.x}, ${car.y}) rotate(${car.rotation}) scale(-1, 1)`}
|
||||
opacity={car.opacity}
|
||||
style={{
|
||||
transition: 'opacity 0.3s ease-in-out',
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
</g>
|
||||
))}
|
||||
</g>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -201,6 +201,16 @@ 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) {
|
||||
@@ -292,6 +302,9 @@ export function SteamTrainJourney({
|
||||
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}
|
||||
/>
|
||||
|
||||
@@ -325,6 +325,9 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
const [clientMomentum, setClientMomentum] = useState(10) // Start at 10 for gentle push
|
||||
const [clientPosition, setClientPosition] = useState(0)
|
||||
const [clientPressure, setClientPressure] = useState(0)
|
||||
|
||||
// Ref to track latest position for broadcasting (avoids recreating interval on every position change)
|
||||
const clientPositionRef = useRef(clientPosition)
|
||||
const [clientAIRacers, setClientAIRacers] = useState<
|
||||
Array<{
|
||||
id: string
|
||||
@@ -564,6 +567,30 @@ export function ComplementRaceProvider({ children }: { children: ReactNode }) {
|
||||
}
|
||||
}, [multiplayerState.currentRoute, compatibleState.style, multiplayerState.passengers.length])
|
||||
|
||||
// Keep position ref in sync with latest position
|
||||
useEffect(() => {
|
||||
clientPositionRef.current = clientPosition
|
||||
}, [clientPosition])
|
||||
|
||||
// Broadcast position to server for multiplayer ghost trains
|
||||
useEffect(() => {
|
||||
if (!compatibleState.isGameActive || compatibleState.style !== 'sprint' || !localPlayerId) {
|
||||
return
|
||||
}
|
||||
|
||||
// Send position update every 200ms (reads from ref to avoid restarting interval)
|
||||
const interval = setInterval(() => {
|
||||
sendMove({
|
||||
type: 'UPDATE_POSITION',
|
||||
playerId: localPlayerId,
|
||||
userId: viewerId || '',
|
||||
data: { position: clientPositionRef.current },
|
||||
} as ComplementRaceMove)
|
||||
}, 200)
|
||||
|
||||
return () => clearInterval(interval)
|
||||
}, [compatibleState.isGameActive, compatibleState.style, localPlayerId, viewerId, sendMove])
|
||||
|
||||
// Keep lastLogRef for future debugging needs
|
||||
// (removed debug logging)
|
||||
|
||||
|
||||
@@ -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 @@
|
||||
{
|
||||
"name": "soroban-monorepo",
|
||||
"version": "4.64.2",
|
||||
"version": "4.66.1",
|
||||
"private": true,
|
||||
"description": "Beautiful Soroban Flashcard Generator - Monorepo",
|
||||
"workspaces": [
|
||||
|
||||
Reference in New Issue
Block a user