//	NEBGameState.swift
//
//	© 2026 by Jeff Weeks
//	See TermsOfUse.txt

import CoreFoundation	//	for CFAbsoluteTimeGetCurrent


//	As a billiard ball rolls along, constant deceleration looks good
//	to the human eye and also seems to be at least roughly consistent
//	with the analysis of rolling resistance found at
//
//		https://en.wikipedia.org/wiki/Rolling_resistance
//
//	For slow speeds, that page says that the force F
//	needed to maintain a constant speed is roughly
//
//		F = Crr * N
//
//	where Crr is the dimensionless rolling resistance coefficient
//	and N is the normal force, which in our case is just
//	the billiard ball's weight. Because Crr and N are both constant,
//	the force F is constant too. This implies that if we don't
//	apply such a force, the billiard ball will decelerate as if
//	a constant force of that magnitude were pushing it backwards.
//	Given that
//
//		    dP     dV
//		F = -- = m --
//		    dt     dt
//
//	we see that the deceleration dV/dt is roughly constant.
//
let ballDeceleration = 0.1875	//	in m/sec²

let colorSaturationChangeDuration: Double = 0.5	//	in seconds
let colorSaturationChangeRate: Double = 1.0 / colorSaturationChangeDuration	//	in sec⁻¹

let cueStickOpacityChangeDuration: Double = 0.5	//	in seconds
let cueStickOpacityChangeRate: Double = 1.0 / cueStickOpacityChangeDuration	//	in sec⁻¹


//	Ball 0 is the cue ball.
//	Balls 1-15 are the object balls with the usual numbers.
let numBalls = 16


extension NEBRenderer {

	enum PlayerType {

		case human
		case computer
		
		func opponent() -> PlayerType {
			switch self {
			case .human:    return .computer
			case .computer: return .human
			}
		}
	}

	enum PlayerStatus {

		case shootingForEither		//	We're shooting for either solids or stripes.
		case shootingForSolids		//	We're shooting for solids.
		case shootingForStripes		//	We're shooting for stripes.
		case shootingForEightBall	//	We're shooting for the 8-ball.
		case won					//	We've won. The game is over.
		case lost					//	We've lost. The game is over.
	}

	enum BallStatus {
		case inPlay
		case inHand	//	only the cue ball may be inHand
		case sunk
	}

	enum GameMessageState {

		//	When the player begins a game, they must
		//
		//	  - walk backwards past the edge of the table, and
		//
		//	  - raise their dominant hand above the height of the table,
		//		  at which point we'll give them a cue ball, and
		//
		//	  - place the cue ball onto the table.
		//
		//	Once they get the cue ball in hand, they are
		//	effectively in the generic game-play state.
		//	The various subsequent states specify what message,
		//	if any, we should display.
		//
		case awaitingFirstFrame	//	needed only as a way to trigger
								//	a texture update for walkingBackwards
		case walkingBackwards
		case raisingHand
		case placingCueBall
		case planningFirstShot
		case gameInProgress
		case gameInProgressWithMiscue
		case gameHasEnded

		var cueBallIsAvailable: Bool {
			switch self {
			case .awaitingFirstFrame:		return false
			case .walkingBackwards:			return false
			case .raisingHand:				return false
			case .placingCueBall:			return true
			case .planningFirstShot:		return true
			case .gameInProgress:			return true
			case .gameInProgressWithMiscue:	return true
			case .gameHasEnded:				return false
			}
		}
		
		var cueStickIsAvailable: Bool {
			switch self {
			case .awaitingFirstFrame:		return false
			case .walkingBackwards:			return false
			case .raisingHand:				return false
			case .placingCueBall:			return false
			case .planningFirstShot:		return true
			case .gameInProgress:			return true
			case .gameInProgressWithMiscue:	return true
			case .gameHasEnded:				return false
			}
		}
		
		func messageText(
			hand: PlayersHand,
			geometryType: GeometryType,
			howGameEnded: HowGameEnded?
		) -> [ (text: String, relativeFontSize: Double) ] {	//	one tuple for each line of text

			if makeScreenshots {
				return []
			}

			switch self {
			case .awaitingFirstFrame:
				return [
				]
			case .walkingBackwards:
				return [
					("Please walk backwards until you see", 1.0),
					("the whole table in front of you", 1.0)
				]
			case .raisingHand:
				return [
					("Raise your \(hand.name) hand to get a cue ball", 1.0)
				]
			case .placingCueBall:
				return [
					("You may place the cue ball", 1.0),
					("anywhere on the table", 1.0)
				]
			case .planningFirstShot:
				//	Let's assume that in most cases the player
				//	will have tried Euclidean and/or hyperbolic pool
				//	before moving on to spherical pool.
				//	So in spherical pool let's put the standard
				//	cue-stick-and-Digital-Crown message in a small font
				//	and instead emphasize to the sphere-specific message
				//	about not walking around the corners.
				//
				//		Alternatively, we could show the standard message
				//		while the player plans their first shot, and then
				//		the sphere-specific message once the game is underway
				//		and they're awaiting their second shot, but that
				//		would, I think, be distracting and annoying.
				//
				if geometryType == .spherical {
					return [
						("⚠️", 2.0),
						("If you walk the usual way around", 1.0),
						("a table-corner in spherical space,", 1.0),
						("visionOS may fade you back to real reality.", 1.0),
						("So instead, please walk across the table itself", 1.0),
						("to get from one side to another.", 1.0),
						("-", 0.25),	//	makes an empty line
						("Extend both hands to get a cue stick.", 0.5),
						("To exit the game at any time, press the Digital Crown.", 0.5)
					]
				} else {
					return [
						("Make sure the Vision Pro can", 1.0),
						("see both your hands, then use", 1.0),
						("the cue stick to begin play. Enjoy!", 1.0),
						("-", 0.25),	//	makes an empty line
						("To exit the game at any time, press the Digital Crown.", 0.5)
					]
				}
			case .gameInProgress:
				return [
				]
			case .gameInProgressWithMiscue:
				return [
					("⚠️", 2.0),
					("For a successful shot,", 1.0),
					("place your front hand 15–20 cm from the cue ball,", 1.0),
					("hold the cue stick parallel to the table, and", 1.0),
					("move the cue stick smoothly along itself.", 1.0)
				]
			case .gameHasEnded:
			
				let theGameEndText: [(String, Double)]
				if let theHowGameEnded = howGameEnded {
				
					switch theHowGameEnded {
					case .humanSunk8BallTooSoon:
						theGameEndText = [
							("You sunk the 8-ball", 1.0),
							("too soon, so you lost.", 1.0),
							("🙁", 2.0)
						]
					case .computerSunk8BallTooSoon:
						theGameEndText = [
							("The computer sunk the 8-ball", 1.0),
							("too soon, so you won!", 1.0),
							("🙂", 2.0)
						]
					case .humanSunkCueBallWith8Ball:
						theGameEndText = [
							("You sunk the cue ball along with", 1.0),
							("the 8-ball, so you lost.", 1.0),
							("🙁", 2.0)
						]
					case .computerSunkCueBallWith8Ball:
						theGameEndText = [
							("The computer sunk the cue ball", 1.0),
							("along with the 8-ball, so you won!", 1.0),
							("🙂", 2.0)
						]
					case .humanSunk8BallAndWon:
						theGameEndText = [
							("Congratulations, you won!", 1.0),
							("🙂", 2.0)
						]
					case .computerSunk8BallAndWon:
						theGameEndText = [
							("The computer won.", 1.0),
							("🙁", 2.0)
						]
					}
				} else {
					assertionFailure("Internal error: state reached gameHasEnded while howGameEnded == nil")
					theGameEndText = [("<MISSING REASON FOR GAME ENDING>", 1.0)]
				}
				
				let theHowToReturnText = [
					("To return to the Control Panel,", 0.5),
					("press the Digital Crown.", 0.5)
				]
				
				return theGameEndText + theHowToReturnText
			}
		}
	}

	struct BilliardBall {

		var status: BallStatus = .sunk

		//	If the ball is currently inPlay, where is it?
		//
		//	The following matrices map the ball mesh
		//	from its raw placement centered at the origin (0,0,0,ρ)
		//	to its desired placement on the table.
		//	The mapping is the composition of three factors:
		//
		//		rotation
		//			rotates the ball about its center (0,0,0,ρ).
		//
		//		ball lift
		//			translates the ball in the y direction
		//			(using an isometry of the curved 3D space)
		//			so that the rotated ball's "south pole"
		//			sits at (0,0,0,ρ).
		//
		//		placement
		//			moves the ball to its desired placement, but
		//			with its south pole still "on the floor" at y = 0.
		//			(The placement is an isometry of "the floor",
		//			keeping the whole floor at y = 0.)
		//
		//	The ball lift is defined globally, so there's no need
		//	to include it in this BilliardBall struct.
		//
		//		Note:
		//			When a ball bounces off a cushion or another ball,
		//			its rotation will change by an appropriate rotation
		//			about the y axis, to align the ball's new direction
		//			of motion with the negative z axis. Its placement
		//			will change by an equal but opposite rotation,
		//			to keep the ball in the same placement on the table.
		//
		var rotation: simd_double4x4 = matrix_identity_double4x4	//	dimensionless
		var placement: simd_double4x4 = matrix_identity_double4x4	//	dimensionless

		//	Conceptually speaking, the ball's speed gets applied after
		//	the rotation and the ball lift, but before the placement.
		//	It always acts in the negative z direction, leaving it
		//	to the placement to give the ball's true location and
		//	direction of travel. (Why the negative z axis instead
		//	of the positive z axis?  Because that's consistent
		//	with ARKit's convention that the negative z direction
		//	is the "forward" direction.)  In effect, the speed says
		//	how fast the ball's point of contact with the table is
		//	moving forwards.
		//
		var speed: Double = 0.0	//	meters/second
		
		//	Only the balls that the player is shooting for are rendered in color.
		//	When the player gets/loses the turn, we'll immediately
		//	set this ball's wantsSaturatedColor flag to the appropriate
		//	value and then let the colorSaturation gradually drift
		//	towards 0.0 or 1.0 over a period of roughly a half second.
		var wantsSaturatedColor: Bool = true
		var colorSaturation: Double = 1.0	//	0.0 = fully greyscale, 1.0 = fully saturated
	}

	struct CueStick {
		var previous: simd_double4x4?	//	in table coordinates; nil if pose unknown
		var current:  simd_double4x4?	//	in table coordinates; nil if pose unknown
		
		mutating func movedTo(
			_ newPose: simd_double4x4?
		) {
			previous = current
			current = newPose
		}
		
		//	We'll render a cue stick opaque when it's that player's turn,
		//	but partially transparent when it's the other player's turn.
		//	We represent the opacity by a floating-point number so
		//	the cue stick can fade in and out gradually.
		var opacity: Double = 1.0	//	0.0 = fully transparent, 1.0 = fully opaque
	}

	struct ShotStatus {
		var cueBallSunk:   Bool = false
		var solidsSunk:    Bool = false
		var eightBallSunk: Bool = false
		var stripesSunk:   Bool = false
	}

	enum HowGameEnded {
		case humanSunk8BallTooSoon
		case computerSunk8BallTooSoon
		case humanSunkCueBallWith8Ball
		case computerSunkCueBallWith8Ball
		case humanSunk8BallAndWon
		case computerSunk8BallAndWon
	}

	//	NEBRenderer contains a single instance of an NEBGameState
	//	which doesn't need to be passed around anywhere,
	//	so let's make it a value struct instead of a reference class.
	//
	//		Caution: If we did ever pass it to a function,
	//		we'd be passing a copy, not the original.
	//
	struct NEBGameState {

		//	All game state is stored in table coordinates (not world coordinates).

		//	Play in spherical, Euclidean or hyperbolic space?
		//
		//		Note: This is a copy of NEBRenderer's spaceGeometry.
		//		They're 'let' constants, so the duplicate sources
		//		of truth shouldn't be a problem.
		//
		let spaceGeometry: NEBSpaceGeometry
		
		//	Only updateGameMessageState() is allowed to change
		//	the gameMessageState, because the renderer will need
		//	to know when to rebuild the messageTexture.
		//	As a consequence, we should never rely on the gameMessageState
		//	for up-to-the-moment reliable information about
		//	the state of the game itself.
		var gameMessageState: GameMessageState = .awaitingFirstFrame

		//	Is it the human's turn or the computer's?
		var whoseTurn: PlayerType = .human

		//	Give the player a little help?
		let guardianAngelFactor: Double	//	∈ 0.0 ... 1.0

		//	Which player is shooting for which balls?
		var playerStatus: [PlayerType: PlayerStatus] = [
			.human:    .shootingForEither,	//	We'll be force-unwrapping calls to playerStatus,
			.computer: .shootingForEither]	//	so both key-value pairs must always be present.

		//	Ball status and poses.
		var balls: [BilliardBall] = Array(repeating: BilliardBall(), count: numBalls)
		
		//	When the cue ball is inHand, the player may hold it anywhere.
		var inHandCueBallPose: simd_double4x4? = nil	//	in table coordintes;
														//	non-nil only when cue ball is inHand
		
		//	Cue stick opacity and poses.
		var cueStick: [PlayerType: CueStick] = [
			.human:    CueStick(),	//	We'll be force-unwrapping calls to cueStick,
			.computer: CueStick()]	//	so both key-value pairs must always be present.

		//	Keep track of which balls get sunk during a shot
		//	and interpret the result.
		var shotStatus: ShotStatus?
		
		//	Whenever the most recent attempted shot was a miscue,
		//	the renderer will display some hopefully helpful hints.
		var lastAttemptedShotWasMiscue: Bool = false
		
		//	Record how the game ended, so the renderer
		//	can display a message to let the player know.
		var howGameEnded: HowGameEnded? = nil
		var gameIsOver: Bool { howGameEnded != nil }
		
		init(
			spaceGeometry: NEBSpaceGeometry,
			guardianAngelFactor: Double	//	∈ 0.0 ... 1.0
		) {
		
			self.spaceGeometry = spaceGeometry
			self.guardianAngelFactor = guardianAngelFactor
			
			resetBalls()
		}

// MARK: -
// MARK: Reset balls

		mutating func resetBalls() {

			//	The easiest way to arrange 15 balls in a triangle
			//	is to start in barycentric coordinates, with the
			//	balls' centers sitting at the positive integer lattice points
			//	in the plane i+j+k = 4, namely
			//
			//		                 (0,0,4)
			//		             (1,0,3) (0,1,3)
			//		        (2,0,2) (1,1,2) (0,2,2)
			//		    (3,0,1) (2,1,1) (1,2,1) (0,3,1)
			//		(4,0,0) (3,1,0) (2,2,0) (1,3,0) (0,4,0)
			//
			//	The matrix
			//
			//		         (   0     1/√3   2/√6 )
			//		( i j k )( -1/√2   1/√3  -1/√6 )  =  ( x y z )
			//		         (  1/√2   1/√3  -1/√6 )
			//
			//	rotates the tilted plane i+j+k = 4 to the horizontal plane y = 4/√3.
			//	The resulting (x,z) coordinates give a tentative placement
			//	for the balls; the y coordinate gets ignored.
			//
			//	To allow for the curvature of the space, we must think
			//	in terms of packing the balls' orthogonal shadows.
			//	For a definition and explanation of orthogonal shadows,
			//	please see the extended comment in ballShadowRadius()
			//	in NEBSpaceGeometry.swift .
			//
			//	In the integer lattice shown above, neighboring
			//	orthogonal shadow centers sit √2 meters apart.
			//	Multiplying all distances by (orthogonal shadow diameter)/√2
			//	would give the correct shadow spacing in the Euclidean case.
			//	But if we used exactly the factor (orthogonal shadow diameter)/√2
			//	in the spherical case, then neighboring balls would
			//	overlap by some tiny amount, because in spherical geometry,
			//	if you start at the center of the packing and work your way
			//	outwards, the circumference of a circle grows more slowly
			//	than C = 2πr and so neighboring balls would slightly overlap.
			//
			//	For small r, the fractional difference between
			//	the circumference of a circle of radius r
			//	on a sphere of radius ρ
			//
			//		C = 2πρ sin(r/ρ) ≈ 2πρ ( r/ρ - (r/ρ)³/6 )
			//
			//	and the circumference of a circle of radius r
			//	on a Euclidean plane
			//
			//		C = 2πr
			//
			//	is
			//		-2πρ(r/ρ)³/6
			//		------------ = -(r/ρ)²/6
			//	         2πr
			//
			//	In practice, in the spherical case, the space radius
			//	varies from ρ ≈ 30 cm to ρ ≈ 100 cm while the radius
			//	of the ball packing, measured from the center of the packing
			//	to the center of a corner ball, is roughly
			//
			//		r ≈ (8/√3)(ball radius) = (8/√3)(0.028575) ≈ 13 cm
			//
			//	so in the outermost parts of the packing,
			//	the fractional difference will be somewhere between
			//
			//		-(r/ρ)²/6 = -(0.13/1.0)²/6 = -0.003 = -0.3%
			//	and
			//		-(r/ρ)²/6 = -(0.13/0.3)²/6 = -0.03 = -3%
			//
			//	So let's separate the balls' orthogonal shadows a little bit,
			//	to avoid overlaps. To do this, we'll pad the orthogonal
			//	shadow radius slightly when doing the packing, to leave
			//	a little extra space.
			//
			let theUnpaddedShadowRadius = spaceGeometry.ballShadowRadius
			let thePaddingFactor: Double
			switch spaceGeometry.geometryType {
			
			case .spherical:

				//	See explanation in comment immediately above.
				let thePackingRadiusInMeters = (8.0 * sqrt(1.0/3.0)) * theUnpaddedShadowRadius
				let thePackingRadiusInRadians = thePackingRadiusInMeters / spaceGeometry.spaceRadius
				thePaddingFactor = 1.0 + thePackingRadiusInRadians * thePackingRadiusInRadians / 6.0

			case .euclidean, .hyperbolic:
				
				//	This might not be necessary, but for future robustness
				//	let's add a tiny amount of extra space between the disks.
				thePaddingFactor = 1.0001
			}
			let thePaddedShadowRadius = thePaddingFactor * theUnpaddedShadowRadius
			
			//	To construct the ball placement matrix,
			//	rescale the matrix (from the discussion above)
			//
			//		         (   0     1/√3   2/√6 )
			//		( i j k )( -1/√2   1/√3  -1/√6 )  =  ( x y z )
			//		         (  1/√2   1/√3  -1/√6 )
			//
			//	and zero out the y coordinate.
			let theScaleFactor = thePaddedShadowRadius / sqrt(0.5)
			let theBallPlacementMatrix = simd_double3x3(
				SIMD3<Double>(   0.0     * theScaleFactor, 0.0,  2.0/sqrt(6.0) * theScaleFactor),
				SIMD3<Double>(-sqrt(0.5) * theScaleFactor, 0.0, -1.0/sqrt(6.0) * theScaleFactor),
				SIMD3<Double>(+sqrt(0.5) * theScaleFactor, 0.0, -1.0/sqrt(6.0) * theScaleFactor)
			)

			func place(
				ball ballIndex: Int,
				at ijk: SIMD3<Int>
			) {
				balls[ballIndex].status = .inPlay
				
				balls[ballIndex].rotation = simd_double4x4(randomQuaternion())
				
				let theTranslationInMeters	//	right-to-left matrix action
					= theBallPlacementMatrix * SIMD3<Double>(ijk)
				let theTranslationInRadians
					= theTranslationInMeters / spaceGeometry.spaceRadius
				balls[ballIndex].placement
					= geodesicTranslation(
						v: theTranslationInRadians,
						geometryType: spaceGeometry.geometryType)
				
				balls[ballIndex].speed = 0.0
				
				balls[ballIndex].wantsSaturatedColor = true
				balls[ballIndex].colorSaturation = 1.0
			}

			//	Let the cue ball start as sunk.
			//	We'll move it to inHand once the player
			//	steps back from the table and raises their hand.
			balls[0].status = .sunk
			balls[0].rotation = matrix_identity_double4x4
			balls[0].placement = matrix_identity_double4x4
			balls[0].speed = 0.0
					
			//	Place the eight ball near the center of the packing.
			place(ball: 8, at: SIMD3<Int>(2, 1, 1))

			//	Randomize the order of the 14 remaining balls.
			var theBalls: [Int] = [1,2,3,4,5,6,7,9,10,11,12,13,14,15]
			theBalls.shuffle()
			
			//	The official rules of 8-ball require that
			//	the "back corners" of the packing hold
			//	one solid-color ball and one striped ball.
			
			//	Extract an arbitrary ball to go at one back corner.
			let theFirstBackCorner = theBalls.remove(at: 0)
			place(ball: theFirstBackCorner, at: SIMD3<Int>(0, 4, 0))
			
			//	For the other back corner, extract an arbitrary ball
			//	of opposite stripeyness.
			let theSuitableArrayIndex = theBalls.firstIndex(where: { ballNumber in
				
					//	Balls numbered  less   than 8 are solid-color,
					//	balls numbered greater than 8 are striped.
					//
					return ((ballNumber < 8) != (theFirstBackCorner < 8))
				})
				?? 0
			let theSecondBackCorner = theBalls.remove(at: theSuitableArrayIndex)
			place(ball: theSecondBackCorner, at: SIMD3<Int>(0, 0, 4))
			
			//	Assign the 12 remaining balls to the remaining spots in the packing.
			var theArrayIndex = 0
			for i in 0...4 {
				for j in 0...(4 - i) {
					let k = 4 - (i + j)
					
					//	Skip the already-filled spots in the packing.
					if ((i == 2 && j == 1 && k == 1)	//	eight ball
					 || (i == 0 && j == 4 && k == 0)	//	first  back corner
					 || (i == 0 && j == 0 && k == 4)) {	//	second back corner
						continue
					}
					
					place(ball: theBalls[theArrayIndex], at: SIMD3<Int>(i, j, k))
					
					theArrayIndex += 1
				}
			}
			precondition(
				theArrayIndex == 12,
				"Internal error: wrong number of balls placed")

			//	Testing code
			//
			//	Because the gaps between the balls are invisible to the naked eye,
			//	I wrote the following code to confirm that no balls were overlapping
			//	and, with table edge length = 50 cm, got the expected results
			//	for each geometry:
			//
			//		spherical:
			//
			//			thePaddingFactor 1.02888642481773
			//			theFraction      1.01659863622376
			//
			//			We allowed for 2.9% extra spacing to be completely safe,
			//			and 1.7% was left unused, which is fine.
			//
			//		Euclidean:
			//
			//			thePaddingFactor 1.0001
			//			theFraction      1.0000999999999995
			//
			//			We allowed for 0.01% extra spacing, and
			//			essentially all of it was left unused.
			//
			//		hyperbolic:
			//
			//			thePaddingFactor 1.0001
			//			theFraction      1.0003033955716285
			//
			//			Again we allowed for a factor of 1.0001, and here we see
			//			that, as expected, the hyperbolic geometry has naturally
			//			spread the balls even further apart.
			//
			//	Note:  The different orders of magnitude for the measured spacings
			//	in the spherical and hyperbolic cases are due to the fact
			//	that in the spherical case the closest ball shadows occur
			//	for the balls furthest from the table center where the
			//	curvature's effects are greatest, while in the hyperbolic case
			//	the closest ball shadows occur near the table center
			//	where the curvature's effects are least.
			//
			//	The following code can be commented out once I'm done testing.
//	#warning("comment this out, but don't delete it")
//			let theDistanceFunction = spaceGeometry.tableDistanceFunction
//			var theMinDistance = Double.infinity
//			for theBallA in 1...15 {	//	excludes cue ball
//				for theBallB in 1...15 {
//					if theBallA == theBallB {
//						continue
//					}
//
//					let thePointA = spaceGeometry.spaceRadius * balls[theBallA].placement[3]
//					let thePointB = spaceGeometry.spaceRadius * balls[theBallB].placement[3]
//
//					let theDistance = theDistanceFunction(
//						VectorXZW(thePointA.x, thePointA.z, thePointA.w),
//						VectorXZW(thePointB.x, thePointB.z, thePointB.w))
//
//					if  theMinDistance > theDistance {
//						theMinDistance = theDistance
//					}
//				}
//			}
//			let theFraction = theMinDistance / (2.0 * theUnpaddedShadowRadius)
//			print("the min spacing = \(theFraction)")
		}

// MARK: -
// MARK: Update gameMessageState

		mutating func updateGameMessageState(
			physicalHeadsetPoseInWorldCoordinates: simd_double4x4,
			virtualGripHand: VirtualHand?,
			activePlayerTookShot: Bool
		) -> Bool {	//	Did the gameMessageState change?
		
			let theGeometryType = spaceGeometry.geometryType
			let theSpaceRadius  = spaceGeometry.spaceRadius
			
			let theOldGameMessageState = gameMessageState

			switch gameMessageState {
			
			case .awaitingFirstFrame:
				//	Changing from awaitingFirstFrame to walkingBackwards
				//	triggers the creation of the messageTexture.
				gameMessageState = .walkingBackwards
			
			case .walkingBackwards:
			
				//	The easier way to know when the player has
				//	walked most of the way to the edge of the table
				//	is to measure it in physical world coordinates.
				//	That way we don't need to think about whether
				//	keepTableLevel is enabled or not, nor work
				//	in geometry-dependent coordinates.
				
				let thePhysicalSpaceRadiusInMeters = 1.0	//	it's always 1 meter
				let theHeadsetPositionInMeters
					= thePhysicalSpaceRadiusInMeters
					* physicalHeadsetPoseInWorldCoordinates[3]
				
				let theHorizontalDistance = sqrt(
					theHeadsetPositionInMeters.x * theHeadsetPositionInMeters.x
				  + theHeadsetPositionInMeters.z * theHeadsetPositionInMeters.z )
				
				let theTableInradiusInRadians = theGeometryType.tableInradius
				let theTableInradiusInMeters = theSpaceRadius * theTableInradiusInRadians

				//	Set theThreshold to 0.8 times theTableInradiusInMeters.
				//	Just so the player can see that the table is there,
				//	that's all that's important.
				let theThreshold = 0.8 * theTableInradiusInMeters
				
				if theHorizontalDistance > theThreshold {
					gameMessageState = .raisingHand
				}

			case .raisingHand:
			
				if let theVirtualHand = virtualGripHand {
				
					let thePoint = theVirtualHand.cueBallInHandPoint
					
					let yInMeters = thePoint.y
					let yInRadians = yInMeters / theSpaceRadius
					let theHandHeightInRadians = {
						switch theGeometryType {
						case .spherical:	return safeAsin(yInRadians)
						case .euclidean:	return yInRadians
						case .hyperbolic:	return asinh(yInRadians)
						}
					}()
					let theHandHeightInMeters = theSpaceRadius * theHandHeightInRadians

					let theThreshold = 3.0 * ballRadius

					if theHandHeightInMeters > theThreshold {
						gameMessageState = .placingCueBall
						balls[0].status = .inHand
					}
				}
				
			case .placingCueBall:

				if balls[0].status == .inPlay {
					gameMessageState = .planningFirstShot
				}
				
			case .planningFirstShot:
			
				if activePlayerTookShot {
					gameMessageState = .gameInProgress
				}
				
				if lastAttemptedShotWasMiscue {
					gameMessageState = .gameInProgressWithMiscue
				}
				
			case .gameInProgress:
			
				if gameIsOver {
					gameMessageState = .gameHasEnded
				}
				
				if lastAttemptedShotWasMiscue {
					gameMessageState = .gameInProgressWithMiscue
				}
							
			case .gameInProgressWithMiscue:
			
				if gameIsOver {
					gameMessageState = .gameHasEnded
				}
				
				if !lastAttemptedShotWasMiscue {
					gameMessageState = .gameInProgress
				}
			
			case .gameHasEnded:
				break
				
			}
			
			return gameMessageState != theOldGameMessageState
		}

// MARK: -
// MARK: Update

		mutating func update(
			//	The timeInterval is the time from one predicted
			//	presentation time to the next, so it should be
			//	essentially constant if everything's going well.
			for timeInterval: Double
		) -> Bool {	//	Did the balls just now stop rolling?

			//	Are the balls rolling now, before we update the simulation?
			let theBallsWereRollingBefore = ballsAreRolling()

			//	If any balls are completely over a pocket, sink them.
			//
			//		Note #1
			//			Sink a ball just after we've drawn it
			//			and just before advancing the animation
			//			for the next frame, so that the user sees
			//			the ball get all the way over the hole
			//			before it disappears.
			//
			//		Note #2
			//			By calling sinkBalls() once per frame,
			//			rather than once per tiny time step (cf. below),
			//			we allow for the possibility that a fast moving
			//			ball might "bounce out of the pocket"
			//			without falling in, just like in real billiards.
			//
			sinkBalls()

			//	On a Euclidean billiards table, we could solve a quadratic equation
			//	to precisely determine ball collisions.
			//	The corresonding equation for a non-Euclidean billiards table
			//	is more complicated, involving cosine and sine or cosh and sinh,
			//	and I don't feel like getting things organized to solve it numerically.
			//	Instead let's just advance the balls one tiny step at a time,
			//	and detect the collisions as they happen.
			//
			//		Note:  The context.deltaTime values vary
			//		from frame to frame by a few tenths of a millisecond.
			//		So if two devices start with the same configuration
			//		of balls and animate them using the varying deltaTime values
			//		that theirs contexts give them, the ball layouts
			//		on the two devices will rapidly diverge.
			//		If we replace the variable context.deltaTime
			//		with a constant 1/60 sec frame time, then
			//		the two ball layouts will stay the same (to the naked eye)
			//		much longer, but even then, after a few shots
			//		with no external ball placement updates, they'll differ
			//		enough that on one device two balls will just barely miss
			//		while on the other device they'll just barely collide.
			//		I'm not sure what's causing that small difference,
			//		but in any case it's moot, because ultimately
			//		we need to be prepared for a Vision Pro
			//		with a ≥ 90 fps refresh rate sending data to a Mac
			//		with only a 60 fps refresh rate.
			//		Conclusion:  We can rely on different devices
			//		to run the animation in parallel and get the same results.
			//
		
			//	I hope the animation will never get interrupted in VR
			//	(which would be very unpleasant) but who knows what might
			//	happen when running on a Mac or iPad that's mirroring
			//	the game running on visionOS. To be safe, let's never
			//	update over an interval longer than 1/20 sec.
			let theMaxTimeInterval = 0.05	//	seconds
			let theSafeTimeInterval = min(timeInterval, theMaxTimeInterval)
			
			//	How finely should we simulate the billiard ball interactions?
			//	That is, into how many tiny time steps should we divide
			//	each frame period?
			let theNumTimeSteps = 16
			let theTinyTimeStep = theSafeTimeInterval / Double(theNumTimeSteps)

			for _ in 0 ..< theNumTimeSteps {
			
				//	Advance the balls through one tiny time step.
				rollAllBalls(Δt: theTinyTimeStep)

				//	Wherever two balls overlap, let them bounce off of each other.
				bounceBallsOffEachOther()

				//	Wherever a ball overlaps a cushion, let it bounce off it.
				bounceBallsOffCushions()

				//	The pockets attracts any balls within them.
				pullBallsIntoPockets(Δt: theTinyTimeStep)

				//	Rolling resistance slows the balls.
				slowTheBalls(Δt: theTinyTimeStep)
			}
				
			//	Make sure the balls' rotation and placement matrices
			//	remain valid elements of their respective matrix groups.
			gramSchmidt()

			//	Are the balls rolling now, after we've updated the simulation?
			let theBallsWereRollingAfter = ballsAreRolling()

			let theBallsJustNowStoppedRolling
				= ( theBallsWereRollingBefore && !theBallsWereRollingAfter )
			
			if theBallsJustNowStoppedRolling
			&& shotStatus != nil {	//	inactive human players don't track ShotStatus

				evaluateShot()
			}
			
			updateBallColorSaturation(for: timeInterval)
			updateCueStickOpacity(for: timeInterval)
					
			return theBallsJustNowStoppedRolling
		}
		
		mutating func sinkBalls() {

			//	When making screenshots, it's good to keep
			//	all the balls in play, for a more colorful image.
			if makeScreenshots {
				return
			}
			
			//	We'll sink a ball iff it sits entirely over a pocket.
			let theSinkRadius = pocketRadius - spaceGeometry.ballShadowRadius
			
			//	The closest a ball center can be to a corner of the table is roughly
			//	√2 * theBallShadowRadius (that value is exact in Euclidean geometry,
			//	but only approximate in spherical and hyperbolic).
			assert(
				theSinkRadius + pocketInset > sqrt(2.0) * spaceGeometry.ballShadowRadius,
				"Pockets are too small to completely hold a ball")

			var aBallWasSunk = false

			for i in 0 ..< numBalls {

				if balls[i].status != .inPlay {
					continue
				}

				let theBallShadowCenter4D = balls[i].placement * spaceGeometry.northPole
				let theBallShadowCenter = VectorXZW(
										theBallShadowCenter4D.x,
										//	omit the y component, which should be zero
										theBallShadowCenter4D.z,
										theBallShadowCenter4D.w)

				for thePocketCenter in spaceGeometry.pocketCentersXZW {
		
					let theDistance	//	in meters
						= spaceGeometry.tableDistanceFunction(
											theBallShadowCenter,
											thePocketCenter)

					//	If the ball lies entirely over the pocket,
					//	sink it.
					if theDistance < theSinkRadius {

						balls[i] = i == 0 ?
									BilliardBall(status: .inHand) :	//	cue ball
									BilliardBall(status: .sunk)		//	not cue ball
						
						aBallWasSunk = true

						switch i {
						case 0:
							shotStatus?.cueBallSunk   = true
						case 1...7:
							shotStatus?.solidsSunk    = true
						case 8:
							shotStatus?.eightBallSunk = true
						case 9...15:
							shotStatus?.stripesSunk   = true
						default:
							assertionFailure("Invalid ballNumber in sinkBalls()")
						}

						//	The ball is now sunk. There's no way
						//	(and no need) to check the remaining pockets.
						break
					}
				}
			}
			
			//	If a ball was sunk, play a ball sink sound.
			if aBallWasSunk {
				Task { await NEBSoundPlayer.shared.play(.ballSunk, volume: 1.0) }
			}
		}
	
		mutating func rollAllBalls(
			Δt: Double
		) {
		
			let theRotationalAnglePerMeterOfCircumference
				= 2.0 * Double.pi / spaceGeometry.ballCircumference	//	in radians/meter
				
			let theSpaceRadius = spaceGeometry.spaceRadius
		
			for i in 0 ..< numBalls {
			
				if balls[i].status != .inPlay {
					continue
				}
				
				//	How far forward should we move the ball's
				//	point of contact with the table?
				let Δs = balls[i].speed * Δt	//	in meters
				
				//	Through how many radians should we rotate the ball?
				let Δθ = theRotationalAnglePerMeterOfCircumference * Δs	//	in radians
				
				//	Rotate the ball towards the -z axis.
				let theIncrementalRotation = axisAlignedRotation(from: 1, towards: 2, angle: -Δθ)
				balls[i].rotation	//	right-to-left matrix action
					= theIncrementalRotation * balls[i].rotation
		
				//	Translate the ball in the direction of the -z axis.
				let theIncrementalTranslation
					= axisAlignedTranslation(
						axis: 2,
						distanceInRadians: -Δs / theSpaceRadius,
						geometryType: spaceGeometry.geometryType)
				balls[i].placement
					= balls[i].placement	//	right-to-left matrix action
					* theIncrementalTranslation
			}
		}
		
		mutating func bounceBallsOffEachOther() {

			//	The time step Δt in rollAllBalls() is small enough that
			//	we'll detect a collision pretty much as soon as two balls
			//	start to overlap. More precisely, billiard balls typically travel
			//	at speeds of ~ 1 meter/second, so over the course of one time step
			//	Δt = timeInterval / theNumTimeSteps = 11 ms / 16 ≈ 1 millisecond,
			//	each ball will advance roughly 1 millimeter, which is small
			//	compared to the billiard ball's ~57 millimeter diameter.
			//
			//	Moreover, the overlap of two colliding balls will be not only shallow,
			//	but also very brief -- at most 2 milliseconds -- so players are unlikely
			//	to notice it.
			//
			//	Nevertheless, if a more precise simulation is ever desired,
			//	we could of course use a larger value for theNumTimeSteps.

			//	We'll check whether each pair of billiard balls' "orthogonal shadows"
			//	(see the comment in NEBSpaceGeometry.ballShadowRadius for the definition)
			//	overlap each other.

			let theShadowDiameter = 2.0 * spaceGeometry.ballShadowRadius

			let theDistanceFunction = spaceGeometry.tableDistanceFunction

			//	Create an array containing only the balls that are still on the table.
			//	While we're at it, pre-compute each ball's shadow center.
			//
			let theBallsInPlay: [(ballNumber: Int, shadowCenterXZW: VectorXZW)]
				= (0 ..< numBalls).compactMap() { i in
				
				if balls[i].status != .inPlay {
					return nil
				}

				let theShadowCenter = balls[i].placement	//	right-to-left matrix action
									* spaceGeometry.northPole

				//	Work in the plane of the table, with y == 0.
				let theShadowCenterXZW = VectorXZW(
					theShadowCenter.x,
					//	omit y = 0.0
					theShadowCenter.z,
					theShadowCenter.w
				)
				
				return (i, theShadowCenterXZW)
			}
			
			//	Consider all pairs of balls.
			for i in 0 ..< theBallsInPlay.count {
			
				let a             = theBallsInPlay[i].ballNumber
				let shadowCenterA = theBallsInPlay[i].shadowCenterXZW
			
				for j in i+1 ..< theBallsInPlay.count {
			
					let b             = theBallsInPlay[j].ballNumber
					let shadowCenterB = theBallsInPlay[j].shadowCenterXZW
				
					//	Ignore pairs of balls that aren't moving.
					if balls[a].speed == 0.0 && balls[b].speed == 0.0 {
						continue
					}
					
					//	Does ball A overlap ball B?
					
					let theDistanceBetweenShadowCenters
						= theDistanceFunction(shadowCenterA, shadowCenterB)
						
					if theDistanceBetweenShadowCenters < theShadowDiameter {
						
						//	Transfer momentum between ball A and ball B
						//	iff they are still on an "inbound collision",
						//	not an "outbound collision".
						
						transferMomentumBetweenBalls(
							ballA: theBallsInPlay[i],
							ballB: theBallsInPlay[j])
					}
				}
			}
		}

		mutating func transferMomentumBetweenBalls(
			ballA: (ballNumber: Int, shadowCenterXZW: VectorXZW),
			ballB: (ballNumber: Int, shadowCenterXZW: VectorXZW)
		) {
			//	Here we'll analyze the motion of the balls'
			//	2-dimensional "orthogonal shadows" (see the comment
			//	in NEBSpaceGeometry.ballShadowRadius for the definition)
			//	as they move and collide in the plane of the table bed.
			//	No 3-dimensional physics is involved, and in particular no spin.
			//	In effect, we'll analyze the motion of 2-dimensional disks
			//	sliding along the table bed -- like hockey pucks on ice.

			//	Our plan is to split each disk's momentum into a component
			//	parallel to the interdisk direction and a component perpendicular
			//	to the interdisk direction. At the moment of collision
			//	the two disks exchange their parallel momenta while
			//	each retains its own perpendicular momentum.
			//	Of course, we must first verify that the disks are
			//	generally moving towards each other, so we process only
			//	"inbound collisions", not "outbound collisions".

			//	Compute a dimensionless orthonormal frame,
			//	not in the tangent space at each disk's current position
			//	on the table, but in the tangent space in the disk's
			//	own local coordinate system, where the disk always
			//	sits centered at (0,0,0,ρ). Let the orthonormal frame
			//	consist of
			//
			//		a vector pointing towards the other disk,
			//		a vector orthogonal to the first vector.
			//
			//	Because the disk lies in the plane of the table y = 0,
			//	each vector in the orthonormal frame will be
			//	of the form (Δx, 0, Δz, 0). For efficiency
			//	we'll record only the non-zero components (Δx, Δz).
			//
			guard let theDynamicalFrameA = computeDynamicalFrameDiskToDisk(
											shadowCenterXZW: ballA.shadowCenterXZW,
											neighborShadowCenterXZW: ballB.shadowCenterXZW,
											placement: balls[ballA.ballNumber].placement),
					
				  let theDynamicalFrameB = computeDynamicalFrameDiskToDisk(
											shadowCenterXZW: ballB.shadowCenterXZW,
											neighborShadowCenterXZW: ballA.shadowCenterXZW,
											placement: balls[ballB.ballNumber].placement)
			else {
				assertionFailure("Couldn't compute dynamical frames disk-to-disk")
				return
			}

			//	Compute the components of each disk's momentum respectively
			//	parallel and perpendicular to its interdisk direction,
			//	by dotting each disk's velocity with the basis vectors
			//	of the disk's dynamical frame. By convention each disk's
			//	velocity is always in the -z direction (for consistency
			//	with ARKit's convention that -z is the "forward" direction)
			//	in the disk's own coordinate system, so the velocity vector
			//	here is (v_x, v_z) = (0, -speed).

			let theMomentumParaA = dot(
									theDynamicalFrameA[0],
									SIMD2<Double>(0.0, -balls[ballA.ballNumber].speed))

			let theMomentumPerpA = dot(
									theDynamicalFrameA[1],
									SIMD2<Double>(0.0, -balls[ballA.ballNumber].speed))

			let theMomentumParaB = dot(
									theDynamicalFrameB[0],
									SIMD2<Double>(0.0, -balls[ballB.ballNumber].speed))

			let theMomentumPerpB = dot(
									theDynamicalFrameB[1],
									SIMD2<Double>(0.0, -balls[ballB.ballNumber].speed))

			//	The disks are moving towards each other
			//	iff the sum of the "parallel" momenta is positive.
			//	Otherwise they are moving away from each other.
			if theMomentumParaA + theMomentumParaB > 0.0 {
				
				//	Let the disks exchange their "parallel" momenta,
				//	noting that their coordinates systems are oppositely oriented
				//	(so inbound positive momentum from one disk becomes
				//	outbound negative momentum for the other).
				let theMomentumExchA = -theMomentumParaB
				let theMomentumExchB = -theMomentumParaA

				//	Reconstruct the two disk's velocity vectors based
				//	on the swapped momenta.

				let theNewVelocityA_XZ
					= theMomentumExchA * theDynamicalFrameA[0]
					+ theMomentumPerpA * theDynamicalFrameA[1]

				let theNewVelocityB_XZ
					= theMomentumExchB * theDynamicalFrameB[0]
					+ theMomentumPerpB * theDynamicalFrameB[1]

				//	Assign the new velocities to the balls.
				setBallVelocity(
					ballNumber: ballA.ballNumber,
					newVelocityXZ: theNewVelocityA_XZ)
				setBallVelocity(
					ballNumber: ballB.ballNumber,
					newVelocityXZ: theNewVelocityB_XZ)

				//	Play a ball collision sound.
				let theFullImpact = 2.0	//	this value seems to work well in practice
				let theRelativeImpact = 	//	∈  0.0 ... 1.0
					min(1.0, (theMomentumParaA + theMomentumParaB) / theFullImpact)
				let theVolume = 1.0 * theRelativeImpact
				Task { await NEBSoundPlayer.shared.play(.ballOnBall, volume: theVolume) }
			}
		}

		func computeDynamicalFrameDiskToDisk(
			shadowCenterXZW: VectorXZW,
			neighborShadowCenterXZW: VectorXZW,
			placement: simd_double4x4
		) -> simd_double2x2? {	//	Return dynamical frame in the xz plane
								//	in the ball's own local coordinate system,
								//	dimensionless

			//	Compute the difference vector running
			//	from the disk's center to the neighboring disk's center.
			let theCollisionDirectionInGameSpace = SIMD4<Double>(
				neighborShadowCenterXZW.x - shadowCenterXZW.x,	//	x
				0.0,											//	y
				neighborShadowCenterXZW.z - shadowCenterXZW.z,	//	z
				neighborShadowCenterXZW.w - shadowCenterXZW.w)	//	w

			//	Transfer theCollisionDirection from the disk's current location
			//	on the table to the disk's own local coordinate system
			//	(where it sits at the origin (0,0,0,ρ) ).
			var theCollisionDirection	//	right-to-left matrix action
				= geometricInverse(
					of: placement,
					geometryType: spaceGeometry.geometryType)
				* theCollisionDirectionInGameSpace

			//	Set theCollisionDirection's w coordinate to 0 to project
			//	theCollisionDirection onto the tangent plane at (0,0,0,ρ).
			//	Its y coordinate is already 0 because the table bed
			//	lies in the plane y = 0.
			theCollisionDirection.y = 0.0	//	redundant
			theCollisionDirection.w = 0.0

			//	Normalize theCollisionDirection's length to 1
			//	and let it be the first element of aDynamicalFrame.
			//
			//	Note:
			//		The tangent plane carries a Euclidean metric
			//		regardless of the curvature of the space itself.
			//
			let theLength = sqrt(theCollisionDirection.x * theCollisionDirection.x
							   + theCollisionDirection.z * theCollisionDirection.z)
			if theLength < 1e-8 {
				return nil	//	should never occur
			}
			var theDynamicalFrame = simd_double2x2()
			theDynamicalFrame[0][0] = theCollisionDirection.x / theLength
			theDynamicalFrame[0][1] = theCollisionDirection.z / theLength

			//	Rotate theDynamicalFrame[0] by 90° to obtain
			//	an orthogonal basis vector in the disk's tangent space.
			//	It doesn't matter whether we rotate clockwise or counterclockwise.
			theDynamicalFrame[1][0] = -theDynamicalFrame[0][1]
			theDynamicalFrame[1][1] =  theDynamicalFrame[0][0]
			
			return theDynamicalFrame
		}
	
		mutating func bounceBallsOffCushions() {

			//	The time step Δt in rollAllBalls() is small enough that we'll
			//	detect a collision pretty much as soon as a ball starts to overlap a cushion.
			//	See additional comments at the top of bounceBallsOffEachOther().
			
			//	We'll check whether each ball's "orthogonal shadow"
			//	(see the comment in NEBSpaceGeometry.ballShadowRadius
			//	for the definition) overlaps a table edge.
			
			let theShadowRadius = spaceGeometry.ballShadowRadius

			let theDistanceFunction			= spaceGeometry.cushionToPointTableDistanceFunction
			let theCushionNormalVectorsXZW	= spaceGeometry.geometryType.cushionNormalVectorsXZW

			for i in 0 ..< numBalls {
			
				if balls[i].status != .inPlay
				|| balls[i].speed == 0.0 {
					continue
				}
				
				let theShadowCenter = balls[i].placement	//	right-to-left matrix action
									* spaceGeometry.northPole
				
				//	Work in the plane of the table, with y == 0.
				let theShadowCenterXZW = VectorXZW(
					theShadowCenter.x,
					//	omit y = 0.0
					theShadowCenter.z,
					theShadowCenter.w
				)

				for theCushionNormalXZW in theCushionNormalVectorsXZW {
				
					//	Does theBall overlap the cushion?
					
					let theDistanceCushionToShadowCenter
						= theDistanceFunction(theCushionNormalXZW, theShadowCenterXZW)
					
					if theDistanceCushionToShadowCenter < theShadowRadius {
					
						//	Transfer momentum between ball i and the cushion
						//	iff the ball is still on an "inbound collision",
						//	not an "outbound collision".
						
						transferMomentumBetweenBallAndCushion(
							ballNumber: i,
							cushionNormalXZW: theCushionNormalXZW,
							geometryIsEuclidean: spaceGeometry.geometryType == .euclidean)
					}
				}
			}
		}
		
		mutating func transferMomentumBetweenBallAndCushion(
			ballNumber: Int,
			cushionNormalXZW: VectorXZW,
			geometryIsEuclidean: Bool
		) {

			//	We'll analyze the motion of the ball's 2-dimensional
			//	"orthogonal shadow" (see the comment in
			//	NEBSpaceGeometry.ballShadowRadius for the definition)
			//	as it moves in the plane of the table bed and bounces off the cushion.

			//	Our plan is to split the disk's momentum into
			//	a component in the "impact direction" (perpendicular to the cushion) and
			//	a component in the "tangential direction" (parallel to the cushion).
			//	At the moment of collision the disk flips its impact-direction momentum
			//	while leaving its tangential-direction momentum unchanged.
			//	Of course, we must first verify that the disk is
			//	generally moving towards the cushion, so we process only
			//	"inbound collisions", not "outbound collisions".

			//	Compute an orthonormal frame, not in the tangent space
			//	at the disk's current position on the table, but in the tangent space
			//	in the disk's own local coordinate system, where the disk always
			//	sits centered at (0,0,0,ρ). Let the orthonormal frame consist of
			//
			//		a vector pointing towards the cushion,
			//		a vector orthogonal to the first vector.
			//
			guard let theDynamicalFrame = computeDynamicalFrameDiskToCushion(
											ballPlacement: balls[ballNumber].placement,
											cushionNormalXZW: cushionNormalXZW,
											geometryIsEuclidean: geometryIsEuclidean)
			else {
				assertionFailure("Couldn't compute dynamical frame disk-to-cushion")
				return
			}
			
			//	Compute the components of the disk's momentum
			//	in the "impact direction" and the "tangential direction"
			//	by dotting the velocity vector with the two basis vectors
			//	of theDynamicalFrame. By convention the disk's velocity
			//	is always in the -z direction (for consistency
			//	with ARKit's convention that -z is the "forward" direction)
			//	in the disk's own coordinate system, so the velocity vector
			//	here is (v_x, v_z) = (0, -speed).
			//
			var theMomentumImp = dot(
									theDynamicalFrame[0],
									SIMD2<Double>(0.0, -balls[ballNumber].speed))

			let theMomentumTan = dot(
									theDynamicalFrame[1],
									SIMD2<Double>(0.0, -balls[ballNumber].speed))
			
			//	The ball is moving towards the cushion iff theMomentumImp is positive.
			//	Otherwise it's moving away.
			//
			if (theMomentumImp > 0.0)
			{
				//	Negate the ball's momentum in the "impact direction".
				theMomentumImp = -theMomentumImp

				//	Reconstruct the ball's velocity vector
				//	using the negated impact-direction momentum.
				let theNewVelocityXZ
					= theMomentumImp * theDynamicalFrame[0]
					+ theMomentumTan * theDynamicalFrame[1]

				//	Assign the new velocity to the ball.
				setBallVelocity(
					ballNumber: ballNumber,
					newVelocityXZ: theNewVelocityXZ)

				//	Play a ball-on-cushion sound.
				let theFullImpact = 1.0	//	this value seems to work well in practice
				let theRelativeImpact =	//	∈  0.0 ... 1.0
					min(1.0, theMomentumImp / theFullImpact)
				let theVolume = 0.25 * theRelativeImpact
				Task { await NEBSoundPlayer.shared.play(.ballOnCushion, volume: theVolume) }
			}
		}
		
		func computeDynamicalFrameDiskToCushion(
			ballPlacement: simd_double4x4,
			cushionNormalXZW: VectorXZW,
			geometryIsEuclidean: Bool
		) -> simd_double2x2? {	//	Return dynamical frame in the xz plane
								//	in the ball's own local coordinate system.

			//	The cushion's normal vector tells the direction from the cushion to the disk.
			//	For consistency with the code in computeDynamicalFrameDiskToDisk(),
			//	negate it to get the direction from the disk to the cushion.
			var theCollisionDirectionXZW = -cushionNormalXZW

			//	The Euclidean case is exceptional in that the (2+1)-dimensional
			//	embedding space has no well-defined metric (well, at least not
			//	the same metric for all purposes). For present purposes,
			//	we need to set the w component of theCollisionDirection to zero
			//	to get it to transform correctly.
			//
			//	Spherical and hyperbolic geometry both have well-defined metrics,
			//	and for them no ad hoc correction is needed here.
			//
			if geometryIsEuclidean {
				theCollisionDirectionXZW.w = 0.0
			}
			
			let theCollisionDirection = SIMD4<Double>(
				theCollisionDirectionXZW.x,	//	x
				0.0,						//	y
				theCollisionDirectionXZW.z,	//	z
				theCollisionDirectionXZW.w	//	w
			)

			//	Transfer theCollisionDirection from the disk's current location
			//	on the table to the disk's own local coordinate system
			//	(where it sits at the origin (0,0,0,ρ) ).
			var theLocalDirection	//	right-to-left matrix action
				= geometricInverse(
					of: ballPlacement,
					geometryType: spaceGeometry.geometryType)
				* theCollisionDirection

			//	Set theLocalDirection's w coordinate to 0 to project
			//	theLocalDirection onto the tangent plane at (0,0,0,ρ).
			//	Its y coordinate should already be 0.
			theLocalDirection.y = 0.0	//	redundant
			theLocalDirection.w = 0.0

			//	Normalize theLocalDirection's length to 1
			//	and let it be the first element of aDynamicalFrame.
			//
			//	Note:
			//		The tangent plane carries a Euclidean metric
			//		regardless of the curvature of the space itself.
			//
			let theLength = sqrt(theLocalDirection.x * theLocalDirection.x
							   + theLocalDirection.z * theLocalDirection.z)
			if theLength < 1e-8 {
				return nil	//	should never occur
			}
			var theDynamicalFrame = simd_double2x2()
			theDynamicalFrame[0][0] = theLocalDirection.x / theLength
			theDynamicalFrame[0][1] = theLocalDirection.z / theLength

			//	Rotate theDynamicalFrame[0] through an angle π/2 to obtain
			//	an orthogonal basis vector in the disk's tangent space.
			theDynamicalFrame[1][0] = -theDynamicalFrame[0][1]
			theDynamicalFrame[1][1] =  theDynamicalFrame[0][0]

			return theDynamicalFrame
		}
	
		mutating func pullBallsIntoPockets(Δt: Double) {

			//	When a ball rolls over the pocket,
			//	bend its trajectory towards the pocket's center.

			//	A ball whose center sits in the pocket's interior
			//	accelerates towards the pocket's center
			//	with acceleration = thePocketAcceleration.
			let thePocketAcceleration = (
			
				makeScreenshots ?
				
					//	When making screenshots, we want
					//	push balls *out* of the pockets.
					-1.0
				:
					//	During normal play, we gently
					//	pull balls into the pockets.
					+1.0	//	in m/sec²
				)

			for i in 0 ..< numBalls {

				if balls[i].status != .inPlay {
					continue
				}

				let theBallShadowCenter4D = balls[i].placement * spaceGeometry.northPole
				let theBallShadowCenter = VectorXZW(
										theBallShadowCenter4D.x,
										//	omit the y component, which should be zero
										theBallShadowCenter4D.z,
										theBallShadowCenter4D.w)

				for thePocketCenter in spaceGeometry.pocketCentersXZW {
		
					let theDistance	//	in meters
						= spaceGeometry.tableDistanceFunction(
											theBallShadowCenter,
											thePocketCenter)

					//	If the ball doesn't sit over this pocket,
					//	continue on to the next one.
					if theDistance >= pocketRadius {
						continue
					}
				
					//	Accelerate the ball towards the pocket's center.
					
					let theOffsetInTableCoordinates = theBallShadowCenter - thePocketCenter
					let theOffsetInTableCoordinates4D
						= SIMD4<Double>(
							theOffsetInTableCoordinates.x,
							0.0,	//	y = 0.0
							theOffsetInTableCoordinates.z,
							theOffsetInTableCoordinates.w)
						
					let theOffsetInBallPostRotationCoordinates	//	right-to-left action
						= geometricInverse(
							of: balls[i].placement,
							geometryType: spaceGeometry.geometryType)
						* theOffsetInTableCoordinates4D
						
					//	We may conceptually set the w coordinate
					//	of theOffsetInBallPostRotationCoordinates to 0
					//	to make the offset parallel the tangent plane at (0,0,0,ρ)
					//	(the w coordinate should be close to 0 already,
					//	because the distance from theBallShadowCenter
					//	to thePocketCenter is reasonably small).
					//	The y coordinate of theOffsetInBallPostRotationCoordinates
					//	should already be 0 because the table bed lies in the plane y = 0.
					//	Thus only the x and z coordinates remain non-zero.

					let theAcceleration = SIMD2<Double> (	//	in m/sec²
						-thePocketAcceleration
							* (theOffsetInBallPostRotationCoordinates.x / pocketRadius),
						-thePocketAcceleration
							* (theOffsetInBallPostRotationCoordinates.z / pocketRadius))
					
					let Δv = theAcceleration * Δt	//	in m/sec
					
					//	In the ball's post-rotation coordinate system,
					//	it always rolls in the negative z direction with the given speed.
					let theOldVelocityXZ = SIMD2<Double>(0.0, -balls[i].speed)
					let theNewVelocityXZ = theOldVelocityXZ + Δv
					
					setBallVelocity(
						ballNumber: i,
						newVelocityXZ: theNewVelocityXZ)
					
					//	There's no need to examine the remaining pockets.
					break
				}
			}
		}

		mutating func slowTheBalls(Δt: Double) {

			let Δv = ballDeceleration * Δt

			for i in 0 ..< numBalls {

				if balls[i].status == .inPlay {
				
					balls[i].speed -= Δv
					
					if  balls[i].speed < 0.0 {
						balls[i].speed = 0.0
					}

					//	Note: In principle a ball could come to rest
					//	while slightly overlapping a cusion,
					//	as a result of a collision that took place
					//	(at very slow speed!) as part of the current
					//	update() call. But this effect should be very rare,
					//	and almost unnoticeable, so I won't worry about it.
				}
			}
		}

		mutating func gramSchmidt() {

			for i in 0 ..< numBalls {
			
				if balls[i].status == .inPlay {
					
					balls[i].rotation  = applyGramSchmidt(
											to: balls[i].rotation,
											geometryType: spaceGeometry.geometryType)
					balls[i].placement = applyGramSchmidt(
											to: balls[i].placement,
											geometryType: spaceGeometry.geometryType)
				}
			}
		}

// MARK: -
// MARK: Place cue ball
	
		mutating func maybePlaceCueBall() {
		
			if balls[0].status != .inHand {
				assertionFailure("maybePlaceCueBall() called when cue ball is not .inHand")
				return
			}
		
			if whoseTurn == .computer {
				assertionFailure("maybePlaceCueBall() called during the computer's turn")
				return
			}
			
			if gameIsOver
			|| ballsAreRolling() {
			
				return
			}
			
			guard let theInHandCueBallPose = inHandCueBallPose else {
				return
			}

			let theCueBallCenter = theInHandCueBallPose * spaceGeometry.northPole

			//	Does the cue ball's center lie between 0 and 2 ball radii above the table?
			let y = theCueBallCenter.y
			let ρ = spaceGeometry.spaceRadius
			let theBallHeight: Double	//	height of in-hand cue ball's center above the table
			switch spaceGeometry.geometryType {
			case .spherical:  theBallHeight = ρ * asin(y/ρ)
			case .euclidean:  theBallHeight = y				// = ρ * id(y/ρ)
			case .hyperbolic: theBallHeight = ρ * asinh(y/ρ)
			}
			if theBallHeight > 2.0 * ballRadius || theBallHeight < 0.0 {
				return	//	ball is too high or too low
			}
			
			//	Project the cue ball's center orthogonally onto the table.
			let theRawProjection = VectorXZW(
									theCueBallCenter.x,
									//	omit y
									theCueBallCenter.z,
									theCueBallCenter.w)
			let theRawLength
				= spaceGeometry.geometryType.xzwMetricForVertical.length(theRawProjection)
			if theRawLength < 0.01 {
				//	theRawLength would be 0.0 if the cue ball were 90° away
				//	from the table in spherical geometry, but in practice
				//	this should never happen because we've already checked
				//	that the cue ball is near the table.
				assertionFailure("theRawLength is unexpectedly small in maybePlaceCueBall()")
				return
			}
			let theShadowCenterXZW = (ρ / theRawLength) * theRawProjection

			let theShadowRadius = spaceGeometry.ballShadowRadius
			
			//	Does the cue ball lie within the cushions?
			let theCushionDistanceFunction	= spaceGeometry.cushionToPointTableDistanceFunction
			let theCushionNormalVectorsXZW	= spaceGeometry.geometryType.cushionNormalVectorsXZW
			for theCushionNormalXZW in theCushionNormalVectorsXZW {
			
				let theDistanceCushionToShadowCenter
					= theCushionDistanceFunction(theCushionNormalXZW, theShadowCenterXZW)
				
				if theDistanceCushionToShadowCenter < theShadowRadius {
					return
				}
			}
			
			//	Does the cue ball avoid the other balls?
			let theTableDistanceFunction = spaceGeometry.tableDistanceFunction
			for theOtherBall in balls {
			
				if theOtherBall.status != .inPlay {	//	excludes the cue ball (which is .inHand)
					continue						//		along with all .sunk balls
				}
				
				let theOtherShadowCenter = theOtherBall.placement * spaceGeometry.northPole
				let theOtherShadowCenterXZW = VectorXZW(
					theOtherShadowCenter.x,
					//	omit y = 0.0
					theOtherShadowCenter.z,
					theOtherShadowCenter.w )
				
				let theCenterToCenterDistance = theTableDistanceFunction(
													theShadowCenterXZW,
													theOtherShadowCenterXZW)
				
				if theCenterToCenterDistance < 2.0 * theShadowRadius {
					return
				}
			}
			
			//	Does the cue ball avoid the pockets?
			for thePocketCenter in spaceGeometry.pocketCentersXZW {

				let theDistance = theTableDistanceFunction(	//	in meters
									theShadowCenterXZW,
									thePocketCenter)
				
				if theDistance < pocketRadius {
					return
				}
			}

			//	The inHand cue ball has passed all the tests,
			//	so let's place it on the table and put it into play!
			let r = theTableDistanceFunction(	//	in meters
						spaceGeometry.northPoleXZW,
						theShadowCenterXZW)
			let φ = r > 0.0001 ?
					atan2(theShadowCenterXZW.z, theShadowCenterXZW.x ) :
					0.0
			let theRadialPart = axisAlignedTranslation(
									axis: 0,
									distanceInRadians: r / ρ,
									geometryType: spaceGeometry.geometryType)
			let theRotationalPart = axisAlignedRotation(
										from: 0,	//	x
										towards: 2,	//	z
										angle: φ)
			balls[0].placement = theRotationalPart	//	right-to-left matrix action
							   * theRadialPart
			balls[0].status = .inPlay
			
			inHandCueBallPose = nil
			
			Task { await NEBSoundPlayer.shared.play(.ballOnTable, volume: 1.0) }
		}
	
// MARK: -
// MARK: Shoot

		mutating func maybeTakeShot(
			cueStick: CueStick,
			handTrackingInterval: Double?
		) -> Bool {	//	Did the player take a shot?
			
			//	When the player's hands aren't visible, there are
			//	no cue stick poses and we can't even consider taking a shot.
			guard let thePreviousCueStickPose = cueStick.previous,
				  let theCurrentCueStickPose = cueStick.current
			else { return false }
			
			//	If we don't know the cue stick's speed,
			//	we can't take a shot.
			guard let theHandTrackingInterval = handTrackingInterval
			else { return false }
			
			//	Is it an appropriate time to shoot?
			if ballsAreRolling()
			|| gameIsOver
			|| balls[0].status != .inPlay {
				return false
			}

			//	Where is (and was) the cue stick's tip?
			let thePreviousCueTip = thePreviousCueStickPose * spaceGeometry.northPole
			let theCurrentCueTip  = theCurrentCueStickPose  * spaceGeometry.northPole

			//	Where's the cue ball?
			//
			//	As explained in the extended comment in the BilliardBall struct,
			//	a ball's pose is the composition of three factors: the rotation,
			//	the ball lift, and the placement. But here we want to ignore
			//	the rotation, and compose only the two remaining factors.
			//	In effect we want to map from the cue ball's "post-rotation space"
			//	into table space. This will make it easier to set the cue ball's
			//	new velocity, because a rolling ball's velocity is constant
			//	in "post-rotation space" (where it always points in the negative
			//	z direction), in contrast to the ball's raw pre-rotation space
			//	where the velocity vector spins around wildly.
			//
			let thePostRotationCueBallPlacement = balls[0].placement // right-to-left matrix action
												* spaceGeometry.ballLift
												
					
			let theCueBallPosition = thePostRotationCueBallPlacement * spaceGeometry.northPole

			//	If the cue stick's tip doesn't sit within the cue ball,
			//	don't take a shot.
			let theCueTipToCueBallCenterDistance
				= spaceGeometry.spaceDistanceFunction(
					theCurrentCueTip,
					theCueBallPosition)
			if theCueTipToCueBallCenterDistance > ballRadius {
				return false
			}
			
			//	If the cue stick's tip was already within the cue ball,
			//	as the result of a recent miscue, we want to keep
			//	ignoring the cue tip until it leaves the cue ball.
			//	(Otherwise we'd either keep triggering the miscue
			//	sound over and over, or we'd let the player initiate
			//	a valid shot from within the cue ball, which would
			//	have the effect of the miscue sound playing and
			//	the ball rolling forward in spite of it.)
			let thePreviousCueTipToCueBallCenterDistance
				= spaceGeometry.spaceDistanceFunction(
					thePreviousCueTip,
					theCueBallPosition)
			if thePreviousCueTipToCueBallCenterDistance <= ballRadius {
				return false
			}

			//	Compute the cue tip's velocity.
			
			let theCueTipDisplacementInTableCoordinates
				= theCurrentCueTip - thePreviousCueTip
				
			let theCueTipVelocityInTableCoordinates
				= theCueTipDisplacementInTableCoordinates / theHandTrackingInterval

			let theCueTipVelocityInCueTipCoordinates
				= geometricInverse(
					of: theCurrentCueStickPose,
					geometryType: spaceGeometry.geometryType)
				* theCueTipVelocityInTableCoordinates
			
			let theCueTipVelocityInBallCoordinates
				= geometricInverse(
					of: thePostRotationCueBallPlacement,
					geometryType: spaceGeometry.geometryType)
				* theCueTipVelocityInTableCoordinates

			//	The cue stick tip must be moving forwards
			//	(negative z direction in cue stick coordinates)
			//	in order to take a shot.
			if theCueTipVelocityInCueTipCoordinates.z >= 0.0 {
			
				lastAttemptedShotWasMiscue = true	//	triggers a message in the headset

				Task { await NEBSoundPlayer.shared.play(.miscue, volume: 1.0) }

				return false
			}

			//	The cue tip should be moving roughly parallel
			//	to the cue stick itself to trigger a shot. It it's not,
			//	then either
			//		the player isn't trying to take a shot and
			//		just accidentally let the cue tip touch
			//		the side of the cue ball (as sometimes happens
			//		when the player places the cue ball on the table
			//		and suddently has a cue stick in his/her hands)
			//	or
			//		the player is trying to take a shot but miscued.
			//	Either way, let's request a hopefully helpful message,
			//	play an error sound and return.
			let theParallelComponent = theCueTipVelocityInCueTipCoordinates.z
			let thePerpendicularComponent = SIMD2<Double>(
					theCueTipVelocityInCueTipCoordinates.x,
					theCueTipVelocityInCueTipCoordinates.y)
			if length(thePerpendicularComponent) > 0.25 * abs(theParallelComponent) {
			
				lastAttemptedShotWasMiscue = true	//	triggers a message in the headset
				
				Task { await NEBSoundPlayer.shared.play(.miscue, volume: 1.0) }

				return false
			}
				
			//	The cue tip sits within the cue ball and is moving forwards
			//	more-or-less parallel to the cue stick itself, so take a shot.

			//	Because the cue tip's previous and current positions are both
			//	close to the cue ball, the vector connecting them should be
			//	almost "horizontal" in the cue ball's local coordinate system.
			//	In other words, the w component of theCueTipVelocityInBallCoordinates
			//	should be small.
			//
			//	In addition, if the player is making a normal shot
			//	parallel to the billiards table, then the y component
			//	of theCueTipVelocityInBallCoordinates will also be small.
			//
			//	If we project theCueTipVelocityInBallCoordinates orthogonally
			//	onto the xz plane (that is, we set its y and w coordinates to zero)
			//	the resulting vector will give the cue stick's velocity
			//	parallel to the plane of the table.
			
			let theCueTipVelocityXZ = SIMD2<Double>(
										theCueTipVelocityInBallCoordinates.x,
										theCueTipVelocityInBallCoordinates.z)

			let theUntweakedCueBallVelocityXZ = cueStickBounceFactor * theCueTipVelocityXZ
			
			let theCueBallVelocityXZ: SIMD2<Double>
			if whoseTurn == .human
			&& guardianAngelFactor > 0.0
			&& length(theUntweakedCueBallVelocityXZ) > 0.0 {

				//	Give the player some help.

				let theFeasibleShots = feasibleShots(
					for: .human,
					guaranteeNonemptyResult: false)
				
				//	Do theFeasibleShots include one that's within
				//	theAngleTolerance of theUntweakedAngle?
				//
				//		Note: We want the untweaked shot's angle relative
				//		to the negative Z axis, as explained in the comment
				//		accompanying theIntendedCueBallVelocityXZ below.
				//
				let theUntweakedAngle = atan2(	theUntweakedCueBallVelocityXZ[0],
											   -theUntweakedCueBallVelocityXZ[1])
				let theAngleTolerance = 0.1	//	radians  (0.1 radians ≈ 6°)
				
				//	It should rarely happen that two different feasible shots both lie
				//	within theAngleTolerance, so we'll just take the first one we find,
				//	without bothering to test whether some other shot might be even closer.
				//
				if let theIntendedShot = theFeasibleShots.first(where: { shot in
					angleDifference(shot.angle, theUntweakedAngle) <= theAngleTolerance
				}) {
				
					//	Pool balls roll in their own forward direction, which is
					//	the negative z direction in their own post-rotation coordinate systems.
					//	So we must interpret θ = theIntendedShot.angle as a rotation
					//	of that default velocity vector (0, -s).
					//
					//		        ( cos(θ) +sin(θ) )
					//		(0, -s) (                ) = ( s·sin(θ), -s·cos(θ) )
					//		        (-sin(θ)  cos(θ) )
					//
					//
					//	When θ = 0.0, the cue ball's forward motion
					//	is in the negative z direction.
					
					let θ = theIntendedShot.angle
					let s = theIntendedShot.startSpeed
					
					let theIntendedCueBallVelocityXZ = SIMD2<Double>( s * sin(θ), -s * cos(θ) )
				
					theCueBallVelocityXZ
						= (1.0 - guardianAngelFactor) * theUntweakedCueBallVelocityXZ
						+     guardianAngelFactor     * theIntendedCueBallVelocityXZ

				} else {
				
					//	theUntweakedAngle didn't match any of theFeasibleShots,
					//	so let's execute the player's shot without tweaking it.
					
					theCueBallVelocityXZ = theUntweakedCueBallVelocityXZ
				}
													
			} else {
				//	Execute the untweaked shot.
				theCueBallVelocityXZ = theUntweakedCueBallVelocityXZ
			}

			setBallVelocity(ballNumber: 0, newVelocityXZ: theCueBallVelocityXZ)

			//	Clear any previous miscue message.
			lastAttemptedShotWasMiscue = false

			//	Start recording this shot's effects.
			shotStatus = ShotStatus()

			//	Play a cue-on-ball sound.
			let theCueBallSpeed = length(theCueBallVelocityXZ)
			let theFullSpeed = 2.0
			let theRelativeSpeed = min(1.0, theCueBallSpeed / theFullSpeed)	//	∈  0.0 ... 1.0
			let theVolume = 1.0 * theRelativeSpeed
			Task { await NEBSoundPlayer.shared.play(.cueOnBall, volume: theVolume) }

			return true
		}
		
		func angleDifference(
			_ α: Double,	//	radians
			_ β: Double	//	radians
		) -> Double {	//	0 ≤ result ≤ π
		
			var δ = fabs(α - β)
			
			//	Allow for possible "wraparound".
			if δ > .pi {
				δ = 2.0 * .pi - δ
			}
			
			return δ
		}
		
		mutating func evaluateShot() {
		
			guard let theShotStatus = shotStatus else {
				assertionFailure("shotStatus == nil in evaluateShot()")
				return
			}
			shotStatus = nil	//	balls have stopped rolling, shot is over
			
			let someSolidBallsRemain = (1...7).reduce(false, { result, i in
				result || balls[i].status == .inPlay
			})
			let someStripedBallsRemain = (9...15).reduce(false, { result, i in
				result || balls[i].status == .inPlay
			})

			var theShooterStatus = playerStatus[whoseTurn]!
			var theOtherStatus   = playerStatus[whoseTurn.opponent()]!
			
			//	Before possibly changing theShooterStatus,
			//	check whether the shooter sunk any appropriate balls.
			let theShooterSunkSomeTargetedBalls: Bool
			switch theShooterStatus {
				
			case .shootingForEither:
				theShooterSunkSomeTargetedBalls = theShotStatus.solidsSunk
											   || theShotStatus.stripesSunk
				
			case .shootingForSolids:
				theShooterSunkSomeTargetedBalls = theShotStatus.solidsSunk
				
			case .shootingForStripes:
				theShooterSunkSomeTargetedBalls = theShotStatus.stripesSunk
				
			case .shootingForEightBall:
				theShooterSunkSomeTargetedBalls = theShotStatus.eightBallSunk
					
			case .won, .lost:
				assertionFailure("Shot was taken in a game that was already over")
				theShooterSunkSomeTargetedBalls = false
			}
			
			//	If the table was "open", can we assign solids or stripes?
			if theShooterStatus == .shootingForEither
			&& !theShotStatus.cueBallSunk {	//	If the cue ball was sunk, the table remains "open".
			
				if theShotStatus.solidsSunk && theShotStatus.stripesSunk {
					
					//	Because the player sunk at least one solid ball
					//	and at least one striped ball, the table typically
					//	remains "open" here.
					
					//	Possible exception:  In the unlikely case that
					//	the player has sunk all the solids or all the stripes
					//	while the table is still open, let's go ahead and
					//	assign the player that ball group. The code that
					//	follows below will then advance the player to shooting
					//	for the 8-ball.
					if !someSolidBallsRemain {
					
						theShooterStatus = .shootingForSolids
						theOtherStatus = .shootingForStripes
					} else
					if !someStripedBallsRemain {
					
						theShooterStatus = .shootingForStripes
						theOtherStatus = .shootingForSolids
					}
					
				} else
				if theShotStatus.solidsSunk {
				
					theShooterStatus = .shootingForSolids
					theOtherStatus = .shootingForStripes
					
				} else
				if theShotStatus.stripesSunk {
				
					theShooterStatus = .shootingForStripes
					theOtherStatus = .shootingForSolids
				}
			}
			
			//	Once all balls of the assigned color have been sunk,
			//	the player moves on to shoot for the 8-ball.
			if !someSolidBallsRemain && !someStripedBallsRemain {
			
				if theShooterStatus == .shootingForEither {	//	other cases handled below
				
					//	It seems unlikley that all the stripes and all the solids
					//	will get sunk while still shootingForEither, but if they do,
					//	then both players shoot for the 8-ball.
					theShooterStatus = .shootingForEightBall
					theOtherStatus = .shootingForEightBall
				}
			}
			if !someSolidBallsRemain {
			
				if theShooterStatus == .shootingForSolids {
					theShooterStatus = .shootingForEightBall
				}
				if theOtherStatus == .shootingForSolids {	//	Did we do our opponent a favor?
					theOtherStatus = .shootingForEightBall
				}
			}
			if !someStripedBallsRemain {
			
				if theShooterStatus == .shootingForStripes {
					theShooterStatus = .shootingForEightBall
				}
				if theOtherStatus == .shootingForStripes {	//	Did we do our opponent a favor?
					theOtherStatus = .shootingForEightBall
				}
			}
			
			//	Was the 8-ball sunk?
			if theShotStatus.eightBallSunk {
			
				//	Was the shooter supposed to be shooting for the 8-ball?
				if theShooterStatus == .shootingForEightBall {
				
					//	Sinking the cue ball along with the 8-ball loses the game.
					if theShotStatus.cueBallSunk{
					
						howGameEnded = whoseTurn == .human ?
										.humanSunkCueBallWith8Ball :
										.computerSunkCueBallWith8Ball
					
						theShooterStatus = .lost
						theOtherStatus = .won
					}
					else {
					
						howGameEnded = whoseTurn == .human ?
										.humanSunk8BallAndWon :
										.computerSunk8BallAndWon
					
						theShooterStatus = .won
						theOtherStatus = .lost
					}
					
				} else {
				
					howGameEnded = whoseTurn == .human ?
									.humanSunk8BallTooSoon :
									.computerSunk8BallTooSoon
									
					theShooterStatus = .lost
					theOtherStatus = .won
				}
					
				Task { await NEBSoundPlayer.shared.play(
								theShooterStatus == .won ? .gameWon : .gameLost,
								volume: 1.0) }
			}

			//	Record the updated status.
			playerStatus[whoseTurn] = theShooterStatus
			playerStatus[whoseTurn.opponent()] = theOtherStatus
			
			//	Pass the turn to the other player, if appropriate.
			if !gameIsOver
			&& ( theShotStatus.cueBallSunk
			  || !theShooterSunkSomeTargetedBalls )
			&& !makeScreenshots {
			  
				whoseTurn = whoseTurn.opponent()
			}

			//	Color the balls as seen from the (possibly new)
			//	active player's point of view.
			//
			//		Design decision
			//
			//		On the one hand, it might make sense to "saturate"
			//		the 8-ball's color only when it's time to sink it.
			//
			//		On the other hand, it might make sense to keep
			//		the 8-ball's color "saturated" at all times, so
			//		the player can see where it is and avoid sinking it
			//		prematurely. When the player is shooting for stripes,
			//		this would also avoid the risk of the player seeing
			//		a seemingly generic solid grey ball drop into a pocket
			//		and then wondering why s/he suddenly lost the game.
			//
			//		Let's keep it saturated at all times.
			//
			let theNewShooterStatus = playerStatus[whoseTurn]!
			balls[0].wantsSaturatedColor = true
			for i in 1...7 {
				balls[i].wantsSaturatedColor = theNewShooterStatus == .shootingForEither
											|| theNewShooterStatus == .shootingForSolids
											|| gameIsOver
			}
			balls[8].wantsSaturatedColor     = true
	//	Alternative approach:				   theNewShooterStatus == .shootingForEightBall
	//										|| gameIsOver
			for i in 9...15 {
				balls[i].wantsSaturatedColor = theNewShooterStatus == .shootingForEither
											|| theNewShooterStatus == .shootingForStripes
											|| gameIsOver
			}
		}

// MARK: -
// MARK: Feasible shots

		func feasibleShots(
			for playerType: PlayerType,
			guaranteeNonemptyResult: Bool
		) -> [ShotCandidate] {
	
			guard let theCueBallPose = getCueBallPoseXZW() else {
				preconditionFailure("feasibleShots() called when cue ball is not in play")
			}
			let theShooterStatus = playerStatus[playerType]!
			let theTargetBallCenters = getTargetBallCentersXZW(shooterStatus: theShooterStatus)
			let theAllBallCenters = getAllBallCentersXZW()	//	excludes cue ball
	
			//	"Direct shots" are those in which the cue ball hits
			//	an object ball which then rolls directly into a pocket.
			//
			//	Curved-Space Pool Hall doesn't consider "indirect shots"
			//	in which the cue ball and/or the object ball bounces
			//	off of a cushion en route, because such shots would require
			//	more work to code up and would be much slower at run time.
			//	The reason they're more difficult is that the "orthogonal shadow"
			//	of a ball's center reflects not off of the pool table's
			//	geodesic edge (which would be easy to handle!), but rather
			//	off of a non-geodesic curve that runs "parallel" to the table edge
			//	but is displaced one ball radius inward toward the center
			//	of the table. Reflecting off of that non-geodesic curve
			//	would (I think) require some messy numerical method
			//	to find the required point of impact. I don't see how
			//	to compute the impact point in close form.
			//
			//	In any case, the computer player plays plenty well enough
			//	using only direct shots.
			//
			let theDirectShots = directShots(
									cueBallPose: theCueBallPose,
									targetBallCenters: theTargetBallCenters,
									allBallCenters: theAllBallCenters)
	
			if guaranteeNonemptyResult
			&& theDirectShots.isEmpty {
			
				let theHailMaryShot = chooseHailMaryShot(
										cueBallPose: theCueBallPose,
										targetBallCenters: theTargetBallCenters)
			
				return [theHailMaryShot]
				
			} else {
			
				return theDirectShots
			}
		}
	
		private func getCueBallPoseXZW() -> matrix_double3x3? {	//	nil if cue ball is not inPlay
	
			let theCueBall = balls[0]
			
			if theCueBall.status == .inPlay {
	
				let p = theCueBall.placement
				
				let theCueBallPoseXZW = matrix_double3x3(
					SIMD3<Double>(p[0][0], p[0][2], p[0][3]),	//	omits XY entry
					//	omits (YX, YY, YZ, YW) row)
					SIMD3<Double>(p[2][0], p[2][2], p[2][3]),	//	omits ZY entry
					SIMD3<Double>(p[3][0], p[3][2], p[3][3])	//	omits WY entry
				)
				
				return theCueBallPoseXZW
	
			} else {
				return nil
			}
		}
	
		private func getTargetBallCentersXZW(
			shooterStatus: PlayerStatus
		) -> [VectorXZW] {
	
			let theTargetRange: Array<Int>
			switch shooterStatus {
			
			case .shootingForEither:
				theTargetRange = Array(1...7) + Array(9...15)
			case .shootingForSolids:
				theTargetRange = Array(1...7)
			case .shootingForStripes:
				theTargetRange = Array(9...15)
			case .shootingForEightBall:
				theTargetRange = [8]
			case .won, .lost:
				assertionFailure("getTargetBallCentersXZW() called when game is already over")
				theTargetRange = []
			}
			
			let theTargetBallCentersXZW = theTargetRange.compactMap() { i in
				getBallCenterXZW(ball: balls[i])
			}
			
			return theTargetBallCentersXZW
		}
	
		func getAllBallCentersXZW() -> [VectorXZW] {
	
			let theBallCentersXZW = Array(1...15).compactMap() { i in
				getBallCenterXZW(ball: balls[i])
			}
			
			return theBallCentersXZW
		}
	
		private func getBallCenterXZW(
			ball: BilliardBall
		) -> VectorXZW? {
				
			if ball.status == .inPlay {
			
				let theTargetBallCenter = ball.placement * spaceGeometry.northPole
	
				let theTargetBallCenterXZW
					= VectorXZW(
						theTargetBallCenter.x,
						//	omit the y component, which should be zero
						theTargetBallCenter.z,
						theTargetBallCenter.w)
				
				return theTargetBallCenterXZW
	
			} else {
			
				return nil
			}
		}
	
		private func directShots(
			cueBallPose cueBallPoseAtStart: matrix_double3x3,
			targetBallCenters: [VectorXZW],
			allBallCenters: [VectorXZW]	//	all balls in play except the cue ball
		) -> [ShotCandidate] {
	
			let theCueBallCenterAtStart = cueBallPoseAtStart * spaceGeometry.northPoleXZW
	
			//	theBallOffset geodesically translates the north pole
			//	to a point one ball shadow diameter away in the Z direction in XZW space.
			let theBallOffset = axisAlignedTranslation3D(
									axis: 1,	//	the Z axis in XZW space
									distanceInRadians: 2.0 * spaceGeometry.ballShadowRadius
														/ spaceGeometry.spaceRadius,
									geometryType: spaceGeometry.geometryType)
			
			//	Multiplication by theHalfTurn converts a backward looking frame
			//	at the cue ball into a forward looking frame.
			let theHalfTurn = simd_double3x3(diagonal: SIMD3<Double>(-1.0, -1.0, 1.0))
			
			var theDirectShots: [ShotCandidate] = []
			
			for theTargetBallCenter in targetBallCenters {
			
				for thePocketCenter in spaceGeometry.pocketCentersXZW {
	
					//	theFrameAtTargetBall is an orthonormal frame with
					//		axis 2 (the W axis) = theTargetBallCenter / |theTargetBallCenter|
					//		axis 1 (the Z axis) pointing away from the pocket center
					//					=> the negative Z axis points towards the pocket center
					//		axis 0 (the X axis) chosen to form a right-handed basis
					let theFrameAtTargetBall = orthonormalFrame(
												at: theTargetBallCenter,
												lookingToward: thePocketCenter,
												geometryType: spaceGeometry.geometryType)
	
					//	If anything obstructs the second leg, give up
					//	on this candidate and move on to the next one.
					if !ballPathIsClear(
							from: theFrameAtTargetBall,
							to: thePocketCenter,
							avoiding: allBallCenters) {
							
						continue
					}
					
					//	The end of the first leg should be colinear with the second leg,
					//	but one ball diameter before the second leg's beginning.
					//	(Draw a picture and all will be clear!)
					let theCueBallOutboundFrameAtImpact = theFrameAtTargetBall * theBallOffset
															//	right-to-left matrix action
					
					let theCueBallCenterAtImpact
						= theCueBallOutboundFrameAtImpact * spaceGeometry.northPoleXZW
					
					//	At the end of the first leg the cue ball will be
					//	at theCueBallCenterAtImpact, but its orthonormal frame
					//	relative to the first leg will be different from
					//	its orthonormal frame relative to the second leg.
					let theBackwardLookingFrame = orthonormalFrame(
												at: theCueBallCenterAtImpact,
												lookingToward: theCueBallCenterAtStart,
												geometryType: spaceGeometry.geometryType)
					let theCueBallInboundFrameAtImpact = theBackwardLookingFrame * theHalfTurn
															//	right-to-left matrix action
					
					//	The cue ball's momentum consists of a component parallel
					//	to the direction of impact and a component perpendicular to it.
					//	Only the parallel component gets transferred from the cue ball
					//	to the target ball. The cosine of the incidence angle
					//	tells us what fraction of the momentum gets transferred.
					let theInboundDirection  = VectorXZW(theCueBallInboundFrameAtImpact[1])		//	-Z direction
					let theOutboundDirection = VectorXZW(theCueBallOutboundFrameAtImpact[1])	//	-Z direction
					let theMomentumTransferFactor
						= theInboundDirection.x * theOutboundDirection.x
						+ theInboundDirection.z * theOutboundDirection.z
						+ spaceGeometry.geometryType.geometrySign
						  * theInboundDirection.w * theOutboundDirection.w
					
					//	The dot product must be positive for the collision to be valid.
					//	If the dot product is negative, it means the cue ball would need
					//	to pass through the target ball to get to the desired point of impact
					//	and would be moving away from the target ball when it got there.
					if theMomentumTransferFactor < 0.00001 {	//	avoid nearly tangential impact
						continue
					}
					
					//	theMomentumTransferFactor may never exceed 1.0 .
					if theMomentumTransferFactor > 1.00001 {	//	allow for numerical error
						assertionFailure("Internal error: theMomentumTransferFactor > 1.0")
						continue
					}
	
					//	At the moment the cue stick strikes the cue ball,
					//	which way should the cue ball go?
					let theCueBallOutboundFrameAtStart = orthonormalFrame(
												at: theCueBallCenterAtStart,
												lookingToward: theCueBallCenterAtImpact,
												geometryType: spaceGeometry.geometryType)
	
					//	If anything obstructs the first leg, give up
					//	on this candidate and move on to the next one.
					if !ballPathIsClear(
							from: theCueBallOutboundFrameAtStart,
							to: theCueBallCenterAtImpact,
							avoiding: allBallCenters) {
							
						continue
					}
					
					//	theCueBallOutboundFrameAtStart and cueBallPoseAtStart
					//	share the same W axis, but differ by a rotation in the XZ plane.
					let theRotation = cueBallPoseAtStart.inverse	//	right-to-left matrix action
									* theCueBallOutboundFrameAtStart
					let theStartAngle = atan2(theRotation[0][1], theRotation[0][0])
	
					//	How fast would we like the target ball
					//	to be rolling when it reaches the pocket?
					let theFinalTargetBallSpeed = 0.4	//	meters/second
	
					//	Given theFinalTargetBallSpeed, deduce theInitialTargetBallSpeed,
					//	that is, the target ball's speed just after the cue ball hits it.
					//
					//	The function slowTheBalls() slows the target ball
					//	with a constant ballDeceleration, so the target ball's
					//	velocity function is
					//
					//		v(t) =  v(0)  -  a * t
					//
					//	where a = ballDeceleration (which is a positive number).
					//
					//	If we take t = 0 to be the time the ball reaches the pocket
					//	(which admittedly is an odd definition of t = 0, but makes sense
					//	in the present case because that's the time at which
					//	theFinalTargetBallSpeed is known), then the velocity function becomes
					//
					//		v(t) = fs - a * t
					//
					//	where fs = theFinalTargetBallSpeed.
					//
					//	Integrating the velocity function gives the position function
					//
					//		x(t) = x(0) + fs*t - (a/2)*t²
					//
					//	If we measure distances relative to the end of the ball's path,
					//	the position function simplifies to
					//
					//		x(t) = fs*t - (a/2)*t²
					//
					//	We can now deduce the (negative) time t at which the ball
					//	began its trip, and compute its velocity at that point.
					//	Let d be the length of the ball's path, we get
					//
					//		-d = fs*t - (a/2)*t²
					//
					//		(a/2)*t² - fs*t - d = 0
					//
					//		t = ( fs ± √( fs² + 2*a*d ) ) / a
					//
					//	We take the negative part of the '±' option;
					//	the positive part corresponds to the solution we'd get
					//	if the ball kept "accelerating backwards" and retraced its path.
					
					let theSecondLegLength = spaceGeometry.tableDistanceFunction(
																theTargetBallCenter,
																thePocketCenter)
											
					//	Use the quadratic formula to solve for t.
					let α₁ = 0.5 * ballDeceleration
					let β₁ = -theFinalTargetBallSpeed
					let γ₁ = -theSecondLegLength
					let t₁ = ( -β₁ - sqrt(β₁*β₁ - 4.0*α₁*γ₁) ) / (2.0 * α₁)
					
					//	What is the target ball's speed at time t + ε,
					//	immediately after the cue ball hit it?
					let theInitialTargetBallSpeed = theFinalTargetBallSpeed - ballDeceleration * t₁
					
					//	What is the cue ball's speed at time t - ε,
					//	immediately before it hit the target ball?
					let theFinalCueBallSpeed	//	meters/second
						= theInitialTargetBallSpeed / theMomentumTransferFactor
	
					//	The same method we used to compute theInitialTargetBallSpeed
					//	works equally well to compute theInitialCueBallSpeed
					
					let theFirstLegLength = spaceGeometry.tableDistanceFunction(
												theCueBallCenterAtStart,
												theCueBallCenterAtImpact)
	
					let α₀ = 0.5 * ballDeceleration
					let β₀ = -theFinalCueBallSpeed
					let γ₀ = -theFirstLegLength
					let t₀ = ( -β₀ - sqrt(β₀*β₀ - 4.0*α₀*γ₀) ) / (2.0 * α₀)
	
					let theInitialCueBallSpeed	//	meters/second
							= theFinalCueBallSpeed - ballDeceleration * t₀
					
					//	Reject a super-fast shot that would just barely graze
					//	an object ball to send it towards a pocket.
					if theInitialCueBallSpeed > 3.0 {
						continue
					}
	
					//	We've found a feasible shot.
					let theShotCandidate = ShotCandidate(
											angle: theStartAngle,
											startSpeed: theInitialCueBallSpeed)
					theDirectShots.append(theShotCandidate)
				}
			}
	
			return theDirectShots
		}
	
		private func axisAlignedTranslation3D(	//	for use in xzw coordinates (with y = 0)
			axis: Int,						//	= 0, 1 for translation in x or z direction.
			distanceInRadians θ: Double,	//	In radians, even in the Euclidean case.
			geometryType: GeometryType
		) -> simd_double3x3 {	//	dimensionless, even in the Euclidean case
	
			precondition(
				axis == 0 || axis == 1,
				"axisAlignedTranslation3D() received an invalid axis")
			
			//	Initialize theTranslation to the identity matrix.
			var theTranslation = matrix_identity_double3x3
			
			//	Rotate the w axis ( = axis 2 ) towards the given axis.
			switch geometryType {
			
			case .spherical:
				theTranslation[axis][axis] =  cos(θ)
				theTranslation[axis][ 2  ] = -sin(θ)
				theTranslation[ 2  ][axis] =  sin(θ)
				theTranslation[ 2  ][ 2  ] =  cos(θ)
				
			case .euclidean:
				//	Just as in the spherical and hyperbolic cases,
				//	in the Euclidean case too
				//
				//		θ = (the translation distance) / (the space radius)
				//
				//	A product like
				//
				//		       (  1     0    0 )
				//		(x z ρ)(  0     1    0 )
				//		       ( Δx/ρ  Δz/ρ  1 )
				//
				//		where ρ = (the space radius),
				//
				//	gives the expected result.
				//
				theTranslation[axis][axis] = 1.0
				theTranslation[axis][ 2  ] = 0.0
				theTranslation[ 2  ][axis] =  θ
				theTranslation[ 2  ][ 2  ] = 1.0
				
			case .hyperbolic:
				theTranslation[axis][axis] = cosh(θ)
				theTranslation[axis][ 2  ] = sinh(θ)
				theTranslation[ 2  ][axis] = sinh(θ)
				theTranslation[ 2  ][ 2  ] = cosh(θ)
			}
			
			return theTranslation
		}
	
		private func orthonormalFrame(
			at pointA: VectorXZW,				//	in meters in XZW space
			lookingToward pointB: VectorXZW,	//	in meters in XZW space
			geometryType: GeometryType
		) -> simd_double3x3
			//	Returns a dimensionless orthonormal frame with
			//		axis 2 (the W axis) = pointA / |pointA|
			//		axis 1 (the Z axis) pointing away from pointB
			//					=> the negative Z axis points towards pointB
			//		axis 0 (the X axis) chosen to form a right-handed basis
		{
	
			let verticalMetric   = geometryType.xzwMetricForVertical
			let horizontalMetric = geometryType.xzwMetricForHorizontal
	
			let wAxis = verticalMetric.normalize(pointA)
			
			let theRawDifference = pointA - pointB
			let theProjectionLength = verticalMetric.innerProduct(
														theRawDifference,
														wAxis)
			let theProjection = theProjectionLength * wAxis
			let theUnnormalizedZAxis = theRawDifference - theProjection
			let zAxis = horizontalMetric.normalize(theUnnormalizedZAxis)
			
			//	In all three geometries (spherical, Euclidean, hyperbolic)
			//	the following formula for the cross product yields a vector that
			//
			//		is orthogonal to both zAxis and wAxis,
			//
			//		has dimensionless length 1
			//			(if the two input vectors weren't orthonormal,
			//			a more general formula would apply), and
			//
			//		gives a basis (xAxis, zAxis, wAxis) with
			//			the same handedness as the XZW coordinate system.
			//
			//	Note:  I've checked that those claims are correct
			//	in the present case, but it would need modifications
			//	in other cases, for example, the case of two "horizontal"
			//	vectors in Euclidean geometry would require the use
			//	of the "vertical" Euclidean metric.
			//
			let σ = geometryType.geometrySign
			let xAxis = VectorXZW(
					zAxis.z * wAxis.w  -  zAxis.w * wAxis.z,
					zAxis.w * wAxis.x  -  zAxis.x * wAxis.w,
			   σ * (zAxis.x * wAxis.z  -  zAxis.z * wAxis.x) )
	
			let theOrthonormalFrame = simd_double3x3(
										xAxis.v,
										zAxis.v,
										wAxis.v)
	
			return theOrthonormalFrame
		}
	
		private func ballPathIsClear(
			from ballFrameAtStart: simd_double3x3,
			to ballCenterAtFinish: VectorXZW,
			avoiding ballsToAvoid: [VectorXZW]
		) -> Bool {
	
			let theWorldToFrame = ballFrameAtStart.inverse
	
			let theBallCenterAtFinishInFrameSpace = theWorldToFrame * ballCenterAtFinish
	
			for ballToAvoid in ballsToAvoid {
			
				let theAvoidedBallInFrameSpace = theWorldToFrame * ballToAvoid
			
				if !pathFromNorthPole(
					to: theBallCenterAtFinishInFrameSpace,
					avoidsBallAt: theAvoidedBallInFrameSpace) {
				
					return false
				}
			}
			
			return true
		}
	
		//	pathFromNorthPole() works entirely in the ball's starting frame.
		//	So the ball's path begins at the north pole and runs
		//	in the negative Z direction to the ballCenterAtFinish.
		//	The otherBallCenter is, of course, given in this same
		//	coordinate system.
		private func pathFromNorthPole(
			to ballCenterAtFinish: VectorXZW,
			avoidsBallAt otherBallCenter: VectorXZW
		) -> Bool {
	
			let ρ = spaceGeometry.spaceRadius
			
			//	Please see the extended comment in ballShadowRadius()
			//	in NEBSpaceGeometry.swift for the definition of a ball's
			//	orthogonal shadow.
			let r = spaceGeometry.ballShadowRadius
	
			//	As the ball moves along a geodesic from the start to the finish,
			//	its orthogonal shadow sweeps out a capsule-like shape,
			//	with circular endcaps of radius r and sides that are
			//	not quite geodesics but instead are curves running
			//	a constant distance r from the path itself.
			//	We want to check whether the ballToAvoid's orthogonal shadow
			//	overlaps that capsule. This is equivalent to asking whether
			//	the otherBallCenter sits within a capsule with endcap radius 2r
			//	and width 2r.
			
			//	We're working in the ball's starting frame, so the path
			//	begins at (x,z,w) = (0,0,ρ) and runs in the direction
			//	of the negative Z axis. If the otherBallCenter lies
			//	further than 2r meters away from that path, we know that
			//	the other ball's orthogonal shadow can't overlap the capsule
			//	described above. Because the path runs in the negative Z direction,
			//	the distance to that path depends only on the otherBallCenter's
			//	x coordinate.
			
			let xMin: Double
			switch spaceGeometry.geometryType {
			
			case .spherical:
				xMin = ρ * sin( 2.0 * r / ρ )
				
			case .euclidean:
				xMin = 2.0 * r	//	= ρ * ( 2.0 * r / ρ )
				
			case .hyperbolic:
				xMin = ρ * sinh( 2.0 * r / ρ )
			}
			
			if abs(otherBallCenter.x) > xMin {
				return true
			}
			
			//	We now know that the otherBallCenter lies somewhere
			//	in the ±2r band running along the path, but it might
			//	lie before the path's start or after its finish.
			
			//	The only ball whose center can fall within the 2r-radius disk
			//	centered at the path's start is the ball whose path
			//	we're following -- we expect it to be there!
			let theDistanceToStartPoint
				= spaceGeometry.tableDistanceFunction(
									otherBallCenter,
									spaceGeometry.northPoleXZW)
			if theDistanceToStartPoint < 2.0 * r - 0.001 {
				assert(
					theDistanceToStartPoint < 0.001,
					"Other ball partially overlaps starting ball, or else numerical precision is terrible.")
				return true	// other ball = this ball
			}
			
			//	Test whether the other ball's center falls within
			//	the 2r-radius disk at the end of the path.
			//	Allow a little bit of slack, because when it's
			//	the cue ball following the path, we expect it to be
			//	exactly tangent to a target ball at the path's end.
			
			let theDistanceToFinishPoint
				= spaceGeometry.tableDistanceFunction(
									otherBallCenter,
									ballCenterAtFinish)
			if theDistanceToFinishPoint < 2.0 * r - 0.001 {
				return false
			}
			
			//	We know from above that the other ball's orthogonal shadow
			//	overlaps the ±2r band running along the path, but it
			//	doesn't over the 2r-radius disks at either end of the capsule.
			//	So to test whether it overlaps the capsule at all,
			//	it suffices to test whether its "distance along the path"
			//	falls between the path's start and finish.
			//
			//	tn(θ) represents
			//	   tan(θ) in the spherical case
			//	     θ    in the Euclidean case
			//	  tanh(θ) in the hyperbolic case
			//
			let tnθ = otherBallCenter.z / otherBallCenter.w
			let maxTnθ = 0.0
			let minTnθ = ballCenterAtFinish.z / ballCenterAtFinish.w	//	< 0.0
			assert(
				minTnθ < 0.0,
				"Internal error:  minTnθ is non-negative")
			if tnθ > minTnθ && tnθ < maxTnθ {
				return false
			}
	
			return true
		}
	
		private func chooseHailMaryShot(
			cueBallPose: matrix_double3x3?,
			targetBallCenters: [VectorXZW]
		) -> ShotCandidate {
	
			//	Shoot towards the nearest target ball, with no regard
			//	for whether the path is clear nor for what might happen
			//	if the cue ball does indeed reach the closest ball.
			
			guard let theCueBallPose = cueBallPose else {
				assertionFailure("chooseHailMaryShot() called with cueBallPose = nil")
				return ShotCandidate(angle: 0.0, startSpeed: 0.01)
			}
			let theCueBallCenter = theCueBallPose * spaceGeometry.northPoleXZW
			
			func distanceToTargetBallCenter(
				_ targetBallCenter: VectorXZW
			) -> Double {
			
				let theDistance = spaceGeometry.tableDistanceFunction(
													theCueBallCenter,
													targetBallCenter)
				return theDistance
			}
			
			guard let theFirstTargetBallCenter = targetBallCenters.first else {
				assertionFailure("chooseHailMaryShot() called with targetBallCenters empty")
				return ShotCandidate(angle: 0.0, startSpeed: 0.01)
			}
			var theClosestBallCenter = theFirstTargetBallCenter
			var theSmallestDistance = distanceToTargetBallCenter(theClosestBallCenter)
			
			for theTargetBallCenter in targetBallCenters {
			
				let theDistance = distanceToTargetBallCenter(theTargetBallCenter)
				
				if theDistance < theSmallestDistance {
					theClosestBallCenter = theTargetBallCenter
					theSmallestDistance = theDistance
				}
			}
	
			//	Shoot the cue ball towards theClosestBallCenter,
			//	with no regard for what happens next.
			//	Just shoot and hope for the best.
			
			let theCueBallOutboundFrame = orthonormalFrame(
											at: theCueBallCenter,
											lookingToward: theClosestBallCenter,
											geometryType: spaceGeometry.geometryType)
			let theRotation = theCueBallPose.inverse	//	right-to-left matrix action
							* theCueBallOutboundFrame
			let theAngle = atan2(theRotation[0][1], theRotation[0][0])
	
			return ShotCandidate(angle: theAngle, startSpeed: 2.0)
		}

// MARK: -
// MARK: Colors

		mutating func updateBallColorSaturation(
			for timeInterval: Double
		) {
			let theIncrement = timeInterval * colorSaturationChangeRate
		
			//	Let each ball's actual colorSaturation
			//	move gradually towards the desired value.
			for i in 0 ..< numBalls {
			
				if balls[i].wantsSaturatedColor {
				
					balls[i].colorSaturation += theIncrement
					balls[i].colorSaturation = min(1.0, balls[i].colorSaturation)
					
				} else {
				
					balls[i].colorSaturation -= theIncrement
					balls[i].colorSaturation = max(0.0, balls[i].colorSaturation)
				}
			}
		}

		mutating func updateCueStickOpacity(
			for timeInterval: Double
		) {
			let theIncrement = timeInterval * cueStickOpacityChangeRate
			
			for player: PlayerType in [.human, .computer] {
			
				var theOpacity = cueStick[player]!.opacity
				
				if whoseTurn == player {	//	player's cue stick should be opaque
					theOpacity += theIncrement
					theOpacity = min(1.0, theOpacity)
				} else {					//	player's cue stick should be transparent
					theOpacity -= theIncrement
					theOpacity = max(0.0, theOpacity)
				}
				
				cueStick[player]!.opacity = theOpacity
			}
		}

// MARK: -
// MARK: Utilities

		mutating func setBallVelocity(
			ballNumber i: Int,
			newVelocityXZ: SIMD2<Double>	//	(v_x, v_z) in meters/second, in the ball's
											//	own local (post-rotation) coordinate system
		) {
			//	balls[i].rotation must change by an appropriate rotation
			//	about the y axis, to align newVelocityXZ with the z axis.
			//
			//	balls[i].placement must then change by an equal but opposite rotation,
			//	to keep the ball in the same orientation on the table.

			let theNewSpeed = length(newVelocityXZ)

			//	Note:  The atan2() arguments in
			//
			//		θ = atan2(v_x, -v_z)
			//
			//	are chosen so that a velocity of (0, -speed) gives θ = 0.0,
			//	because (0, -speed) already points in the correct direction.
			//
			let θ = theNewSpeed > 0.0 ?
						atan2(newVelocityXZ[0], -newVelocityXZ[1]) :
						0.0
			
			let theRotationAdjustment = axisAlignedRotation(from: 2, towards: 0, angle: +θ)
			balls[i].rotation = theRotationAdjustment	//	right-to-left matrix action
							  * balls[i].rotation

			let thePlacementAdjustment = geometricInverse(
											of: theRotationAdjustment,
											geometryType: spaceGeometry.geometryType)
			balls[i].placement = balls[i].placement
							   * thePlacementAdjustment
		
			balls[i].speed = theNewSpeed
		}

		func ballsAreRolling() -> Bool {
		
			for i in 0 ..< numBalls {
			
				if balls[i].status == .inPlay
				&& balls[i].speed > 0.0 {
				
					return true
				}
			}
			
			return false
		}
	}
}
