diff --git a/.gitignore b/.gitignore index c571a23..6559694 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,3 @@ node_modules/ dist/ - .env.local \ No newline at end of file diff --git a/README.md b/README.md index b0a3f4d..3b000c8 100644 --- a/README.md +++ b/README.md @@ -109,6 +109,12 @@ rm -rf node_modules pnpm-lock.yaml && pnpm store prune && pnpm install pnpm prepare --watch ``` +### Build the minified bundled dist files: (NOTE: from lila you will likely then need to restart the build as it does not watch for changes on the minified file): + +```sh +pnpm dist +``` + ### Run tests: ```sh diff --git a/package.json b/package.json index 93b81d8..100d7b9 100644 --- a/package.json +++ b/package.json @@ -1,21 +1,28 @@ { "name": "chessground", - "version": "7.11.1-pstrat3.3", + "version": "7.11.1-pstrat3.4", "description": "playstrategy.org chess ui, forked from lichess.org", "type": "module", + "module": "dist/chessground.js", "main": "dist/chessground.js", "types": "chessground.d.ts", - "exports": { - ".": "./dist/chessground.js", - "./*": "./dist/*.js" - }, "typesVersions": { "*": { "*": [ - "dist/*" + "dist/types/*" ] } }, + "exports": { + ".": { + "import": "./dist/chessground.js", + "types": "./dist/types/chessground.d.ts" + }, + "./*": { + "import": "./dist/*.js", + "types": "./dist/types/*.d.ts" + } + }, "packageManager": "pnpm@9.1.0", "engines": { "node": ">=20", @@ -35,7 +42,7 @@ }, "scripts": { "prepare": "$npm_execpath run compile", - "compile": "tsc --sourceMap --declaration", + "compile": "tsc --declarationDir dist/types --sourceMap", "test": "node node_modules/jest/bin/jest.js", "lint": "eslint src/*.ts", "format": "prettier --write .", @@ -48,11 +55,9 @@ "postinstall": "$npm_execpath run bundle" }, "files": [ - "/dist/*.js", - "/dist/*.d.ts", - "/dist/*.js.map", - "/assets/*.css", - "/src/*.ts" + "/src", + "/dist", + "/assets/*.css" ], "jest": { "globals": { diff --git a/src/api.ts b/src/api.ts index dc4113a..d196fff 100644 --- a/src/api.ts +++ b/src/api.ts @@ -144,11 +144,11 @@ export function start(state: State, redrawAll: cg.Redraw): Api { }, move(orig, dest): void { - anim(state => board.baseMove(state, orig, dest), state); + anim(state => state.baseMove(state, orig, dest), state); }, moveNoAnim(orig, dest): void { - board.baseMove(state, orig, dest); + state.baseMove(state, orig, dest); state.dom.redraw(); }, diff --git a/src/board.ts b/src/board.ts index 50a6b52..7ddba89 100644 --- a/src/board.ts +++ b/src/board.ts @@ -24,6 +24,8 @@ import predrop from './predrop'; import * as cg from './types'; import * as T from './transformations'; +import { getKeyAtDomPos as abaloneGetKeyAtDomPos } from './variants/abalone/board'; + export function setOrientation(state: HeadlessState, o: cg.Orientation): void { state.orientation = o; state.animation.current = state.draggable.current = state.selected = undefined; @@ -181,6 +183,10 @@ function updatePocketPieces( state.pocketPieces = newPocketPieces; } +/** + * called when a piece is moved from orig to dest + * @returns: false if the move is invalid, true if the move is valid but no capture happened, or the captured piece if a capture happened + */ export function baseMove(state: HeadlessState, orig: cg.Key, dest: cg.Key): cg.Piece | boolean { const origPiece = state.pieces.get(orig), destPiece = state.pieces.get(dest); @@ -234,6 +240,8 @@ function isCapture(variant: cg.Variant, destPiece: cg.Piece | undefined, origPie case 'oware': //TODO this is more complicated to calculate... (but its only used for sound in lila atm) return destPiece && destPiece.playerIndex !== origPiece.playerIndex ? destPiece : undefined; + case 'abalone': + return undefined; // we compute it from Abalone namespace using HOF default: return destPiece && destPiece.playerIndex !== origPiece.playerIndex ? destPiece : undefined; } @@ -263,7 +271,7 @@ export function baseNewPiece(state: HeadlessState, piece: cg.Piece, key: cg.Key, } function baseUserMove(state: HeadlessState, orig: cg.Key, dest: cg.Key): cg.Piece | boolean { - const result = baseMove(state, orig, dest); + const result = state.baseMove(state, orig, dest); if (result) { state.movable.dests = undefined; state.dropmode.dropDests = undefined; @@ -625,6 +633,8 @@ export function stop(state: HeadlessState): void { cancelMove(state); } +// triggered when we click on the svg area (a piece, a square or even an area outside the board drawn can be below the cursor). +// @return the key of the square that was clicked, or undefined if the click was outside the board. export function getKeyAtDomPos( pos: cg.NumberPair, orientation: cg.Orientation, @@ -632,6 +642,9 @@ export function getKeyAtDomPos( bd: cg.BoardDimensions, variant: cg.Variant = 'chess', ): cg.Key | undefined { + if (variant === 'abalone') { + return abaloneGetKeyAtDomPos(pos, orientation, bounds); + } const bgBorder = 1 / 15; const file = variant === 'backgammon' || variant === 'hyper' || variant === 'nackgammon' @@ -729,6 +742,7 @@ export function p1Pov(s: HeadlessState): boolean { return s.myPlayerIndex === 'p1'; } +// at least triggered when we use right click to draw arrows or highlight a square export function getSnappedKeyAtDomPos( orig: cg.Key, pos: cg.NumberPair, diff --git a/src/chessground.ts b/src/chessground.ts index 84c6170..d328b9f 100644 --- a/src/chessground.ts +++ b/src/chessground.ts @@ -3,7 +3,7 @@ import { Config, configure } from './config'; import { HeadlessState, State, defaults } from './state'; import { renderWrap } from './wrap'; import * as events from './events'; -import { render, updateBounds } from './render'; +import { updateBounds } from './render'; import * as svg from './svg'; import * as util from './util'; @@ -18,7 +18,7 @@ export function Chessground(element: HTMLElement, config?: Config): Api { elements = renderWrap(element, maybeState, relative), bounds = util.memo(() => elements.board.getBoundingClientRect()), redrawNow = (skipSvg?: boolean): void => { - render(state); + maybeState.render(state); if (!skipSvg && elements.svg) svg.renderSvg(state, elements.svg, elements.customSvg!); }, boundsUpdated = (): void => { diff --git a/src/config.ts b/src/config.ts index f60d0af..64be347 100644 --- a/src/config.ts +++ b/src/config.ts @@ -1,8 +1,10 @@ +import * as cg from './types'; import { HeadlessState } from './state'; import { setSelected, setGoScore } from './board'; import { read as fenRead, readPocket as fenReadPocket } from './fen'; import { DrawShape, DrawBrush } from './draw'; -import * as cg from './types'; + +import { configure as abaloneConfigure } from './variants/abalone/config'; export interface Config { fen?: cg.FEN; // chess position in Forsyth notation @@ -181,6 +183,11 @@ export function configure(state: HeadlessState, config: Config): void { ), ); } + + // configure variants + if (state.variant === 'abalone') { + abaloneConfigure(state); + } } function setCheck(state: HeadlessState, playerIndex: cg.PlayerIndex | boolean): void { diff --git a/src/drag.ts b/src/drag.ts index 8369a55..4165ef0 100644 --- a/src/drag.ts +++ b/src/drag.ts @@ -7,6 +7,8 @@ import { anim } from './anim'; import predrop from './predrop'; import * as T from './transformations'; +import { processDrag as abaloneProcessDrag } from './variants/abalone/drag'; + export interface DragCurrent { orig: cg.Key; // orig key of dragging piece origPos: cg.Pos; @@ -150,6 +152,7 @@ export function dragNewPiece(s: State, piece: cg.Piece, e: cg.MouchEvent, force? function processDrag(s: State): void { requestAnimationFrame(() => { + if (s.variant === 'abalone') return abaloneProcessDrag(s); // "working" WIP: have to use HOF const cur = s.draggable.current; if (!cur) return; // cancel animations while dragging @@ -172,7 +175,7 @@ function processDrag(s: State): void { cur.pos = [cur.epos[0] - cur.rel[0], cur.epos[1] - cur.rel[1]]; // move piece - const translation = util.posToTranslateAbs(s.dom.bounds(), s.dimensions, s.variant)(cur.origPos, s.orientation); + const translation = util.posToTranslateAbs(s.dom.bounds(), s.dimensions, s.variant)(cur.origPos, s.orientation); // until translateAbs becomes a HOF, it has to remain invoked from util. translation[0] += cur.pos[0] + cur.dec[0]; translation[1] += cur.pos[1] + cur.dec[1]; util.translateAbs(cur.element, translation); diff --git a/src/fen.ts b/src/fen.ts index c53028e..9d01988 100644 --- a/src/fen.ts +++ b/src/fen.ts @@ -1,11 +1,13 @@ import { pos2key, NRanks, invNRanks } from './util'; import * as cg from './types'; +import { read as abaloneRead } from './variants/abalone/fen'; + export const initial: cg.FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR'; const commaFenVariants: cg.Variant[] = ['oware', 'togyzkumalak', 'bestemshe', 'backgammon', 'hyper', 'nackgammon']; const mancalaFenVariants: cg.Variant[] = ['oware', 'togyzkumalak', 'bestemshe']; -function roles(letter: string) { +export function roles(letter: string) { return (letter.replace('+', 'p') + '-piece') as cg.Role; } @@ -15,6 +17,7 @@ function letters(role: cg.Role) { } export function read(fen: cg.FEN, dimensions: cg.BoardDimensions, variant: cg.Variant): cg.Pieces { + if (variant === 'abalone') return abaloneRead(fen, dimensions); if (fen === 'start') fen = initial; if (fen.indexOf('[') !== -1) fen = fen.slice(0, fen.indexOf('[')); const pieces: cg.Pieces = new Map(); diff --git a/src/premove.ts b/src/premove.ts index dccab50..8697d0e 100644 --- a/src/premove.ts +++ b/src/premove.ts @@ -1,7 +1,9 @@ import * as util from './util'; import * as cg from './types'; -type Mobility = (x1: number, y1: number, x2: number, y2: number) => boolean; +import { marble as abaloneMarble } from './variants/abalone/premove'; + +export type Mobility = (x1: number, y1: number, x2: number, y2: number) => boolean; function diff(a: number, b: number): number { return Math.abs(a - b); @@ -1147,6 +1149,10 @@ export function premove( mobility = breakthroughPawn(pieces, playerIndex); break; + case 'abalone': + mobility = abaloneMarble(pieces, playerIndex); + break; + // Variants using standard pieces and additional fairy pieces like S-chess, Capablanca, etc. default: switch (role) { diff --git a/src/render.ts b/src/render.ts index 540749d..32d9305 100644 --- a/src/render.ts +++ b/src/render.ts @@ -1,28 +1,22 @@ import { State } from './state'; -import { - key2pos, - createEl, - posToTranslateRel, - posToTranslateAbs, - translateRel, - translateAbs, - calculatePlayerEmptyAreas, -} from './util'; +import { key2pos, createEl, posToTranslateAbs, translateRel, translateAbs, calculatePlayerEmptyAreas } from './util'; import { p1Pov } from './board'; import { AnimCurrent, AnimVectors, AnimVector, AnimFadings } from './anim'; import { DragCurrent } from './drag'; import * as cg from './types'; import * as T from './transformations'; -type PieceName = string; // `$playerIndex $role` -type SquareClasses = Map; +export type PieceName = string; // `$playerIndex $role` +export type SquareClasses = Map; // ported from https://github.com/veloce/lichobile/blob/master/src/js/chessground/view.js // in case of bugs, blame @veloce export function render(s: State): void { const orientation = s.orientation, asP1: boolean = p1Pov(s), - posToTranslate = s.dom.relative ? posToTranslateRel : posToTranslateAbs(s.dom.bounds(), s.dimensions, s.variant), + posToTranslate = s.dom.relative + ? s.posToTranslateRelative + : s.posToTranslateAbsolute(s.dom.bounds(), s.dimensions, s.variant), translate = s.dom.relative ? translateRel : translateAbs, boardEl: HTMLElement = s.dom.elements.board, pieces: cg.Pieces = s.pieces, @@ -215,18 +209,18 @@ export function updateBounds(s: State): void { } } -function isPieceNode(el: cg.PieceNode | cg.SquareNode): el is cg.PieceNode { +export function isPieceNode(el: cg.PieceNode | cg.SquareNode): el is cg.PieceNode { return el.tagName === 'PIECE'; } -function isSquareNode(el: cg.PieceNode | cg.SquareNode): el is cg.SquareNode { +export function isSquareNode(el: cg.PieceNode | cg.SquareNode): el is cg.SquareNode { return el.tagName === 'SQUARE'; } -function removeNodes(s: State, nodes: HTMLElement[]): void { +export function removeNodes(s: State, nodes: HTMLElement[]): void { for (const node of nodes) s.dom.elements.board.removeChild(node); } -function posZIndex(pos: cg.Pos, orientation: cg.Orientation, asP1: boolean, bd: cg.BoardDimensions): string { +export function posZIndex(pos: cg.Pos, orientation: cg.Orientation, asP1: boolean, bd: cg.BoardDimensions): string { pos = T.mapToP1[orientation](pos, bd); let z = 2 + (pos[1] - 1) * bd.height + (bd.width - pos[0]); if (asP1) z = 67 - z; @@ -354,7 +348,7 @@ function addSquare(squares: SquareClasses, key: cg.Key, klass: string): void { else squares.set(key, klass); } -function appendValue(map: Map, key: K, value: V): void { +export function appendValue(map: Map, key: K, value: V): void { const arr = map.get(key); if (arr) arr.push(value); else map.set(key, [value]); diff --git a/src/state.ts b/src/state.ts index fd116c9..78180ad 100644 --- a/src/state.ts +++ b/src/state.ts @@ -1,8 +1,11 @@ import * as fen from './fen'; import { AnimCurrent } from './anim'; +import { baseMove } from './board'; import { DragCurrent } from './drag'; import { Drawable } from './draw'; -import { timer } from './util'; +import { render } from './render'; +import { key2pos, posToTranslateAbs, posToTranslateRel, timer } from './util'; +import { pos2px } from './svg'; import * as cg from './types'; export interface HeadlessState { @@ -136,6 +139,21 @@ export interface HeadlessState { notation: cg.Notation; onlyDropsVariant: boolean; singleClickMoveVariant: boolean; + baseMove: (state: HeadlessState, orig: cg.Key, dest: cg.Key) => cg.Piece | boolean; + render: (state: State) => void; + posToTranslateRelative: ( + pos: cg.Pos, + orientation: cg.Orientation, + bt: cg.BoardDimensions, + v: cg.Variant, + ) => cg.NumberPair; + posToTranslateAbsolute: ( + bounds: ClientRect, + bt: cg.BoardDimensions, + variant: cg.Variant, + ) => (pos: cg.Pos, orientation: cg.Orientation) => cg.NumberPair; + pos2px: (pos: cg.Pos, bounds: ClientRect, bd: cg.BoardDimensions) => cg.NumberPair; + key2pos: (k: cg.Key) => cg.Pos; } export interface State extends HeadlessState { @@ -248,5 +266,11 @@ export function defaults(): HeadlessState { notation: cg.Notation.DEFAULT, onlyDropsVariant: false, singleClickMoveVariant: false, + baseMove, + render, + posToTranslateRelative: posToTranslateRel, + posToTranslateAbsolute: posToTranslateAbs, + pos2px, + key2pos, }; } diff --git a/src/svg.ts b/src/svg.ts index c97e259..b5144ef 100644 --- a/src/svg.ts +++ b/src/svg.ts @@ -1,5 +1,4 @@ import { State } from './state'; -import { key2pos } from './util'; import { Drawable, DrawShape, DrawShapePiece, DrawBrush, DrawBrushes, DrawModifiers } from './draw'; import * as cg from './types'; import * as T from './transformations'; @@ -187,12 +186,13 @@ function renderShape( ): SVGElement { let el: SVGElement; if (shape.customSvg) { - const orig = orient(key2pos(shape.orig), state.orientation, state.dimensions); + const orig = orient(state.key2pos(shape.orig), state.orientation, state.dimensions); el = renderCustomSvg(shape.customSvg, orig, bounds, state.dimensions); } else if (shape.piece) el = renderPiece( + state, state.drawable.pieces.baseUrl, - orient(key2pos(shape.orig), state.orientation, state.dimensions), + orient(state.key2pos(shape.orig), state.orientation, state.dimensions), shape.piece, bounds, state.dimensions, @@ -200,20 +200,21 @@ function renderShape( state.variant, ); else { - const orig = orient(key2pos(shape.orig), state.orientation, state.dimensions); + const orig = orient(state.key2pos(shape.orig), state.orientation, state.dimensions); if (shape.orig && shape.dest) { let brush: DrawBrush = brushes[shape.brush!]; if (shape.modifiers) brush = makeCustomBrush(brush, shape.modifiers); el = renderArrow( + state, brush, orig, - orient(key2pos(shape.dest), state.orientation, state.dimensions), + orient(state.key2pos(shape.dest), state.orientation, state.dimensions), current, (arrowDests.get(shape.dest) || 0) > 1, bounds, state.dimensions, ); - } else el = renderCircle(brushes[shape.brush!], orig, current, bounds, state.dimensions); + } else el = renderCircle(state, brushes[shape.brush!], orig, current, bounds, state.dimensions); } el.setAttribute('cgHash', hash); return el; @@ -238,13 +239,14 @@ function renderCustomSvg(customSvg: string, pos: cg.Pos, bounds: ClientRect, bd: } function renderCircle( + state: State, brush: DrawBrush, pos: cg.Pos, current: boolean, bounds: ClientRect, bd: cg.BoardDimensions, ): SVGElement { - const o = pos2px(pos, bounds, bd), + const o = state.pos2px(pos, bounds, bd), widths = circleWidth(bounds, bd), radius = (bounds.width + bounds.height) / (2 * (bd.height + bd.width)); return setAttributes(createElement('circle'), { @@ -259,6 +261,7 @@ function renderCircle( } function renderArrow( + state: State, brush: DrawBrush, orig: cg.Pos, dest: cg.Pos, @@ -268,8 +271,8 @@ function renderArrow( bd: cg.BoardDimensions, ): SVGElement { const m = arrowMargin(bounds, shorten && !current, bd), - a = pos2px(orig, bounds, bd), - b = pos2px(dest, bounds, bd), + a = state.pos2px(orig, bounds, bd), + b = state.pos2px(dest, bounds, bd), dx = b[0] - a[0], dy = b[1] - a[1], angle = Math.atan2(dy, dx), @@ -289,6 +292,7 @@ function renderArrow( } function renderPiece( + state: State, baseUrl: string, pos: cg.Pos, piece: DrawShapePiece, @@ -297,7 +301,7 @@ function renderPiece( myPlayerIndex: cg.PlayerIndex, variant: cg.Variant, ): SVGElement { - const o = pos2px(pos, bounds, bd), + const o = state.pos2px(pos, bounds, bd), width = (bounds.width / bd.width) * (piece.scale || 1), height = (bounds.height / bd.height) * (piece.scale || 1), //name = piece.playerIndex[0] + piece.role[0].toUpperCase(); @@ -370,7 +374,7 @@ function arrowMargin(bounds: ClientRect, shorten: boolean, bd: cg.BoardDimension return ((shorten ? 20 : 10) / (bd.width * 64)) * bounds.width; } -function pos2px(pos: cg.Pos, bounds: ClientRect, bd: cg.BoardDimensions): cg.NumberPair { +export function pos2px(pos: cg.Pos, bounds: ClientRect, bd: cg.BoardDimensions): cg.NumberPair { return [((pos[0] - 0.5) * bounds.width) / bd.width, ((bd.height + 0.5 - pos[1]) * bounds.height) / bd.height]; } @@ -419,6 +423,7 @@ function roleToSvgName(variant: cg.Variant, piece: DrawShapePiece): string { case 'go9x9': case 'go13x13': case 'go19x19': + case 'abalone': return (piece.playerIndex === 'p1' ? 'b' : 'w') + piece.role[0].toUpperCase(); case 'oware': case 'togyzkumalak': diff --git a/src/types.ts b/src/types.ts index 31fdc54..2ebf0ca 100644 --- a/src/types.ts +++ b/src/types.ts @@ -57,6 +57,7 @@ export type Variant = | 'nackgammon' | 'breakthroughtroyka' | 'minibreakthroughtroyka' + | 'abalone' | undefined; export type PlayerIndex = (typeof playerIndexs)[number]; export type Letter = (typeof letters)[number]; diff --git a/src/util.ts b/src/util.ts index abe15ac..c1380c0 100644 --- a/src/util.ts +++ b/src/util.ts @@ -1,6 +1,8 @@ import * as cg from './types'; import * as T from './transformations'; +import { posToTranslateRel as abalonePosToTranslateRel, posToTranslateBase2 } from './variants/abalone/util'; + export const playerIndexs: cg.PlayerIndex[] = ['p1', 'p2']; export const invRanks: readonly cg.Rank[] = [...cg.ranks19].reverse(); export const NRanks: number[] = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19]; @@ -159,6 +161,10 @@ export const posToTranslateAbs = ( (bounds.width / bt.width) * (variant === 'backgammon' || variant === 'hyper' || variant === 'nackgammon' ? 0.8 : 1), yFactor = bounds.height / bt.height; + if (variant === 'abalone') { + // "working" WIP: have to use HOF + return (pos, orientation) => posToTranslateBase2(bounds, pos, orientation); + } return (pos, orientation) => posToTranslateBase(pos, orientation, xFactor, yFactor, bt); }; @@ -167,8 +173,12 @@ export const posToTranslateRel = ( orientation: cg.Orientation, bt: cg.BoardDimensions, v: cg.Variant, -): cg.NumberPair => - posToTranslateBase( +): cg.NumberPair => { + if (v === 'abalone') { + // "working" WIP: have to use HOF + return abalonePosToTranslateRel(pos, orientation, bt, v); + } + return posToTranslateBase( pos, orientation, 100, @@ -179,6 +189,7 @@ export const posToTranslateRel = ( : 100, bt, ); +}; export const translateAbs = (el: HTMLElement, pos: cg.NumberPair): void => { el.style.transform = `translate(${pos[0]}px,${pos[1]}px)`; diff --git a/src/variants/abalone/board.ts b/src/variants/abalone/board.ts new file mode 100644 index 0000000..ec2dad9 --- /dev/null +++ b/src/variants/abalone/board.ts @@ -0,0 +1,150 @@ +import type * as cg from '../../types'; + +import { getCoordinates, getSquareDimensions } from './util'; + +/* + from a position in pixels, returns the key of the square + by default squares positions in CG are computed like this : + ----------------- + |_ _ _ _ _ _ _ _ _| 8 + |_ _ _ _ _ _ _ _ _| 7 + |_ _ _ _ _ _ _ _ _| 6 + |_ _ _ _ _ _ _ _ _| 5 + |_ _ _ _ _ _ _ _ _| 4 + |_ _ _ _ _ _ _ _ _| 3 + |_ _ _ _ _ _ _ _ _| 2 + |_ _ _ _ _ _ _ _ _| 1 + ----------------- + a b c d e f g h i + + But as an hexagonal grid, we have to take into account the margin between the border of the board + ... and the limit of the area : + ---------------------- + | |margin + | _ _ _ _ _ | 9 + | /_ _ _ _ _ _\ | 8 + | /_ _ _ _ _ _ _\ | 7 + | /_ _ _ _ _ _ _ _\ | 6 + | /_ _ _ _ _ _ _ _ _\ | 5 + | \ _ _ _ _ _ _ _ _ / | 4 + | \ _ _ _ _ _ _ _ / | 3 + | \ _ _ _ _ _ _ / | 2 + | \ _ _ _ _ _ / | 1 + | |margin + ---------------------- + \ \ \ \ \ \ \ \ \ + a b c d e f g h i + + Used to know on which square the user clicked. +*/ +export const getKeyAtDomPos = ( + pos: cg.NumberPair, + orientation: cg.Orientation, + bounds: ClientRect, +): cg.Key | undefined => { + const clickCenterX = pos[0] - bounds.left; + const clickCenterY = pos[1] - bounds.top; + const squareDimensions = getSquareDimensions(bounds); + const verticalCenter = bounds.height / 2; + const horizontalCenter = bounds.width / 2; + + if ( + clickCenterY > verticalCenter - 0.5 * squareDimensions.height && + clickCenterY < verticalCenter + 0.5 * squareDimensions.height + ) { + // line "e" + const columnIndex = Math.floor((clickCenterX - horizontalCenter) / squareDimensions.width + 4.5); + if (columnIndex < 0 || columnIndex > 8) return undefined; + return getCoordinates(columnIndex, 4, orientation); + } + if ( + clickCenterY > verticalCenter + 0.5 * squareDimensions.height && + clickCenterY < verticalCenter + 1.5 * squareDimensions.height + ) { + // line "d" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 4 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 8) return undefined; + return getCoordinates(columnIndex, 3, orientation); + } + if ( + clickCenterY > verticalCenter + 1.5 * squareDimensions.height && + clickCenterY < verticalCenter + 2.5 * squareDimensions.height + ) { + // line "c" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 3.5 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 6) return undefined; + return getCoordinates(columnIndex, 2, orientation); + } + if ( + clickCenterY > verticalCenter + 2.5 * squareDimensions.height && + clickCenterY < verticalCenter + 3.5 * squareDimensions.height + ) { + // line "b" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 3 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 5) return undefined; + return getCoordinates(columnIndex, 1, orientation); + } + if ( + clickCenterY > verticalCenter + 3.5 * squareDimensions.height && + clickCenterY < verticalCenter + 4.5 * squareDimensions.height + ) { + // line "a" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 2.5 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 4) return undefined; + return getCoordinates(columnIndex, 0, orientation); + } + if ( + clickCenterY < verticalCenter - 0.5 * squareDimensions.height && + clickCenterY > verticalCenter - 1.5 * squareDimensions.height + ) { + // line "f" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 4 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 8) return undefined; + return getCoordinates(columnIndex + 1, 5, orientation); + } + if ( + clickCenterY < verticalCenter - 1.5 * squareDimensions.height && + clickCenterY > verticalCenter - 2.5 * squareDimensions.height + ) { + // line "g" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 3.5 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 8) return undefined; + return getCoordinates(columnIndex + 2, 6, orientation); + } + if ( + clickCenterY < verticalCenter - 2.5 * squareDimensions.height && + clickCenterY > verticalCenter - 3.5 * squareDimensions.height + ) { + // line "h" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 3 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 8) return undefined; + return getCoordinates(columnIndex + 3, 7, orientation); + } + if ( + clickCenterY < verticalCenter - 3.5 * squareDimensions.height && + clickCenterY > verticalCenter - 4.5 * squareDimensions.height + ) { + // line "i" + const columnIndex = Math.floor( + (clickCenterX - (horizontalCenter - 2.5 * squareDimensions.width)) / squareDimensions.width, + ); + if (columnIndex < 0 || columnIndex > 8) return undefined; + return getCoordinates(columnIndex + 4, 8, orientation); + } + + return undefined; +}; diff --git a/src/variants/abalone/config.ts b/src/variants/abalone/config.ts new file mode 100644 index 0000000..cc3d91c --- /dev/null +++ b/src/variants/abalone/config.ts @@ -0,0 +1,25 @@ +import type { HeadlessState } from '../../state'; +import type * as cg from '../../types'; + +import { baseMove } from './engine'; +import { render } from './render'; +import { pos2px } from './svg'; +import { key2pos, posToTranslateAbs2 as posToTranslateAbs2Original, posToTranslateRel } from './util'; + +export const configure = (state: HeadlessState): void => { + // HOF + state.baseMove = baseMove; + state.render = render; + state.posToTranslateRelative = posToTranslateRel; + state.posToTranslateAbsolute = posToTranslateAbsBridge; + state.pos2px = pos2px; + state.key2pos = key2pos; + + // these below could just have been overriden by a config object + state.animation.enabled = false; +}; + +const posToTranslateAbsBridge = + (bounds: ClientRect, _bt: cg.BoardDimensions, _variant: cg.Variant) => + (pos: cg.Pos, orientation: 'p1' | 'p2' | 'left' | 'right' | 'p1vflip') => + posToTranslateAbs2Original()(bounds, pos, orientation); diff --git a/src/variants/abalone/directions.ts b/src/variants/abalone/directions.ts new file mode 100644 index 0000000..59ec517 --- /dev/null +++ b/src/variants/abalone/directions.ts @@ -0,0 +1,156 @@ +import type * as cg from '../../types'; + +import type { DirectionString } from './types'; +import { isValidKey } from './util'; + +export enum DiagonalDirectionString { + UpLeft = 'NW', + UpRight = 'NE', + DownRight = 'SE', + DownLeft = 'SW', +} +export enum HorizontalDirectionString { + Left = 'W', + Right = 'E', +} + +export const inverseDirection = (direction: DirectionString): DirectionString => { + switch (direction) { + case DiagonalDirectionString.UpLeft: + return DiagonalDirectionString.DownRight; + case DiagonalDirectionString.UpRight: + return DiagonalDirectionString.DownLeft; + case DiagonalDirectionString.DownRight: + return DiagonalDirectionString.UpLeft; + case DiagonalDirectionString.DownLeft: + return DiagonalDirectionString.UpRight; + case HorizontalDirectionString.Left: + return HorizontalDirectionString.Right; + case HorizontalDirectionString.Right: + return HorizontalDirectionString.Left; + } +}; + +export const move = (key: cg.Key, direction: DirectionString): cg.Key | undefined => { + const transformedKey = directionMappings[direction](key); + if (isValidKey(transformedKey)) return transformedKey; + return undefined; +}; + +export const getDirectionString = (orig: cg.Key, dest: cg.Key): DirectionString | undefined => { + const fileDiff = orig[0].charCodeAt(0) - dest[0].charCodeAt(0); + const rankDiff = parseInt(orig[1], 10) - parseInt(dest[1], 10); + + if (fileDiff !== 0 && rankDiff === 0) { + return fileDiff > 0 ? HorizontalDirectionString.Left : HorizontalDirectionString.Right; + } else if (fileDiff === 0 && rankDiff !== 0) { + return rankDiff > 0 ? DiagonalDirectionString.DownRight : DiagonalDirectionString.UpLeft; + } else if (fileDiff !== 0 && rankDiff !== 0) { + if (fileDiff > 0 && rankDiff > 0) { + return DiagonalDirectionString.DownLeft; + } else if (fileDiff < 0 && rankDiff < 0) { + return DiagonalDirectionString.UpRight; + } else if (fileDiff < 0 && rankDiff > 0) { + return DiagonalDirectionString.DownRight; + } else { + return DiagonalDirectionString.UpLeft; + } + } + return undefined; +}; + +export const isMoveInLine = (orig: cg.Key, dest: cg.Key, directionString: DirectionString): boolean => { + const pathToEdge = traverseUntil( + orig, + (square: cg.Key) => move(square, directionString) == undefined, + directionString, + ); + return pathToEdge.includes(dest); +}; + +export const candidateLineDirs = (origToDestDirection: DiagonalDirectionString): DirectionString[] => { + const directionMap: { [key in DiagonalDirectionString]: DirectionString[] } = { + [DiagonalDirectionString.UpLeft]: [HorizontalDirectionString.Left, DiagonalDirectionString.UpLeft], + [DiagonalDirectionString.UpRight]: [ + DiagonalDirectionString.UpLeft, + DiagonalDirectionString.UpRight, + HorizontalDirectionString.Right, + ], + [DiagonalDirectionString.DownRight]: [HorizontalDirectionString.Right, DiagonalDirectionString.DownRight], + [DiagonalDirectionString.DownLeft]: [ + DiagonalDirectionString.DownRight, + DiagonalDirectionString.DownLeft, + HorizontalDirectionString.Left, + ], + }; + return directionMap[origToDestDirection]; +}; + +export const deducePotentialSideDirs = ( + origToDestDirection: DiagonalDirectionString, + lineDirection: DirectionString, +): DirectionString[] => { + switch (origToDestDirection) { + case DiagonalDirectionString.DownLeft: + switch (lineDirection) { + case DiagonalDirectionString.DownLeft: + return [HorizontalDirectionString.Left, DiagonalDirectionString.DownRight]; + case DiagonalDirectionString.DownRight: + return [DiagonalDirectionString.DownLeft]; + case HorizontalDirectionString.Left: + return [DiagonalDirectionString.DownLeft]; + default: + return []; + } + case DiagonalDirectionString.UpRight: + switch (lineDirection) { + case DiagonalDirectionString.UpLeft: + return [DiagonalDirectionString.UpRight]; + case DiagonalDirectionString.UpRight: + return [HorizontalDirectionString.Right, DiagonalDirectionString.UpLeft]; + case HorizontalDirectionString.Right: + return [DiagonalDirectionString.UpRight]; + default: + return []; + } + case DiagonalDirectionString.UpLeft: + switch (lineDirection) { + case DiagonalDirectionString.UpLeft: + return [HorizontalDirectionString.Left]; + case HorizontalDirectionString.Left: + return [DiagonalDirectionString.UpLeft]; + default: + return []; + } + case DiagonalDirectionString.DownRight: + switch (lineDirection) { + case DiagonalDirectionString.DownRight: + return [HorizontalDirectionString.Right]; + case HorizontalDirectionString.Right: + return [DiagonalDirectionString.DownRight]; + default: + return []; + } + default: + return []; + } +}; + +const traverseUntil = (pos: cg.Key, stop: (pos: cg.Key) => boolean, direction: DirectionString): cg.Key[] => { + const nextPos = move(pos, direction); + if (nextPos) { + const rest = stop(nextPos) ? [] : traverseUntil(nextPos, stop, direction); + return [nextPos, ...rest]; + } else { + return []; + } +}; + +const directionMappings: { [key in DirectionString]: (key: cg.Key) => cg.Key } = { + NW: (key: cg.Key) => (String.fromCharCode(key[0].charCodeAt(0)) + (parseInt(key[1]) + 1).toString()) as cg.Key, + NE: (key: cg.Key) => (String.fromCharCode(key[0].charCodeAt(0) + 1) + (parseInt(key[1]) + 1).toString()) as cg.Key, + SW: (key: cg.Key) => (String.fromCharCode(key[0].charCodeAt(0) - 1) + (parseInt(key[1]) - 1).toString()) as cg.Key, + SE: (key: cg.Key) => (String.fromCharCode(key[0].charCodeAt(0)) + (parseInt(key[1]) - 1).toString()) as cg.Key, + W: (key: cg.Key) => (String.fromCharCode(key[0].charCodeAt(0) - 1) + key[1]) as cg.Key, + E: (key: cg.Key) => (String.fromCharCode(key[0].charCodeAt(0) + 1) + key[1]) as cg.Key, +}; diff --git a/src/variants/abalone/drag.ts b/src/variants/abalone/drag.ts new file mode 100644 index 0000000..d5bd632 --- /dev/null +++ b/src/variants/abalone/drag.ts @@ -0,0 +1,40 @@ +import { cancel } from '../../drag'; +import { State } from '../../state'; +import { distanceSq, posToTranslateAbs, samePiece } from '../../util'; + +import { translateAbs } from './util'; + +// @TODO: remove parts unrelated with Abalone if there are. I see a 'chess' line 32. +export function processDrag(s: State): void { + requestAnimationFrame(() => { + const cur = s.draggable.current; + if (!cur) return; + // cancel animations while dragging + if (s.animation.current?.plan.anims.has(cur.orig)) s.animation.current = undefined; + // if moving piece is gone, cancel + const origPiece = s.pieces.get(cur.orig); + if (!origPiece || !samePiece(origPiece, cur.piece)) cancel(s); + else { + if (!cur.started && distanceSq(cur.epos, cur.rel) >= Math.pow(s.draggable.distance, 2)) cur.started = true; + if (cur.started) { + // support lazy elements + if (typeof cur.element === 'function') { + const found = cur.element(); + if (!found) return; + found.cgDragging = true; + found.classList.add('dragging'); + cur.element = found; + } + + cur.pos = [cur.epos[0] - cur.rel[0], cur.epos[1] - cur.rel[1]]; + + // move piece + const translation = posToTranslateAbs(s.dom.bounds(), s.dimensions, 'chess')(cur.origPos, s.orientation); // until translateAbs becomes a HOF, it has to remain invoked like this. + translation[0] += cur.pos[0] + cur.dec[0]; + translation[1] += cur.pos[1] + cur.dec[1]; + translateAbs(cur.element, translation); // "working" WIP: have to use HOF + } + } + processDrag(s); + }); +} diff --git a/src/variants/abalone/engine.ts b/src/variants/abalone/engine.ts new file mode 100644 index 0000000..37d59d8 --- /dev/null +++ b/src/variants/abalone/engine.ts @@ -0,0 +1,210 @@ +import { setPieces, unselect } from '../../board'; +import { HeadlessState } from '../../state'; +import { callUserFunction } from '../../util'; +import type * as cg from '../../types'; + +import { + candidateLineDirs, + deducePotentialSideDirs, + move, + getDirectionString, + isMoveInLine, + DiagonalDirectionString, + inverseDirection, +} from './directions'; +import type { MoveImpact, MoveVector } from './types'; + +// compute the impact of a move on the board before it is made +export const computeMoveImpact = (pieces: cg.Pieces, orig: cg.Key, dest: cg.Key): MoveImpact | undefined => { + const directionString = getDirectionString(orig, dest); + if (!directionString) return undefined; + const isAMoveInLine = isMoveInLine(orig, dest, directionString); + const diff: cg.PiecesDiff = new Map(pieces); + + if (isAMoveInLine) { + diff.set(dest, pieces.get(orig)); + diff.set(orig, undefined); + if (!pieces.get(dest)) { + // line move + return { + diff, + capture: false, + moveVector: { + directionString, + landingSquares: [dest], + }, + }; + } + // push move + const landingSquare1 = move(dest, directionString); + if (landingSquare1 === undefined) + // xxo\ xxxo\ + return { + diff, + capture: true, + moveVector: { + directionString, + landingSquares: [dest], + }, + }; + if (!pieces.get(landingSquare1)) { + // xxo. xxxo. + diff.set(landingSquare1, pieces.get(dest)); + return { + diff, + capture: false, + moveVector: { + directionString, + landingSquares: [dest, landingSquare1], + }, + }; + } + + const landingSquare2 = move(landingSquare1, directionString); + if (landingSquare2 === undefined) + // xxxoo\ + return { + diff, + capture: true, + moveVector: { + directionString, + landingSquares: [dest, landingSquare1], + }, + }; + if (!pieces.get(landingSquare2)) { + // xxxoo. + diff.set(landingSquare2, pieces.get(dest)); + return { + diff, + capture: false, + moveVector: { + directionString, + landingSquares: [dest, landingSquare2], + }, + }; + } + } + + // side move + for (const lineDir of candidateLineDirs(directionString as DiagonalDirectionString)) { + const sideDirs = deducePotentialSideDirs(directionString as DiagonalDirectionString, lineDir); + const secondPos = move(orig, lineDir); + if (secondPos === undefined) continue; + for (const sideDir of sideDirs) { + const side2ndPos = move(secondPos, sideDir); + if (side2ndPos) { + const side1stPos = move(orig, sideDir); + if (side1stPos === undefined) continue; + if (side1stPos && pieces.get(secondPos)) { + if (side2ndPos === dest) { + diff.set(side1stPos, pieces.get(orig)); + diff.set(orig, undefined); + diff.set(dest, pieces.get(secondPos)); + diff.set(secondPos, undefined); + return { + diff, + capture: false, + moveVector: { + directionString: sideDir, + landingSquares: [side1stPos, dest], + }, + }; + } else { + // 3 marbles are moving + const thirdPos = move(secondPos, lineDir); + if (thirdPos === undefined) continue; + const side3rdPos = move(thirdPos, sideDir); + if (side3rdPos === undefined) continue; + if (pieces.get(thirdPos) && side3rdPos === dest) { + diff.set(side1stPos, pieces.get(orig)); + diff.set(orig, undefined); + diff.set(side2ndPos, pieces.get(secondPos)); + diff.set(secondPos, undefined); + diff.set(side3rdPos, pieces.get(thirdPos)); + diff.set(thirdPos, undefined); + return { + diff, + capture: false, + moveVector: { + directionString: sideDir, + landingSquares: [side1stPos, side2ndPos, side3rdPos], + }, + }; + } + } + } + } + } + } + + return undefined; +}; + +// compute a move vector after a move has been made +export const computeMoveVectorPostMove = (pieces: cg.Pieces, orig: cg.Key, dest: cg.Key): MoveVector | undefined => { + const directionString = getDirectionString(dest, orig); + if (!directionString) return undefined; + const isAMoveInLine = isMoveInLine(dest, orig, directionString); + const inverseDirectionString = inverseDirection(directionString); + + if (isAMoveInLine) { + return { + directionString: inverseDirectionString, + landingSquares: [dest], + }; + } + + // side move + for (const lineDir of candidateLineDirs(directionString as DiagonalDirectionString)) { + const sideDirs = deducePotentialSideDirs(directionString as DiagonalDirectionString, lineDir); + const secondPos = move(dest, lineDir); + if (secondPos === undefined) continue; + for (const sideDir of sideDirs) { + const side2ndPos = move(secondPos, sideDir); + if (side2ndPos) { + const side1stPos = move(dest, sideDir); + if (side1stPos === undefined) continue; + if (side1stPos && pieces.get(secondPos)) { + if (side2ndPos === orig) { + return { + directionString: inverseDirection(sideDir), + landingSquares: [secondPos, dest], + }; + } else { + // 3 marbles are moving + const thirdPos = move(secondPos, lineDir); + if (thirdPos === undefined) continue; + const side3rdPos = move(thirdPos, sideDir); + if (side3rdPos === undefined) continue; + if (pieces.get(thirdPos) && side3rdPos === orig) { + return { + directionString: inverseDirection(sideDir), + landingSquares: [secondPos, thirdPos, dest], + }; + } + } + } + } + } + } + + return undefined; +}; + +export function baseMove(state: HeadlessState, orig: cg.Key, dest: cg.Key): cg.Piece | boolean { + // Note: after you moved, you also receive the move from the API. But the piece is already gone, since you moved. + if (!state.pieces.get(orig)) return false; + + const moveImpact = computeMoveImpact(state.pieces, orig, dest); + if (!moveImpact) return false; + + if (dest === state.selected) unselect(state); + callUserFunction(state.events.move, orig, dest, moveImpact.capture); + + setPieces(state, moveImpact.diff); + + state.lastMove = [orig, dest]; + state.check = undefined; + callUserFunction(state.events.change); + return moveImpact.capture || true; +} diff --git a/src/variants/abalone/fen.ts b/src/variants/abalone/fen.ts new file mode 100644 index 0000000..c337a7a --- /dev/null +++ b/src/variants/abalone/fen.ts @@ -0,0 +1,36 @@ +import { roles } from '../../fen'; +import { pos2key } from '../../util'; +import type * as cg from '../../types'; + +export const read = (fen: cg.FEN, dimensions: cg.BoardDimensions): cg.Pieces => { + const pieces: cg.Pieces = new Map(); + + let row: number = dimensions.height; + const padding = [0, 0, 0, 0, 0, 0, 1, 2, 3, 4]; + let file = padding[row]; + + for (let i = 0; i < fen.length; i++) { + const c = fen[i]; + if (c === ' ') break; + else if (c == '/') { + file = padding[row - 1]; + --row; + } else { + const step = parseInt(c, 10); + if (step > 0) { + file += step; + } else { + file++; + const letter = c.toLowerCase(); + const playerIndex = (c === letter ? 'p2' : 'p1') as cg.PlayerIndex; + const piece = { + role: roles(letter), + playerIndex: playerIndex, + } as cg.Piece; + pieces.set(pos2key([file, row]), piece); // @TODO VFR: check all cases are correctly handled to prevent a js error in console + } + } + } + + return pieces; +}; diff --git a/src/variants/abalone/premove.ts b/src/variants/abalone/premove.ts new file mode 100644 index 0000000..ebe4b96 --- /dev/null +++ b/src/variants/abalone/premove.ts @@ -0,0 +1,13 @@ +import type * as cg from '../../types'; +import { Mobility } from '../../premove'; + +import { pos2key } from './util'; + +// @VFR: TODO: update this later on for correct premoves : this code is just an ugly copy paste +export const marble = (pieces: cg.Pieces, playerIndex: cg.PlayerIndex): Mobility => { + return (x1, y1 /*, x2, y2*/) => { + const pos = pos2key([x1, y1 + (playerIndex === 'p1' ? 1 : -1)]) as cg.Key; + if (pieces.has(pos) && pieces.get(pos)?.playerIndex === playerIndex) return false; + return false; + }; +}; diff --git a/src/variants/abalone/render.ts b/src/variants/abalone/render.ts new file mode 100644 index 0000000..3e2ae17 --- /dev/null +++ b/src/variants/abalone/render.ts @@ -0,0 +1,280 @@ +import type * as cg from '../../types'; +import type { PieceName, SquareClasses } from '../../render'; +import { p1Pov } from '../../board'; +import { State } from '../../state'; +import { createEl } from '../../util'; +import { AnimCurrent, AnimFadings, AnimVector, AnimVectors } from '../../anim'; +import { DragCurrent } from '../../drag'; +import { appendValue, isPieceNode, isSquareNode, posZIndex, removeNodes } from '../../render'; + +import { translateAbs, translateRel } from './util'; +import { computeMoveVectorPostMove } from './engine'; + +// @TODO: remove parts unrelated to Abalone +export const render = (s: State): void => { + const orientation = s.orientation, + asP1: boolean = p1Pov(s), + posToTranslate = s.dom.relative + ? s.posToTranslateRelative + : s.posToTranslateAbsolute(s.dom.bounds(), s.dimensions, s.variant), + translate = s.dom.relative ? translateRel : translateAbs, + boardEl: HTMLElement = s.dom.elements.board, + pieces: cg.Pieces = s.pieces, + curAnim: AnimCurrent | undefined = s.animation.current, + anims: AnimVectors = curAnim ? curAnim.plan.anims : new Map(), + fadings: AnimFadings = curAnim ? curAnim.plan.fadings : new Map(), + curDrag: DragCurrent | undefined = s.draggable.current, + squares: SquareClasses = computeSquareClasses(s), + samePieces: Set = new Set(), + sameSquares: Set = new Set(), + movedPieces: Map = new Map(), + movedSquares: Map = new Map(); // by class name + + let k: cg.Key, + el: cg.PieceNode | cg.SquareNode | undefined, + pieceAtKey: cg.Piece | undefined, + elPieceName: PieceName, + anim: AnimVector | undefined, + fading: cg.Piece | undefined, + pMvdset: cg.PieceNode[] | undefined, + pMvd: cg.PieceNode | undefined, + sMvdset: cg.SquareNode[] | undefined, + sMvd: cg.SquareNode | undefined; + + // walk over all board dom elements, apply animations and flag moved pieces + el = boardEl.firstChild as cg.PieceNode | cg.SquareNode | undefined; + + while (el) { + k = el.cgKey; + if (isPieceNode(el)) { + pieceAtKey = pieces.get(k); + anim = anims.get(k); + fading = fadings.get(k); + elPieceName = el.cgPiece; + // if piece not being dragged anymore, remove dragging style + if (el.cgDragging && (!curDrag || curDrag.orig !== k)) { + el.classList.remove('dragging'); + translate(el, posToTranslate(s.key2pos(k), orientation, s.dimensions, s.variant)); + el.cgDragging = false; + } + // remove fading class if it still remains + if (!fading && el.cgFading) { + el.cgFading = false; + el.classList.remove('fading'); + } + // there is now a piece at this dom key + if (pieceAtKey) { + // continue animation if already animating and same piece + // (otherwise it could animate a captured piece) + if (anim && el.cgAnimating && elPieceName === pieceNameOf(pieceAtKey, s.myPlayerIndex, s.orientation)) { + const pos = s.key2pos(k); + pos[0] += anim[2]; + pos[1] += anim[3]; + el.classList.add('anim'); + translate(el, posToTranslate(s.key2pos(k), orientation, s.dimensions, s.variant)); + } else if (el.cgAnimating) { + el.cgAnimating = false; + el.classList.remove('anim'); + translate(el, posToTranslate(s.key2pos(k), orientation, s.dimensions, s.variant)); + if (s.addPieceZIndex) el.style.zIndex = posZIndex(s.key2pos(k), orientation, asP1, s.dimensions); + } + // same piece: flag as same + if (elPieceName === pieceNameOf(pieceAtKey, s.myPlayerIndex, s.orientation) && (!fading || !el.cgFading)) { + samePieces.add(k); + } + // different piece: flag as moved unless it is a fading piece + else { + if (fading && elPieceName === pieceNameOf(fading, s.myPlayerIndex, s.orientation)) { + el.classList.add('fading'); + el.cgFading = true; + } else { + appendValue(movedPieces, elPieceName, el); + } + } + } + // no piece: flag as moved + else { + appendValue(movedPieces, elPieceName, el); + } + } else if (isSquareNode(el)) { + const cn = el.className; + if (squares.get(k) === cn) sameSquares.add(k); + else if (movedSquares.has(cn)) appendValue(movedSquares, cn, el); + else movedSquares.set(cn, [el]); + } + el = el.nextSibling as cg.PieceNode | cg.SquareNode | undefined; + } + + // walk over all squares in current set, apply dom changes to moved squares + // or append new squares + for (const [sk, className] of squares) { + // if (!sameSquares.has(sk)) { + sMvdset = movedSquares.get(className); + sMvd = sMvdset && sMvdset.pop(); + const translation = posToTranslate(s.key2pos(sk), orientation, s.dimensions, s.variant); + if (sMvd) { + sMvd.cgKey = sk; + translate(sMvd, translation); + } else { + const squareNode = createEl('square', className) as cg.SquareNode; + squareNode.cgKey = sk; + translate(squareNode, translation); + boardEl.insertBefore(squareNode, boardEl.firstChild); + } + // } + } + + // walk over all pieces in current set, apply dom changes to moved pieces + // or append new pieces + for (const [k, p] of pieces) { + anim = anims.get(k); + if (!samePieces.has(k)) { + pMvdset = movedPieces.get(pieceNameOf(p, s.myPlayerIndex, s.orientation)); + pMvd = pMvdset && pMvdset.pop(); + // a same piece was moved + if (pMvd) { + // apply dom changes + pMvd.cgKey = k; + if (pMvd.cgFading) { + pMvd.classList.remove('fading'); + pMvd.cgFading = false; + } + const pos = s.key2pos(k); + if (s.addPieceZIndex) pMvd.style.zIndex = posZIndex(pos, orientation, asP1, s.dimensions); + if (anim) { + pMvd.cgAnimating = true; + pMvd.classList.add('anim'); + pos[0] += anim[2]; + pos[1] += anim[3]; + } + translate(pMvd, posToTranslate(pos, orientation, s.dimensions, s.variant)); + } + // no piece in moved obj: insert the new piece + // assumes the new piece is not being dragged + else { + const pieceName = pieceNameOf(p, s.myPlayerIndex, s.orientation), + pieceNode = createEl('piece', pieceName) as cg.PieceNode, + pos = s.key2pos(k); // used here to compute position + + pieceNode.cgPiece = pieceName; + pieceNode.cgKey = k; + if (anim) { + pieceNode.cgAnimating = true; + pos[0] += anim[2]; + pos[1] += anim[3]; + } + translate(pieceNode, posToTranslate(pos, orientation, s.dimensions, s.variant)); + + if (s.addPieceZIndex) pieceNode.style.zIndex = posZIndex(pos, orientation, asP1, s.dimensions); + + boardEl.appendChild(pieceNode); + } + } + } + + // remove any element that remains in the moved sets + for (const nodes of movedPieces.values()) removeNodes(s, nodes); + for (const nodes of movedSquares.values()) removeNodes(s, nodes); // do not compute movedSquares ??? +}; + +function pieceNameOf(piece: cg.Piece, myPlayerIndex: cg.PlayerIndex, orientation: cg.Orientation): string { + const side = + (piece.playerIndex === myPlayerIndex && orientation === myPlayerIndex) || + (piece.playerIndex !== myPlayerIndex && orientation !== myPlayerIndex) + ? 'ally' + : 'enemy'; + + return `${piece.playerIndex} ${piece.role} ${side}`; +} + +function addSquare(squares: SquareClasses, key: cg.Key, klass: string): void { + const classes = squares.get(key); + if (classes) squares.set(key, `${classes} ${klass}`); + else squares.set(key, klass); +} + +// @TODO: clean this up to remove any notion non related to Abalone +export function computeSquareClasses(s: State): SquareClasses { + const squares: SquareClasses = new Map(); + + if (s.lastMove && s.lastMove.length === 2) { + const moveImpact = computeMoveVectorPostMove(s.pieces, s.lastMove[0], s.lastMove[1]); + const player = s.turnPlayerIndex; + moveImpact?.landingSquares.forEach(coordinates => { + addSquare(squares, coordinates, `last-move to ${player}${moveImpact.directionString}`); + }); + } + + if (s.lastMove && s.highlight.lastMove) { + // if (s.variants.abalone.lastMove) { + // for (const k of s.variants.abalone.lastMove.piecesMoving) { + // addSquare(squares, k, 'last-move from ww'); + // } + // } + // let first = true; + // for (const k of s.lastMove) { + // if (k !== 'a0') { + // if (first) { + // console.log(s.variants); + // addSquare(squares, k, 'last-move from ww'); // + variantSpecificHighlightClass(s.variant, k, s.orientation)); + // first = false; + // } else { + // addSquare(squares, k, 'last-move to ww'); // + variantSpecificHighlightClass(s.variant, k, s.orientation)); + // } + // } else { + // first = false; + // } + // } + } + if (s.selectOnly) { + for (const key of s.selectedPieces.keys()) { + addSquare(squares, key, 'selected'); + } + } + if (s.selected) { + addSquare(squares, s.selected, 'selected'); + if (s.movable.showDests) { + const dests = s.movable.dests?.get(s.selected); + if (dests) + for (const k of dests) { + addSquare(squares, k, 'move-dest' + (s.pieces.has(k) ? ' oc' : '')); + } + const pDests = s.premovable.dests; + if (pDests) + for (const k of pDests) { + addSquare(squares, k, 'premove-dest' + (s.pieces.has(k) ? ' oc' : '')); + } + } + } else if (s.dropmode.active || s.draggable.current?.orig === 'a0') { + const piece = s.dropmode.active ? s.dropmode.piece : s.draggable.current?.piece; + + if (piece) { + // TODO: there was a function called isPredroppable that was used in drag.ts or drop.ts or both. + // Maybe use the same here to decide what to render instead of potentially making it possible both + // kinds of highlighting to happen if something was not cleared up in the state. + // In other place (pocket.ts) this condition is used ot decide similar question: ctrl.myplayerIndex === ctrl.turnPlayerIndex + if (s.dropmode.showDropDests) { + const dests = s.dropmode.dropDests?.get(piece.role); + if (dests) + for (const k of dests) { + addSquare(squares, k, 'move-dest'); + } + } + if (s.predroppable.showDropDests) { + const pDests = s.predroppable.dropDests; + if (pDests) + for (const k of pDests) { + addSquare(squares, k, 'premove-dest' + (s.pieces.has(k) ? ' oc' : '')); + } + } + } + } + const premove = s.premovable.current; + if (premove) for (const k of premove) addSquare(squares, k, 'current-premove'); + else if (s.predroppable.current) addSquare(squares, s.predroppable.current.key, 'current-premove'); + + const o = s.exploding; + if (o) for (const k of o.keys) addSquare(squares, k, 'exploding' + o.stage); + + return squares; +} diff --git a/src/variants/abalone/svg.ts b/src/variants/abalone/svg.ts new file mode 100644 index 0000000..81d544d --- /dev/null +++ b/src/variants/abalone/svg.ts @@ -0,0 +1,33 @@ +import type * as cg from '../../types'; + +import { abaloneFiles, getSquareDimensions, isValidKey } from './util'; + +// pos2px is used to convert a position from the grid (board coordinates) to a position in pixels +export const pos2px = (pos: cg.Pos, bounds: ClientRect, _bd: cg.BoardDimensions): cg.NumberPair => { + const height = bounds.height; + const width = bounds.width; + const squareDimensions = getSquareDimensions(bounds); + + const computedHeight = height * 0.5 + squareDimensions.height * (5 - pos[1]); + let computedWidth = width * 0.5 + squareDimensions.width * (pos[0] - 5); + + if (!isValidKey((abaloneFiles[pos[0] - 1] + pos[1].toString()) as cg.Key)) { + return [10, 10]; + } + + if (pos[1] > 5) { + computedWidth = + width * 0.4546 + squareDimensions.width * (pos[0] - 5) - 0.5 * (pos[1] - 5) * squareDimensions.width; + } else if (pos[1] < 5) { + computedWidth = + width * 0.4546 - squareDimensions.width * (5 - pos[0]) + 0.5 * (5 - pos[1]) * squareDimensions.width; + } else { + if (pos[0] >= 5) { + computedWidth = width * 0.4546 + squareDimensions.width * (pos[0] - 5); + } else if (pos[0] < 5) { + computedWidth = width * 0.4546 - squareDimensions.width * (5 - pos[0]); + } + } + + return [computedWidth + width / 22, computedHeight]; +}; diff --git a/src/variants/abalone/types.ts b/src/variants/abalone/types.ts new file mode 100644 index 0000000..94a0729 --- /dev/null +++ b/src/variants/abalone/types.ts @@ -0,0 +1,24 @@ +import type * as cg from '../../types'; + +import { DiagonalDirectionString, HorizontalDirectionString } from './directions'; + +export type DirectionString = DiagonalDirectionString | HorizontalDirectionString; + +export type MoveImpact = { + diff: cg.PiecesDiff; // diff applied to pieces after the move + capture: boolean; + moveVector: MoveVector; +}; + +// allow describing a move in terms of direction applied to some pieces +export type MoveVector = { + directionString: DirectionString; + landingSquares: cg.Key[]; +}; + +export type SquareDimensions = { + width: number; + height: number; +}; + +export type TranslateBase = (pos: cg.Pos, bounds: ClientRect) => cg.NumberPair; diff --git a/src/variants/abalone/util.ts b/src/variants/abalone/util.ts new file mode 100644 index 0000000..0840085 --- /dev/null +++ b/src/variants/abalone/util.ts @@ -0,0 +1,261 @@ +import type * as cg from '../../types'; +import { SquareDimensions, TranslateBase } from './types'; + +export const abaloneFiles = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i'] as const; + +const abaloneRanks = ['1', '2', '3', '4', '5', '6', '7', '8', '9'] as const; + +export const pos2key = (pos: cg.Pos): cg.Key => { + // let posx = pos[0]; + // if (pos[1] == 0) { + // posx += 2; + // } + // if (pos[1] == 1) { + // posx = pos[0] - 2; + // } + // if (pos[1] == 2) { + // posx = pos[0] - 2; // -2.5 + // } + // if (pos[1] == 3) { + // posx = pos[0] - 1; + // } + // if (pos[1] == 4) { + // posx = pos[0] - 1; // -1.5 + // } + // if (pos[1] == 5) { + // posx = pos[0]; // - 1 + // } + // if (pos[1] == 6) { + // posx = pos[0]; // -0.5 + // } + // if (pos[1] == 7) { + // posx = pos[0]; // 0 + // } + // if (pos[1] == 8) { + // posx = pos[0]; // 0.5 + // } + // if (pos[1] == 9) { + // posx = pos[0] + 1; + // } + + const posx = pos[0]; + let posy = pos[1]; + + const key = (abaloneFiles[posx] + abaloneRanks[posy]) as cg.Key; + return key; +}; + +export const key2pos = (k: cg.Key): cg.Pos => { + const rank = parseInt(k.slice(1)); + const file = k.charCodeAt(0) - 96; + + return [file, rank] as cg.Pos; +}; + +const computeShift = (k: cg.Key): cg.Pos => { + const rank = parseInt(k.slice(1)); + const file = k.charCodeAt(0) - 96; + const xScale = 100; + const yScale = 100; + const bt = { width: 9, height: 9 }; + + if (rank == 1) { + // bottom left + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank - 1) * yScale]; + } + if (rank == 2) { + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank - 1) * yScale]; + } + if (rank == 3) { + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank - 1) * yScale]; + } + if (rank == 4) { + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank - 1) * yScale]; + } + if (rank == 5) { + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank) * yScale]; + } + if (rank == 6) { + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank + 1) * yScale]; + } + if (rank == 7) { + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank + 1) * yScale]; + } + if (rank == 8) { + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank + 1) * yScale]; + } + return [(file + shift[rank - 1] - 1) * xScale, (bt.height - rank + 1) * yScale * 10]; +}; + +// from a key, determine a position +export const key2posAlt = (k: cg.Key): cg.Pos => { + return computeShift(k); +}; + +// shift is used by analysis page and miniboards +const shift = [2, 1.5, 1, 0.5, 0, -0.5, -1, -1.5, -2]; + +// translateBase defines where the translation of a piece should be placed on the board. +// It is used to render the piece at the correct place. +const createTranslateBase = (): Record => { + const squareWidth = 102.5; + const squareHeight = 88; + const bottomLeft = [295, 854]; + const shift = [0, 0.5, 1, 1.5, 2, 2.5, 3, 3.5, 4]; + + return { + p1: (pos: cg.Pos, _xScale: number, _yScale: number, _bt: cg.BoardDimensions) => { + if (pos[1] < 6) { + return [ + bottomLeft[0] + squareWidth * pos[0] - (shift[pos[1] - 1] + 1) * squareWidth, + bottomLeft[1] - (pos[1] - 1) * squareHeight, + ]; + } + return [ + bottomLeft[0] + squareWidth * pos[0] - (shift[pos[1] - 1] + 1) * squareWidth, + bottomLeft[1] - (pos[1] - 1) * squareHeight, + ]; + }, + p2: (pos: cg.Pos, _xScale: number, _yScale: number, _bt: cg.BoardDimensions) => { + if (pos[1] < 6) { + return [ + bottomLeft[0] + squareWidth * pos[0] - (shift[pos[1] - 1] + 1) * squareWidth, + bottomLeft[1] - (pos[1] - 1) * squareHeight, + ]; + } + return [ + bottomLeft[0] + squareWidth * pos[0] - (shift[pos[1] - 1] + 1) * squareWidth, + bottomLeft[1] - (pos[1] - 1) * squareHeight, + ]; + }, + right: (pos: cg.Pos, xScale: number, yScale: number, _) => [(pos[1] - 1) * xScale, (pos[0] - 1) * yScale], + left: (pos: cg.Pos, xScale: number, yScale: number, bt: cg.BoardDimensions) => [ + (bt.width - pos[0]) * xScale, + (pos[1] - 1) * yScale, + ], + p1vflip: (pos: cg.Pos, xScale: number, yScale: number, _) => [(pos[0] - 1) * xScale, (pos[1] - 1) * yScale], + }; +}; + +const translateBase = createTranslateBase(); + +export const posToTranslateRel = ( + pos: cg.Pos, + orientation: cg.Orientation, + _bt: cg.BoardDimensions, + _v: cg.Variant, +): cg.NumberPair => { + return translateBase[orientation](pos, 100, 100, { width: 9, height: 9 }); +}; + +export const translateAbs = (el: HTMLElement, pos: cg.NumberPair): void => { + el.style.transform = `translate(${pos[0]}px,${pos[1]}px)`; +}; + +export const translateRel = (el: HTMLElement, percents: cg.NumberPair): void => { + el.style.transform = `translate(${percents[0]}%,${percents[1]}%)`; +}; + +function files(n: number) { + return abaloneFiles.slice(0, n); +} + +function ranks(n: number) { + return abaloneRanks.slice(0, n); +} + +function allKeys(bd: cg.BoardDimensions = { width: 9, height: 9 }) { + return Array.prototype.concat(...files(bd.width).map(c => ranks(bd.height).map(r => c + r))); +} + +export const allPos = (bd: cg.BoardDimensions): cg.Pos[] => allKeys(bd).map(key2posAlt); + +export const posToTranslateBase2 = (bounds: ClientRect, pos: cg.Pos, orientation: cg.Orientation): cg.NumberPair => { + return translateBase2[orientation](pos, bounds); +}; + +export const posToTranslateAbs2 = (): (( + bounds: ClientRect, + pos: cg.Pos, + orientation: cg.Orientation, +) => cg.NumberPair) => { + return (bounds, pos, orientation) => posToTranslateBase2(bounds, pos, orientation); +}; +const translateBase2: Record = { + p1: (pos: cg.Pos, bounds: ClientRect) => { + const height = bounds.height; + const width = bounds.width; + const squareDimensions = getSquareDimensions(bounds); + + const computedHeight = height * 0.4546 + squareDimensions.height * (5 - pos[1]); + let computedWidth = width * 0.4546 + squareDimensions.width * (5 - pos[0]); + + if (pos[1] > 5) { + computedWidth = + width * 0.4546 + squareDimensions.width * (pos[0] - 5) - 0.5 * (pos[1] - 5) * squareDimensions.width; + } else if (pos[1] < 5) { + computedWidth = + width * 0.4546 - squareDimensions.width * (5 - pos[0]) + 0.5 * (5 - pos[1]) * squareDimensions.width; + } else { + if (pos[0] >= 5) { + computedWidth = width * 0.4546 + squareDimensions.width * (pos[0] - 5); + } else if (pos[0] < 5) { + computedWidth = width * 0.4546 - squareDimensions.width * (5 - pos[0]); + } + } + return [computedWidth, computedHeight]; + }, + p2: (pos: cg.Pos, bounds: ClientRect) => { + const height = bounds.height; + const width = bounds.width; + const squareDimensions = getSquareDimensions(bounds); + + const computedHeight = height * 0.4546 + squareDimensions.height * (pos[1] - 5); + let computedWidth = width * 0.4546 + squareDimensions.width * (5 - pos[0]); + + if (pos[1] < 5) { + computedWidth = + width * 0.4546 + squareDimensions.width * (5 - pos[0]) - 0.5 * (5 - pos[1]) * squareDimensions.width; + } else if (pos[1] > 5) { + computedWidth = + width * 0.4546 - squareDimensions.width * (pos[0] - 5) + 0.5 * (pos[1] - 5) * squareDimensions.width; + } else { + if (pos[0] <= 5) { + computedWidth = width * 0.4546 + squareDimensions.width * (5 - pos[0]); + } else if (pos[0] > 5) { + computedWidth = width * 0.4546 - squareDimensions.width * (pos[0] - 5); + } + } + return [computedWidth, computedHeight]; + }, + right: (pos: cg.Pos, bounds: ClientRect) => [(pos[1] - 1) * bounds.x, (pos[0] - 1) * bounds.x], + left: (pos: cg.Pos, bounds: ClientRect) => [(pos[1] - 1) * bounds.x, (pos[0] - 1) * bounds.x], + p1vflip: (pos: cg.Pos, bounds: ClientRect) => [(pos[1] - 1) * bounds.x, (pos[0] - 1) * bounds.x], +}; + +export const getSquareDimensions = (bounds: ClientRect): SquareDimensions => ({ + width: bounds.width * 0.093, + height: bounds.height * 0.081, +}); + +export const getCoordinates = (x: number, y: number, orientation: cg.Orientation): cg.Key => { + const file = abaloneFiles[x] as cg.File; + const rank = abaloneRanks[y] as cg.Rank; + if (orientation === 'p1') { + return (file + rank) as cg.Key; + } + + return rotate180(file, rank) as cg.Key; +}; + +const rotate180 = (file: string, rank: string): string => { + const files = 'abcdefghi'; + const ranks = '123456789'; + const rotatedFile = files[files.length - 1 - files.indexOf(file)]; + const rotatedRank = ranks[ranks.length - 1 - ranks.indexOf(rank)]; + return rotatedFile + rotatedRank; +}; + +export const isValidKey = (key: cg.Key): boolean => { + return /^(a[1-5]|b[1-6]|c[1-7]|d[1-8]|e[1-9]|f[2-9]|g[3-9]|h[4-9]|i[5-9])$/.test(key); +}; diff --git a/src/wrap.ts b/src/wrap.ts index 61481f6..50eb905 100644 --- a/src/wrap.ts +++ b/src/wrap.ts @@ -146,6 +146,30 @@ export function renderWrap(element: HTMLElement, s: HeadlessState, relative: boo ); } } + } else if (s.variant === 'abalone') { + if (s.orientation === 'p1') { + container.appendChild(renderCoords(ranks.slice(0, 5), 'files' + orientClass + ' rank-1')); + container.appendChild(renderCoords(['6'], 'files' + orientClass + ' rank-2')); + container.appendChild(renderCoords(['7'], 'files' + orientClass + ' rank-3')); + container.appendChild(renderCoords(['8'], 'files' + orientClass + ' rank-4')); + container.appendChild(renderCoords(['9'], 'files' + orientClass + ' rank-5')); + container.appendChild(renderCoords(['e'], 'ranks' + orientClass + ' file-1')); + container.appendChild(renderCoords(['d', 'f'], 'ranks' + orientClass + ' file-2')); + container.appendChild(renderCoords(['c', 'g'], 'ranks' + orientClass + ' file-3')); + container.appendChild(renderCoords(['b', '', 'h'], 'ranks' + orientClass + ' file-4')); + container.appendChild(renderCoords(['a', '', '', '', 'i'], 'ranks' + orientClass + ' file-5')); + } else { + container.appendChild(renderCoords(ranks.slice(0, 5), 'files' + orientClass + ' rank-1')); + container.appendChild(renderCoords(['6'], 'files' + orientClass + ' rank-2')); + container.appendChild(renderCoords(['7'], 'files' + orientClass + ' rank-3')); + container.appendChild(renderCoords(['8'], 'files' + orientClass + ' rank-4')); + container.appendChild(renderCoords(['9'], 'files' + orientClass + ' rank-5')); + container.appendChild(renderCoords(['e'], 'ranks' + orientClass + ' file-1')); + container.appendChild(renderCoords(['d', 'f'], 'ranks' + orientClass + ' file-2')); + container.appendChild(renderCoords(['c', 'g'], 'ranks' + orientClass + ' file-3')); + container.appendChild(renderCoords(['b', '', 'h'], 'ranks' + orientClass + ' file-4')); + container.appendChild(renderCoords(['a', '', '', '', 'i'], 'ranks' + orientClass + ' file-5')); + } } else { container.appendChild(renderCoords(ranks19.slice(0, s.dimensions.height), 'ranks' + orientClass)); container.appendChild(renderCoords(files.slice(0, s.dimensions.width), 'files' + orientClass)); diff --git a/tsconfig.json b/tsconfig.json index aa90a88..f58087e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,20 +1,19 @@ { - "include": ["src/*.ts"], - "exclude": [], + "include": ["src"], "compilerOptions": { - "esModuleInterop": true, "declaration": true, - "outDir": "dist", - "noEmitOnError": false, - "strict": true, - "noUnusedLocals": true, - "noUnusedParameters": true, - "noImplicitThis": true, - "noImplicitReturns": true, + "esModuleInterop": true, + "lib": ["DOM", "es2017"], "module": "esnext", "moduleResolution": "node", + "noEmitOnError": true, + "noImplicitReturns": true, + "noImplicitThis": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "outDir": "dist", "resolveJsonModule": true, - "target": "es2017", - "lib": ["DOM", "es2017"] + "strict": true, + "target": "es2017" } }