Skip to content

Commit b694e4e

Browse files
Dean SoferDean Sofer
authored andcommitted
Adding rules project code
1 parent a9ca892 commit b694e4e

File tree

8 files changed

+1555
-0
lines changed

8 files changed

+1555
-0
lines changed

rules/app.tsx

Lines changed: 757 additions & 0 deletions
Large diffs are not rendered by default.

rules/board.tsx

Lines changed: 248 additions & 0 deletions
Large diffs are not rendered by default.

rules/constants.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
2+
import { Player } from './types';
3+
4+
export const NUM_POINTS = 24;
5+
export const NUM_CHECKERS_PER_PLAYER = 15;
6+
7+
export const BAR_INDEX_P1 = -1; // Conceptual index for Player 1 on bar
8+
export const BAR_INDEX_P2 = 25; // Conceptual index for Player 2 on bar
9+
export const OFF_INDEX_P1 = NUM_POINTS; // Conceptual index for Player 1 bearing off
10+
export const OFF_INDEX_P2 = -1; // Conceptual index for Player 2 bearing off (relative to their movement)
11+
12+
13+
export const PLAYER_COLORS: { [key in Player]: { base: string; border: string; text: string } } = {
14+
[Player.Player1]: { base: 'bg-slate-100', border: 'border-slate-300', text: 'text-slate-800' }, // Cream/White
15+
[Player.Player2]: { base: 'bg-red-700', border: 'border-red-900', text: 'text-red-100' }, // Red
16+
};
17+
18+
export const POINT_COLORS = ['bg-amber-100', 'bg-amber-700']; // Alternating point colors
19+
20+
// getInitialPointState is no longer needed as points are numbers.
21+
22+
export function getInitialBoardSetup(): number[] {
23+
const points = Array(NUM_POINTS).fill(0);
24+
25+
// Player 1 (Positive numbers)
26+
points[0] = 2;
27+
points[11] = 5;
28+
points[16] = 3;
29+
points[18] = 5;
30+
31+
// Player 2 (Negative numbers)
32+
points[23] = -2;
33+
points[12] = -5;
34+
points[7] = -3;
35+
points[5] = -5;
36+
37+
return points;
38+
}
39+
40+
// Player 1 moves from 0 towards 23 (low to high index)
41+
// Player 2 moves from 23 towards 0 (high to low index)
42+
// Player 1 Home: 18-23 (points with indices 18 through 23)
43+
// Player 2 Home: 0-5 (points with indices 0 through 5)
44+
45+
// Bar re-entry for P1: onto points 0-5 (opponent's home). Roll 1 -> point 0. Roll 6 -> point 5. Target index = dice - 1.
46+
// Bar re-entry for P2: onto points 18-23 (opponent's home). Roll 1 -> point 23. Roll 6 -> point 18. Target index = NUM_POINTS - dice.

rules/dice.tsx

Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
import React from 'react';
2+
import { Player, GamePhase } from './types';
3+
import { PLAYER_COLORS } from './constants';
4+
5+
interface DiceControlsProps {
6+
dice: number[] | null;
7+
availableDice: number[];
8+
currentPlayer: Player;
9+
gamePhase: GamePhase;
10+
onRollDice: () => void;
11+
showEndTurnButton?: boolean;
12+
onEndTurnClick?: () => void;
13+
autoRollEnabled: boolean; // New prop
14+
onToggleAutoRoll: () => void; // New prop
15+
}
16+
17+
const DieFace: React.FC<{ value: number, used: boolean, playerColorClass: string }> = ({ value, used, playerColorClass }) => {
18+
const dots = React.useMemo(() => {
19+
const dotPositions: { [key: number]: string[] } = {
20+
1: ['center'],
21+
2: ['top-left', 'bottom-right'],
22+
3: ['top-left', 'center', 'bottom-right'],
23+
4: ['top-left', 'top-right', 'bottom-left', 'bottom-right'],
24+
5: ['top-left', 'top-right', 'center', 'bottom-left', 'bottom-right'],
25+
6: ['top-left', 'top-right', 'mid-left', 'mid-right', 'bottom-left', 'bottom-right'],
26+
};
27+
const pipColorClass = playerColorClass.includes('slate-100') ? 'bg-slate-800' : 'bg-red-100';
28+
29+
const baseClasses = `absolute w-1.5 h-1.5 sm:w-2 sm:h-2 ${pipColorClass} rounded-full`;
30+
return (dotPositions[value] || []).map(pos => {
31+
let classes = baseClasses;
32+
if (pos === 'center') classes += ' top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2';
33+
if (pos === 'top-left') classes += ' top-1 left-1 sm:top-1.5 sm:left-1.5';
34+
if (pos === 'top-right') classes += ' top-1 right-1 sm:top-1.5 sm:right-1.5';
35+
if (pos === 'bottom-left') classes += ' bottom-1 left-1 sm:bottom-1.5 sm:left-1.5';
36+
if (pos === 'bottom-right') classes += ' bottom-1 right-1 sm:bottom-1.5 sm:right-1.5';
37+
if (pos === 'mid-left') classes += ' top-1/2 left-1 sm:left-1.5 -translate-y-1/2';
38+
if (pos === 'mid-right') classes += ' top-1/2 right-1 sm:right-1.5 -translate-y-1/2';
39+
return <div key={pos} className={classes}></div>;
40+
});
41+
}, [value, playerColorClass]);
42+
43+
return (
44+
<div className={`w-8 h-8 sm:w-10 sm:h-10 ${playerColorClass} border border-gray-300 rounded shadow-md flex items-center justify-center relative ${used ? 'opacity-50' : ''}`}>
45+
{dots}
46+
</div>
47+
);
48+
};
49+
50+
const DiceControls: React.FC<DiceControlsProps> = ({
51+
dice,
52+
availableDice,
53+
currentPlayer,
54+
gamePhase,
55+
onRollDice,
56+
showEndTurnButton,
57+
onEndTurnClick,
58+
autoRollEnabled,
59+
onToggleAutoRoll
60+
}) => {
61+
const playerColor = PLAYER_COLORS[currentPlayer];
62+
63+
const availableDiceMap = availableDice.reduce((acc, d) => {
64+
acc[d] = (acc[d] || 0) + 1;
65+
return acc;
66+
}, {} as Record<number, number>);
67+
68+
const diceToDisplay = dice || [];
69+
const displayedDiceStatus = diceToDisplay.map((dVal, index) => {
70+
if (availableDiceMap[dVal] && availableDiceMap[dVal] > 0) {
71+
availableDiceMap[dVal]--;
72+
return { value: dVal, used: false, id: `die-${index}-${dVal}-avail` };
73+
}
74+
return { value: dVal, used: true, id: `die-${index}-${dVal}-used` };
75+
});
76+
77+
78+
return (
79+
<div className="fixed bottom-0 left-0 right-0 p-2 sm:p-3 bg-gray-900 bg-opacity-80 backdrop-blur-sm z-30 shadow-md flex flex-wrap justify-between items-center text-white gap-2 sm:gap-3">
80+
<div className="flex items-center space-x-2 shrink-0 order-1">
81+
<h3 className={`text-sm sm:text-base font-semibold ${playerColor.text}`}>
82+
{currentPlayer}'s Turn <span className="hidden sm:inline">({gamePhase})</span>
83+
</h3>
84+
<div className="flex items-center space-x-1">
85+
<input
86+
type="checkbox"
87+
id="autoRollToggle"
88+
checked={autoRollEnabled}
89+
onChange={onToggleAutoRoll}
90+
className="form-checkbox h-3 w-3 sm:h-4 sm:w-4 text-amber-500 bg-gray-700 border-gray-500 rounded focus:ring-amber-400 cursor-pointer"
91+
/>
92+
<label htmlFor="autoRollToggle" className="text-xs sm:text-sm text-gray-300 cursor-pointer">Auto Roll</label>
93+
</div>
94+
</div>
95+
96+
<div className="flex space-x-1 sm:space-x-2 order-3 sm:order-2 sm:absolute sm:left-1/2 sm:-translate-x-1/2">
97+
{gamePhase === GamePhase.Moving && displayedDiceStatus.length > 0 &&
98+
displayedDiceStatus.map(d => <DieFace key={d.id} value={d.value} used={d.used} playerColorClass={playerColor.base} />)
99+
}
100+
{gamePhase !== GamePhase.Moving && dice === null && <p className="text-xs sm:text-sm text-gray-400">Roll the dice</p>}
101+
</div>
102+
103+
<div className="flex items-center space-x-2 order-2 sm:order-3">
104+
{gamePhase === GamePhase.Rolling && (
105+
<button
106+
onClick={onRollDice}
107+
className={`px-3 py-1.5 sm:px-4 sm:py-2 text-sm sm:text-base rounded font-semibold shadow hover:opacity-90 transition-opacity ${playerColor.base} ${playerColor.text} border ${playerColor.border}`}
108+
aria-label="Roll Dice"
109+
disabled={autoRollEnabled} // Disable if auto-roll will handle it
110+
>
111+
Roll Dice
112+
</button>
113+
)}
114+
{showEndTurnButton && onEndTurnClick && (
115+
<button
116+
onClick={onEndTurnClick}
117+
className={`px-3 py-1.5 sm:px-4 sm:py-2 text-sm sm:text-base rounded font-semibold shadow hover:opacity-90 transition-opacity bg-gray-500 text-white border border-gray-400`}
118+
aria-label="Pass turn, no moves available"
119+
>
120+
Pass Turn
121+
</button>
122+
)}
123+
</div>
124+
</div>
125+
);
126+
};
127+
128+
export default DiceControls;

rules/piece.tsx

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
import React from 'react';
2+
import { Player } from './types';
3+
import { PLAYER_COLORS } from './constants';
4+
5+
interface CheckerPieceProps {
6+
player: Player;
7+
onDragStart?: (e: React.DragEvent<HTMLDivElement>) => void; // Mouse
8+
onTouchStartDraggable?: (event: React.TouchEvent<HTMLDivElement>) => void; // Touch, primarily for Bar
9+
isDraggable?: boolean;
10+
small?: boolean;
11+
}
12+
13+
const CheckerPiece: React.FC<CheckerPieceProps> = ({
14+
player,
15+
onDragStart,
16+
onTouchStartDraggable,
17+
isDraggable,
18+
small
19+
}) => {
20+
const sizeClasses = small ? "w-4 h-4 md:w-5 md:h-5" : "w-8 h-8 md:w-10 md:h-10";
21+
const innerSizeClasses = small ? "w-2 h-2 md:w-2.5 md:h-2.5" : "w-4 h-4 md:w-5 md:h-5";
22+
23+
return (
24+
<div
25+
draggable={isDraggable}
26+
onDragStart={onDragStart}
27+
onTouchStart={onTouchStartDraggable} // Added for bar checkers
28+
className={`${sizeClasses} checker-piece-visual rounded-full checker-shadow flex items-center justify-center ${isDraggable ? 'cursor-grab' : 'cursor-default'} ${PLAYER_COLORS[player].base} ${PLAYER_COLORS[player].border} border-2`}
29+
style={{ transition: 'transform 0.2s ease-out' }}
30+
aria-label={`${player} checker`}
31+
onMouseDown={(e) => { if (isDraggable) e.stopPropagation(); }} // For mouse
32+
>
33+
<div className={`${innerSizeClasses} rounded-full ${player === Player.Player1 ? 'bg-slate-300' : 'bg-red-900'}`}></div>
34+
</div>
35+
);
36+
};
37+
38+
export default CheckerPiece;

rules/point.tsx

Lines changed: 168 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,168 @@
1+
import React, { useState } from 'react';
2+
import { Player } from './types';
3+
import CheckerPiece from './piece';
4+
5+
export type PointDirection = 'up' | 'down' | 'left' | 'right';
6+
export type CheckerStackDirection = 'vertical-base' | 'vertical-tip' | 'horizontal-base' | 'horizontal-tip';
7+
8+
interface PointProps {
9+
pointValue: number;
10+
pointIndex: number;
11+
pointDirection: PointDirection;
12+
checkerStackDirection: CheckerStackDirection;
13+
isLandscape: boolean;
14+
colorIndex: number;
15+
onClick: () => void;
16+
onDrop: (e: React.DragEvent<HTMLDivElement>) => void;
17+
isHighlighted: boolean; // Is this point a possible destination?
18+
isSelectedSource: boolean; // Is this point the selected source?
19+
isTouchDragHovering?: boolean; // Is this the current touch hover target?
20+
canDragFrom: boolean;
21+
onCheckerDragStart: (player: Player, sourceIndex: number) => void; // Mouse drag
22+
onTouchStartDraggable: (player: Player, sourceIndex: number) => void; // Touch drag
23+
}
24+
25+
const Point: React.FC<PointProps> = ({
26+
pointValue,
27+
pointIndex,
28+
pointDirection,
29+
checkerStackDirection,
30+
isLandscape,
31+
colorIndex,
32+
onClick,
33+
onDrop,
34+
isHighlighted,
35+
isSelectedSource,
36+
isTouchDragHovering,
37+
canDragFrom,
38+
onCheckerDragStart,
39+
onTouchStartDraggable
40+
}) => {
41+
const [isMouseDragHovering, setIsMouseDragHovering] = useState(false);
42+
43+
const numCheckers = Math.abs(pointValue);
44+
const playerOnPoint: Player | null = pointValue > 0 ? Player.Player1 : (pointValue < 0 ? Player.Player2 : null);
45+
46+
const checkersToRenderCount = numCheckers;
47+
48+
let pointContainerClasses = 'point-container ';
49+
50+
if (isLandscape) {
51+
pointContainerClasses += pointDirection === 'up' ? 'point-shape-up ' : 'point-shape-down ';
52+
} else {
53+
pointContainerClasses += pointDirection === 'left' ? 'point-shape-left ' : 'point-shape-right ';
54+
}
55+
56+
const showDragOverHighlight = (isMouseDragHovering || isTouchDragHovering) && isHighlighted;
57+
58+
if (showDragOverHighlight) {
59+
pointContainerClasses += 'point-color-highlight-hover ';
60+
} else if (isSelectedSource) {
61+
pointContainerClasses += 'point-color-selected-source ';
62+
} else if (isHighlighted) {
63+
pointContainerClasses += 'point-color-highlight-move ';
64+
} else {
65+
pointContainerClasses += colorIndex % 2 === 0 ? 'point-color-default-0 ' : 'point-color-default-1 ';
66+
}
67+
68+
let checkerLayoutClasses = 'absolute flex z-10 overflow-hidden';
69+
const checkerSpacing = 'space-x-0.5 md:space-x-1';
70+
const verticalCheckerSpacing = 'space-y-0.5 md:space-y-1';
71+
72+
if (isLandscape) {
73+
checkerLayoutClasses += ` w-full h-[85%] items-center ${verticalCheckerSpacing}`;
74+
if (pointDirection === 'up') {
75+
checkerLayoutClasses += ' bottom-[5%] flex-col-reverse justify-start';
76+
} else {
77+
checkerLayoutClasses += ' top-[5%] flex-col justify-start';
78+
}
79+
checkerLayoutClasses += ' left-1/2 -translate-x-1/2';
80+
} else {
81+
checkerLayoutClasses += ` h-full w-[85%] items-center ${checkerSpacing}`;
82+
if (pointDirection === 'left') {
83+
checkerLayoutClasses += ' right-[5%] flex-row-reverse justify-start';
84+
} else {
85+
checkerLayoutClasses += ' left-[5%] flex-row justify-start';
86+
}
87+
checkerLayoutClasses += ' top-1/2 -translate-y-1/2';
88+
}
89+
90+
const handleMouseDragStartPoint = (e: React.DragEvent<HTMLDivElement>) => {
91+
if (canDragFrom && playerOnPoint) {
92+
onCheckerDragStart(playerOnPoint, pointIndex);
93+
e.dataTransfer.setData("text/plain", JSON.stringify({ type: 'point_checker', sourceIndex: pointIndex, player: playerOnPoint }));
94+
e.dataTransfer.effectAllowed = "move";
95+
96+
const checkerVisualEl = e.currentTarget.querySelector('.checker-piece-visual') as HTMLElement;
97+
if (checkerVisualEl) {
98+
const xOffset = checkerVisualEl.offsetWidth / 2;
99+
const yOffset = checkerVisualEl.offsetHeight / 2;
100+
e.dataTransfer.setDragImage(checkerVisualEl, xOffset, yOffset);
101+
}
102+
} else {
103+
e.preventDefault();
104+
}
105+
};
106+
107+
const handleTouchStartPoint = (e: React.TouchEvent<HTMLDivElement>) => {
108+
if (canDragFrom && playerOnPoint) {
109+
e.preventDefault(); // Prevent mouse event emulation, scrolling, and other default gestures.
110+
onTouchStartDraggable(playerOnPoint, pointIndex);
111+
}
112+
};
113+
114+
115+
const getCheckerKey = (idx: number) => `checker-${pointIndex}-${idx}`;
116+
117+
const handleDragOverPoint = (e: React.DragEvent<HTMLDivElement>) => {
118+
if (isHighlighted) {
119+
e.preventDefault();
120+
e.dataTransfer.dropEffect = "move";
121+
}
122+
};
123+
124+
const handleDragEnterPoint = (e: React.DragEvent<HTMLDivElement>) => {
125+
if (isHighlighted) {
126+
setIsMouseDragHovering(true);
127+
e.preventDefault();
128+
}
129+
};
130+
131+
const handleDragLeavePoint = (e: React.DragEvent<HTMLDivElement>) => {
132+
setIsMouseDragHovering(false);
133+
};
134+
135+
return (
136+
<div
137+
className={`${pointContainerClasses.trim()} cursor-pointer`}
138+
onClick={onClick}
139+
onDrop={(e) => { e.stopPropagation(); onDrop(e); setIsMouseDragHovering(false); }}
140+
onDragOver={handleDragOverPoint}
141+
onDragEnter={handleDragEnterPoint}
142+
onDragLeave={handleDragLeavePoint}
143+
draggable={canDragFrom && playerOnPoint != null} // For mouse drag
144+
onDragStart={handleMouseDragStartPoint} // For mouse drag
145+
onTouchStart={handleTouchStartPoint} // For touch drag
146+
aria-label={`Point ${pointIndex + 1}, Checkers: ${numCheckers}, Player: ${playerOnPoint || 'None'}`}
147+
>
148+
{playerOnPoint && (
149+
<div className={checkerLayoutClasses}>
150+
{Array.from({ length: checkersToRenderCount }).map((_, idx) => (
151+
<div
152+
key={getCheckerKey(idx)}
153+
className="flex-shrink-0"
154+
>
155+
<CheckerPiece
156+
player={playerOnPoint}
157+
small={numCheckers > 1}
158+
// No direct touch handlers on checker piece within point, point handles it.
159+
/>
160+
</div>
161+
))}
162+
</div>
163+
)}
164+
</div>
165+
);
166+
};
167+
168+
export default Point;

0 commit comments

Comments
 (0)