├── .eslintrc ├── .github └── workflows │ ├── check.yml │ └── test.yml ├── .gitignore ├── .mocharc.json.ignore ├── .npmignore ├── CHANGELOG.md ├── CLA.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── assets ├── decl.d.ts ├── entry ├── esbuild.mjs ├── package.json ├── scripts ├── postpack └── prepack ├── src ├── action │ ├── action.ts │ ├── index.ts │ ├── selection.ts │ └── utils.ts ├── board │ ├── adjacency-space.ts │ ├── connected-space-map.ts │ ├── element-collection.ts │ ├── element.ts │ ├── fixed-grid.ts │ ├── game.ts │ ├── hex-grid.ts │ ├── index.ts │ ├── piece-grid.ts │ ├── piece.ts │ ├── single-layout.ts │ ├── space.ts │ ├── square-grid.ts │ ├── stack.ts │ └── utils.ts ├── components │ ├── d6 │ │ ├── assets │ │ │ ├── dice.ogg │ │ │ └── index.scss │ │ ├── d6.ts │ │ ├── index.ts │ │ └── useD6.tsx │ ├── flippable │ │ ├── Flippable.tsx │ │ ├── assets │ │ │ └── index.scss │ │ └── index.ts │ └── index.ts ├── flow │ ├── action-step.ts │ ├── each-player.ts │ ├── enums.ts │ ├── every-player.ts │ ├── flow.ts │ ├── for-each.ts │ ├── for-loop.ts │ ├── if-else.ts │ ├── index.ts │ ├── switch-case.ts │ └── while-loop.ts ├── game-creator.ts ├── game-manager.ts ├── index.ts ├── interface.ts ├── player │ ├── collection.ts │ ├── index.ts │ └── player.ts ├── test-runner.ts ├── test │ ├── actions_test.ts │ ├── compiler.cjs │ ├── fixtures │ │ └── games.ts │ ├── flow_test.ts │ ├── game_manager_test.ts │ ├── game_test.ts │ ├── render_test.ts │ ├── setup-debug.js │ ├── setup.js │ └── ui_test.ts ├── ui │ ├── Main.tsx │ ├── assets │ │ ├── click.ogg.ts │ │ ├── click_004.ogg │ │ ├── grain.jpg │ │ ├── index.css │ │ └── index.scss │ ├── game │ │ ├── Game.tsx │ │ └── components │ │ │ ├── ActionForm.tsx │ │ │ ├── AnnouncementOverlay.tsx │ │ │ ├── BoardDebug.tsx.wip │ │ │ ├── Debug.tsx │ │ │ ├── DebugArgument.tsx │ │ │ ├── DebugChoices.tsx │ │ │ ├── Drawer.tsx │ │ │ ├── Element.tsx │ │ │ ├── FlowDebug.tsx │ │ │ ├── InfoOverlay.tsx │ │ │ ├── PlayerControls.tsx │ │ │ ├── Popout.tsx │ │ │ ├── ProfileBadge.tsx │ │ │ ├── Selection.tsx │ │ │ └── Tabs.tsx │ ├── index.tsx │ ├── lib.ts │ ├── queue.ts │ ├── render.ts │ ├── setup │ │ ├── Setup.tsx │ │ ├── components │ │ │ ├── Seating.tsx │ │ │ └── settingComponents.tsx │ │ └── scratch.js │ └── store.ts └── utils.ts ├── tsconfig.json ├── tsfmt.json └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "parser": "@typescript-eslint/parser", 4 | "plugins": [ 5 | "@typescript-eslint", 6 | ], 7 | "extends": [ 8 | "eslint:recommended", 9 | "plugin:@typescript-eslint/eslint-recommended", 10 | "plugin:@typescript-eslint/recommended", 11 | "plugin:react-hooks/recommended" 12 | ], 13 | "rules": { 14 | '@typescript-eslint/no-unused-vars': [ 15 | 'error', { 16 | argsIgnorePattern: '^_', 17 | varsIgnorePattern: '^_', 18 | caughtErrorsIgnorePattern: '^_', 19 | }, 20 | ], 21 | 'no-unused-vars': 0, 22 | '@typescript-eslint/no-explicit-any': 0, 23 | '@typescript-eslint/ban-ts-comment': 0, 24 | '@typescript-eslint/no-non-null-assertion': 0, 25 | '@typescript-eslint/no-empty-function': 0, 26 | 'no-underscore-dangle': 0, 27 | 'arrow-parens': 0, 28 | 'no-nested-ternary': 0, 29 | 'max-classes-per-file': 0, 30 | 'object-curly-newline': 0, 31 | 'max-len': 0, 32 | 'prefer-destructuring': 0, 33 | 'no-console': 0, 34 | "no-constant-condition": 0, 35 | "prefer-const": 0, 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /.github/workflows/check.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | branches: 7 | - main 8 | name: Check 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 18.18.2 17 | - uses: actions/checkout@v3 18 | - run: yarn 19 | - run: yarn run compile 20 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - main 5 | pull_request: 6 | branches: 7 | - main 8 | name: Check 9 | jobs: 10 | check: 11 | runs-on: ubuntu-latest 12 | steps: 13 | - name: Use Node.js ${{ matrix.node-version }} 14 | uses: actions/setup-node@v3 15 | with: 16 | node-version: 18.18.2 17 | - uses: actions/checkout@v3 18 | - run: yarn 19 | - run: yarn test 20 | - run: yarn run dep-check 21 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # temp files 2 | .DS_Store 3 | \#* 4 | .\#* 5 | 6 | # npm 7 | node_modules 8 | yarn-error.log 9 | 10 | # build artifacts 11 | /dist 12 | /docs 13 | 14 | todos.txt 15 | notes.txt 16 | **/scratch.* 17 | -------------------------------------------------------------------------------- /.mocharc.json.ignore: -------------------------------------------------------------------------------- 1 | { 2 | "require": [ "ts-node/register" ], 3 | "loader": "ts-node/esm", 4 | "extensions": ["ts", "tsx"], 5 | "spec": [ 6 | "src/test/**" 7 | ] 8 | } 9 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | !/dist -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # v0.2.14 2 | * Game elements now have next/previous selectors to find adjacent elements in 3 | the same space. 4 | * Invalid options in selections now retain their position 5 | * Corrected a bug with nested follow-ups 6 | 7 | # v0.2.13 8 | * Game settings provided now all accept an initial default value 9 | (e.g. numberSetting, etc) 10 | * The `everyPlayer` flow now passes the currently acting player to the flow 11 | args, just like `eachPlayer` 12 | * Corrected an issue with `everyPlayer` hanging when using an array `do` block 13 | * Corrected a bug with `layout.haphazardly`. 14 | 15 | # v0.2.12 16 | * Added action#confirm as an alternate means to add confirmation prompts 17 | * Providing a `validate` function in a `chooseFrom` now shows invalid choices 18 | with error messages if clicked 19 | 20 | # v0.2.11 Hideable Space Layouts 21 | * Added 3 new layouts for Spaces that save screen real estate 22 | * `element#layoutAsDrawer` puts Space in a expandable drawer (replaces the 23 | drawer 'attribute' in layout) 24 | * `element#layoutAsTabs` puts several Spaces into a switchable layout of tabs 25 | * `element#layoutAsPopout` hides a Space behind a button that opens it as a 26 | popout modal 27 | 28 | # v0.2.10 29 | * Fixed Info and Debug overlays 30 | 31 | # v0.2.9 32 | * Removed deprecated index.scss import 33 | 34 | # v0.2.8 Stack and Flippable 35 | * Added Stack class for decks within hidden elements and internal moves 36 | * Added Flippable component with back/front and animated flips 37 | 38 | # v0.2.7 Animation overhaul 39 | * Overhauled the animation logic that decides how elements move for various 40 | players, correcting several glitches 41 | * Pieces now animate on to and off of the board, into and out of the bottom of 42 | decks even if unrendered 43 | 44 | # v0.2.6 Subflows 45 | * Subflows allow you to define entire new flows that branch of your main flow, 46 | e.g. playing a particular Card causes a set of new actions to happen 47 | * Follow-ups are still available but these are now actually just Subflows under 48 | the hood that automatically perform only a single action 49 | * Some small flow and animation fixes included 50 | 51 | # v0.2.5 52 | * Added `action.swap` to allow players to exchange Pieces 53 | * Added `action.reorder` to allow players to reorder a collection of Pieces 54 | * Also made some improvements in the dragging and moving to remove flicker and 55 | quirkiness 56 | 57 | # v0.2.4 Component modules 58 | * Added Component modules. The `D6` class is the first example and it has been 59 | moved into a separate self-contained import. See 60 | https://docs.boardzilla.io/game/modules 61 | * It is no longer necessary to include `@boardzilla/core/index.css` in 62 | `ui/index.tsx`. This line can be removed. 63 | * Added `actionStep.continueIfImpossible` to allow a step to be skipped if no 64 | actions are valid 65 | * Added `actionStep.repeatUntil` to make a set of actions repeatable until a 66 | 'pass' action 67 | * Added `Player.hiddenAttributes` to make some attributes hidden from other 68 | players 69 | * Some consistency fixes to prompts. 70 | 71 | # v0.2.3 72 | * Board Sizes now accepts more detailed matchers, with a range of "best fit" 73 | aspect ratios, a setting to choose scrollbars or letterbox and mobile 74 | orientation fixing 75 | * Show/hide methods moved to Piece only 76 | * Space now has new convenience methods to attach content show/hide event 77 | handlers 78 | * Space now has "screen" methods to make contents completely invisible to 79 | players 80 | * Added Test Runner mocks and updatePlayers 81 | 82 | # v0.2 Grids 83 | * Added subclasses of `Space` for various grids, including more options for 84 | square, hex and the brand new PieceGrid. These replace `Space#createGrid`. 85 | * Grids can now be shaped, e.g. to create a hex grid in a square shape 86 | * `PieceGrid` allows the placement and tiling of irregular shapes, with an 87 | extendible grid that correctly sizes shaped pieces inside it. 88 | * Pieces now have a `setShape` and `setEdges` for adding irregular shapes and 89 | labelling the cells and edges for the purposes of adjacency. 90 | * Connecting spaces now allows bidirectional distances 91 | * Generics have been given a full rework, removing lots of unnecessary generics 92 | and making the importing of more game classes more straightfoward and 93 | extendable. 94 | * `createGameClasses` is gone. `Space` and `Piece` can be imported directly from 95 | core. 96 | * `Game#registerClasses` is no longer necessary and can be removed. 97 | * `Piece#putInto` now accepts `row`, `column`. 98 | * Now using incremental Typescript compilation for much faster rebuilds 99 | * History reprocessing in development has been completely reworked for much 100 | better performance 101 | * Added a new associated starter template using PieceGrid and irregular tiles. 102 | 103 | # v0.1 Lobby 104 | * Brand new lobby with seating controls, allowing self-seating, async game start 105 | and better UI. 106 | * renamed `Board` to `Game` and gave this class all exposed API methods that 107 | were previously in `Game`. `Game` renamed to `GameManager` and intended as an 108 | internal class. 109 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Any open source product is only as good as the community behind it. You can 4 | participate by sharing code, ideas, or simply helping others. No matter what 5 | your skill level is, every contribution counts. 6 | 7 | ## Games vs Core 8 | 9 | These contributions guidelines do not apply in any way to games submitted to the 10 | Boardzilla platform. **You retain full copyright for your game code and 11 | assets.** This only only applies to code submitted to the actual Boardzilla 12 | core library. 13 | 14 | ## Copyright 15 | 16 | **IMPORTANT:** By supplying code to Boardzilla core in issues and pull requests, 17 | you agree to assign copyright of that code to us, on the condition that we also 18 | release that code under the AGPL license. 19 | 20 | We ask for this so that the ownership in the license is clear and unambiguous, 21 | and so that community involvement doesn't stop us from being able to sustain the 22 | project by releasing this code under a permissive licenses in addition to the 23 | open source AGPL license. This copyright assignment won't prevent you from using 24 | the code in any way you see fit. 25 | 26 | See our [Contributor License Agreement](CLA.md) for the legal details. 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | 👋 This is Boardzilla, a framework to make writing a digital board game easy. Boardzilla takes care of 2 | 3 | - player management 4 | - structuring game rules 5 | - persisting game & player state 6 | - animations 7 | 8 | and much more! 9 | 10 | Visit us at https://boardzilla.io. 11 | 12 | See documentation at https://docs.boardzilla.io. 13 | 14 | (c)2024 Andrew Hull 15 | -------------------------------------------------------------------------------- /assets: -------------------------------------------------------------------------------- 1 | src/ui/assets -------------------------------------------------------------------------------- /decl.d.ts: -------------------------------------------------------------------------------- 1 | declare module "*.ogg"; 2 | -------------------------------------------------------------------------------- /entry: -------------------------------------------------------------------------------- 1 | src -------------------------------------------------------------------------------- /esbuild.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild' 2 | import {sassPlugin} from 'esbuild-sass-plugin' 3 | 4 | await esbuild.build({ 5 | entryPoints: ['./src/ui/assets/index.scss'], 6 | assetNames: '[name]', 7 | bundle: true, 8 | format: 'esm', 9 | outdir: 'dist/ui/assets/', 10 | loader: { 11 | '.ogg': 'dataurl', 12 | '.jpg': 'file', 13 | }, 14 | sourcemap: 'inline', 15 | plugins: [sassPlugin()], 16 | }) 17 | 18 | await esbuild.build({ 19 | entryPoints: ['./src/components/d6/index.ts'], 20 | assetNames: '[name]', 21 | bundle: true, 22 | format: 'esm', 23 | outdir: 'dist/components/d6/assets/', 24 | loader: { 25 | '.ogg': 'copy', 26 | }, 27 | sourcemap: 'inline', 28 | plugins: [sassPlugin()], 29 | }) 30 | 31 | await esbuild.build({ 32 | entryPoints: ['./src/components/flippable/index.ts'], 33 | assetNames: '[name]', 34 | bundle: true, 35 | format: 'esm', 36 | outdir: 'dist/components/flippable/assets/', 37 | sourcemap: 'inline', 38 | plugins: [sassPlugin()], 39 | }) 40 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@boardzilla/core", 3 | "version": "0.2.14", 4 | "author": "Andrew Hull ", 5 | "license": "AGPL-3.0", 6 | "scripts": { 7 | "clean": "rm -rf dist docs", 8 | "build": "yarn build:test && rm -rf dist/test", 9 | "prepublish": "git diff-index --quiet HEAD || (echo 'git unclean' && exit 1)", 10 | "prepack": "./scripts/prepack", 11 | "postpack": "./scripts/postpack", 12 | "build:test": "yarn assets && yarn compile && find dist/ -type f|grep js$|xargs grep -l scss|xargs sed -i~ 's/\\.scss/.css/g' && find dist/ -type f|grep js~$|xargs rm", 13 | "assets": "node esbuild.mjs", 14 | "test": "NODE_ENV=test yarn run build:test && mocha src/test/setup.js dist/test/*_test.js", 15 | "test:debug": "NODE_ENV=test yarn run build:test && mocha --inspect-brk --timeout 3600000 src/test/setup-debug.js dist/test/*_test.js", 16 | "compile": "tsc", 17 | "docs": "typedoc", 18 | "lint": "eslint . --ext .ts", 19 | "dep-check": "dpdm --no-warning --no-tree -T ./src/index.ts" 20 | }, 21 | "type": "module", 22 | "exports": { 23 | ".": "./entry/index.js", 24 | "./components": "./entry/components/index.js" 25 | }, 26 | "files": [ 27 | "entry/**/*" 28 | ], 29 | "types": "entry/index.d.ts", 30 | "sideEffects": [ 31 | "./src/ui/assets/index.scss" 32 | ], 33 | "dependencies": { 34 | "classnames": "^2.3.1", 35 | "graphology": "^0.25.4", 36 | "graphology-shortest-path": "^2.0.2", 37 | "graphology-traversal": "^0.3.1", 38 | "graphology-types": "^0.24.7", 39 | "random-seed": "^0.3.0", 40 | "react": "^18.2", 41 | "react-color": "^2.19.3", 42 | "react-dom": "^18.2", 43 | "react-draggable": "^4.4.5", 44 | "uuid-random": "^1.3.2", 45 | "zustand": "^4.4.0" 46 | }, 47 | "devDependencies": { 48 | "@types/chai": "^4.3.1", 49 | "@types/chai-spies": "^1.0.3", 50 | "@types/jest": "^28.1.4", 51 | "@types/node": "^20.6.2", 52 | "@types/random-seed": "^0.3.3", 53 | "@types/react": "^18.2.25", 54 | "@types/react-color": "^3.0.6", 55 | "@types/react-dom": "^18.2.14", 56 | "@typescript-eslint/eslint-plugin": "^6.9.1", 57 | "@typescript-eslint/parser": "^6.9.1", 58 | "chai": "^4.3.8", 59 | "chai-spies": "^1.0.0", 60 | "dpdm": "^3.14.0", 61 | "esbuild": "^0.19.5", 62 | "esbuild-sass-plugin": "^2.16.0", 63 | "eslint": "^8.53.0", 64 | "eslint-plugin-react-hooks": "^4.6.0", 65 | "mocha": "^10.2.0", 66 | "ts-node": "^10.8.2", 67 | "typedoc": "^0.25.2", 68 | "typedoc-plugin-markdown": "^3.17.1", 69 | "typedoc-plugin-merge-modules": "^5.1.0", 70 | "typescript": "^5.2.0" 71 | }, 72 | "typedocOptions": { 73 | "entryPoints": [ 74 | "src", 75 | "src/game.ts", 76 | "src/board", 77 | "src/flow", 78 | "src/action", 79 | "src/player", 80 | "src/ui" 81 | ], 82 | "plugin": [ 83 | "typedoc-plugin-markdown", 84 | "typedoc-plugin-merge-modules" 85 | ], 86 | "sort": "source-order", 87 | "categorizeByGroup": false, 88 | "excludeInternal": true, 89 | "excludeNotDocumented": true, 90 | "out": "docs", 91 | "name": "@boardzilla/core" 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /scripts/postpack: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | rm -r entry 5 | ln -s src entry 6 | -------------------------------------------------------------------------------- /scripts/prepack: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | set -e 3 | 4 | yarn clean 5 | yarn build 6 | rm -r entry 7 | mv dist entry 8 | -------------------------------------------------------------------------------- /src/action/index.ts: -------------------------------------------------------------------------------- 1 | export {default as Action} from './action.js'; 2 | export {default as Selection} from './selection.js'; 3 | 4 | export type { Argument, ActionStub } from './action.js'; 5 | 6 | export { 7 | serializeArg, 8 | deserializeArg 9 | } from './utils.js'; 10 | -------------------------------------------------------------------------------- /src/action/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Argument, SingleArgument } from './action.js'; 2 | import type { Player } from '../player/index.js'; 3 | import type { BaseGame } from '../board/game.js'; 4 | import type GameElement from '../board/element.js'; 5 | 6 | export type SerializedSingleArg = string | number | boolean; 7 | export type SerializedArg = SerializedSingleArg | SerializedSingleArg[]; 8 | export type Serializable = SingleArgument | null | undefined | Serializable[] | { [key: string]: Serializable }; 9 | 10 | export const serialize = (arg: Serializable, forPlayer=true, name?: string): any => { 11 | if (arg === undefined) return undefined; 12 | if (arg === null) return null; 13 | if (arg instanceof Array) return arg.map(a => serialize(a, forPlayer)); 14 | if (typeof arg === 'object' && 'constructor' in arg && ('isPlayer' in arg.constructor || 'isGameElement' in arg.constructor)) { 15 | return serializeSingleArg(arg as GameElement | Player, forPlayer); 16 | } 17 | if (typeof arg === 'object') return serializeObject(arg, forPlayer); 18 | if (typeof arg === 'number' || typeof arg === 'string' || typeof arg === 'boolean') return serializeSingleArg(arg, forPlayer); 19 | throw Error(`Unable to serialize the property ${name ? '"' + name + '": ' : ''} ${arg}. Only primitives, Player's, GameElement's or arrays/objects containing such can be used.`); 20 | } 21 | 22 | export const serializeArg = (arg: Argument, forPlayer=true): SerializedArg => { 23 | if (arg instanceof Array) return arg.map(a => serializeSingleArg(a, forPlayer)); 24 | return serializeSingleArg(arg, forPlayer); 25 | } 26 | 27 | export const serializeSingleArg = (arg: SingleArgument, forPlayer=true): SerializedSingleArg => { 28 | if (typeof arg === 'object' && 'constructor' in arg) { 29 | if ('isPlayer' in arg.constructor) return `$p[${(arg as Player).position}]`; 30 | if ('isGameElement' in arg.constructor) return forPlayer ? `$el[${(arg as GameElement).branch()}]` : `$eid[${(arg as GameElement)._t.id}]`; 31 | } 32 | return arg as SerializedSingleArg; 33 | } 34 | 35 | export const serializeObject = (obj: Record, forPlayer=true) => { 36 | return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, serialize(v, forPlayer, k)])); 37 | } 38 | 39 | export const escapeArgument = (arg: Argument): string => { 40 | if (arg instanceof Array) { 41 | const escapees = arg.map(escapeArgument); 42 | return escapees.slice(0, -1).join(', ') + (escapees.length > 1 ? ' and ' : '') + (escapees[escapees.length - 1] || ''); 43 | } 44 | if (typeof arg === 'object') return `[[${serializeSingleArg(arg)}|${arg.toString()}]]`; 45 | return String(arg); 46 | } 47 | 48 | export const deserializeArg = (arg: SerializedArg, game: BaseGame): Argument => { 49 | if (arg instanceof Array) return arg.map(a => deserializeSingleArg(a, game)) as GameElement[]; 50 | return deserializeSingleArg(arg, game); 51 | } 52 | 53 | export const deserializeSingleArg = (arg: SerializedSingleArg, game: BaseGame): SingleArgument => { 54 | if (typeof arg === 'number' || typeof arg === 'boolean') return arg; 55 | let deser: SingleArgument | undefined; 56 | if (arg.slice(0, 3) === '$p[') { 57 | deser = game.players.atPosition(parseInt(arg.slice(3, -1))); 58 | } else if (arg.slice(0, 4) === '$el[') { 59 | deser = game.atBranch(arg.slice(4, -1)); 60 | } else if (arg.slice(0, 5) === '$eid[') { 61 | deser = game.atID(parseInt(arg.slice(5, -1))); 62 | } else { 63 | return arg; 64 | } 65 | if (!deser) throw Error(`Unable to find arg: ${arg}`); 66 | return deser; 67 | } 68 | 69 | export const deserializeObject = (obj: Record, game: BaseGame) => { 70 | return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, deserialize(v, game)])); 71 | } 72 | 73 | export const deserialize = (arg: any, game: BaseGame): Serializable => { 74 | if (arg === undefined) return undefined; 75 | if (arg === null) return null; 76 | if (arg instanceof Array) return arg.map(a => deserialize(a, game)); 77 | if (typeof arg === 'object') return deserializeObject(arg, game); 78 | if (typeof arg === 'number' || typeof arg === 'string' || typeof arg === 'boolean') return deserializeSingleArg(arg, game); 79 | throw Error(`unable to deserialize ${arg}`); 80 | } 81 | 82 | export const combinations = (set: T[], min: number, max: number): T[][] => { 83 | const combos = [] as T[][]; 84 | const poss = (curr: T[], i: number) => { 85 | if (set.length - i < min - curr.length) return; 86 | if (curr.length >= min) combos.push(curr); 87 | if (curr.length < max) { 88 | for (let j = i; j !== set.length; j++) { 89 | poss(curr.concat([set[j]]), j + 1); 90 | } 91 | } 92 | } 93 | poss([], 0); 94 | return combos; 95 | } 96 | -------------------------------------------------------------------------------- /src/board/adjacency-space.ts: -------------------------------------------------------------------------------- 1 | import GameElement from './element.js'; 2 | 3 | import type { BaseGame } from './game.js'; 4 | import type { ElementUI, LayoutAttributes } from './element.js'; 5 | import SingleLayout from './single-layout.js'; 6 | 7 | /** 8 | * Abstract base class for all adjacency spaces 9 | */ 10 | export default abstract class AdjacencySpace extends SingleLayout { 11 | 12 | _ui: ElementUI = { 13 | layouts: [], 14 | appearance: {}, 15 | getBaseLayout: () => ({ 16 | sticky: true, 17 | alignment: 'center', 18 | direction: 'square' 19 | }) 20 | }; 21 | 22 | isAdjacent(_el1: GameElement, _el2: GameElement): boolean { 23 | throw Error("Abstract AdjacencySpace has no implementation"); 24 | } 25 | 26 | _positionOf(element: GameElement) { 27 | const positionedParent = this._positionedParentOf(element); 28 | return {column: positionedParent.column, row: positionedParent.row}; 29 | } 30 | 31 | _positionedParentOf(element: GameElement): GameElement { 32 | if (!element._t.parent) throw Error(`Element not found within adjacency space "${this.name}"`); 33 | return element._t.parent === this ? element : this._positionedParentOf(element._t.parent); 34 | } 35 | 36 | /** 37 | * Change the layout attributes for this space's layout. 38 | * @category UI 39 | */ 40 | configureLayout(layoutConfiguration: Partial) { 41 | const keys = Object.keys(layoutConfiguration); 42 | if (keys.includes('scaling') || keys.includes('alignment')) { 43 | throw Error("Layouts for grids cannot have alignment, scaling"); 44 | } 45 | super.configureLayout(layoutConfiguration); 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /src/board/fixed-grid.ts: -------------------------------------------------------------------------------- 1 | import ConnectedSpaceMap from "./connected-space-map.js"; 2 | import Space from './space.js'; 3 | 4 | import type Game from './game.js'; 5 | import type { default as GameElement, ElementClass, ElementUI } from "./element.js"; 6 | 7 | /** 8 | * Abstract base class for {@link SquareGrid} and {@link HexGrid} 9 | * @category Board 10 | */ 11 | export default abstract class FixedGrid extends ConnectedSpaceMap { 12 | 13 | rows: number = 1; 14 | columns: number = 1; 15 | space: ElementClass> = Space; 16 | 17 | _ui: ElementUI = { 18 | layouts: [], 19 | appearance: {}, 20 | getBaseLayout: () => ({ 21 | rows: this.rows, 22 | columns: this.columns, 23 | sticky: true, 24 | alignment: 'center', 25 | direction: 'square' 26 | }) 27 | }; 28 | 29 | static unserializableAttributes = [...ConnectedSpaceMap.unserializableAttributes, 'space']; 30 | 31 | afterCreation() { 32 | const name = this.name + '-' + this.space.name.toLowerCase(); 33 | const grid: Space[][] = []; 34 | for (const [column, row] of this._gridPositions()) { 35 | const space = this.createElement(this.space, name, {column, row}); 36 | space._t.parent = this; 37 | this._t.children.push(space); 38 | this._graph.addNode(space._t.id, {space}); 39 | grid[column] ??= []; 40 | grid[column][row] = space; 41 | } 42 | for (const space of this._t.children) { 43 | for (const [column, row, distance] of this._adjacentGridPositionsTo(space.column!, space.row!)) { 44 | if (grid[column]?.[row]) this._graph.addDirectedEdge(space._t.id, grid[column][row]._t.id, {distance: distance ?? 1}); 45 | } 46 | } 47 | this.configureLayout({ rows: this.rows, columns: this.columns }); 48 | } 49 | 50 | create(_className: ElementClass, _name: string): T { 51 | throw Error("Fixed grids automatically create it's own spaces. Spaces can be destroyed but not created"); 52 | } 53 | 54 | _adjacentGridPositionsTo(_column: number, _row: number): [number, number, number?][] { 55 | return []; // unimplemented 56 | } 57 | 58 | _gridPositions(): [number, number][] { 59 | return []; // unimplemented 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/board/hex-grid.ts: -------------------------------------------------------------------------------- 1 | import FixedGrid from "./fixed-grid.js"; 2 | import { times } from '../utils.js'; 3 | 4 | import type Game from './game.js'; 5 | import type { ElementUI } from "./element.js"; 6 | 7 | /** 8 | * A Hex grid. Create the HexGrid with 'rows' and 'columns' values to 9 | * automatically create the spaces. Optionally use {@link shape} and {@link 10 | * axes} to customize the type of hex. 11 | * @category Board 12 | * 13 | * @example 14 | * game.create(HexGrid, 'catan-board', { rows: 5, columns: 5, shape: 'hex' }) 15 | */ 16 | export default class HexGrid extends FixedGrid { 17 | 18 | /** 19 | * Determines which direction the rows and columns go within the 20 | * hex. E.g. with east-by-southwest axes, The cell at {row: 1, column: 2} is 21 | * directly east of {row: 1, column: 1}. The cell at {row: 2, column: 1} is 22 | * directly southwest of {row: 1, column: 1}. 23 | * @category Adjacency 24 | */ 25 | axes: 'east-by-southwest' | 'east-by-southeast' | 'southeast-by-south' | 'northeast-by-south' = 'east-by-southwest'; 26 | /** 27 | * Determines the overall shape of the spaces created. 28 | * 29 | * rhomboid - A rhomboid shape. This means a cell will exist at every row and 30 | * column combination. A 3x3 rhomboid hex contains 9 cells. 31 | * 32 | * hex - A hex shape. This means the hex will be at most row x columns but 33 | * will be missing cells at the corners. A 3x3 hex shape contains 7 cells. 34 | * 35 | * square - A square shape. This means the hex will be at most row x columns 36 | * but will be shaped to keep a square shape. Some cells will therefore have a 37 | * column value outside the range of columns. A 3x3 square hex contains 8 38 | * cells, 3 on each side, and two in the middle. 39 | * @category Adjacency 40 | */ 41 | shape: 'square' | 'hex' | 'rhomboid' = 'rhomboid'; 42 | 43 | _ui: ElementUI = { 44 | layouts: [], 45 | appearance: {}, 46 | getBaseLayout: () => ({ 47 | rows: this.rows, 48 | columns: this.columns, 49 | sticky: true, 50 | alignment: 'center', 51 | direction: 'square', 52 | offsetColumn: { 53 | 'east-by-southwest': {x: 100, y: 0}, 54 | 'east-by-southeast': {x: 100, y: 0}, 55 | 'southeast-by-south': {x: 100, y: 50}, 56 | 'northeast-by-south': {x: 100, y: -50} 57 | }[this.axes], 58 | offsetRow: { 59 | 'east-by-southwest': {x: -50, y: 100}, 60 | 'east-by-southeast': {x: 50, y: 100}, 61 | 'southeast-by-south': {x: 0, y: 100}, 62 | 'northeast-by-south': {x: 0, y: 100} 63 | }[this.axes] 64 | }) 65 | }; 66 | 67 | _adjacentGridPositionsTo(column: number, row: number): [number, number][] { 68 | const positions: [number, number][] = []; 69 | if (column > 1) { 70 | positions.push([column - 1, row]); 71 | if (['east-by-southwest', 'northeast-by-south'].includes(this.axes)) { 72 | positions.push([column - 1, row - 1]); 73 | } 74 | if (['east-by-southeast', 'southeast-by-south'].includes(this.axes)) { 75 | positions.push([column - 1, row + 1]); 76 | } 77 | } 78 | if (column < this.columns) { 79 | positions.push([column + 1, row]); 80 | if (['east-by-southeast', 'southeast-by-south'].includes(this.axes)) { 81 | positions.push([column + 1, row - 1]); 82 | } 83 | if (['east-by-southwest', 'northeast-by-south'].includes(this.axes)) { 84 | positions.push([column + 1, row + 1]); 85 | } 86 | } 87 | if (row > 1) positions.push([column, row - 1]); 88 | if (row < this.rows) positions.push([column, row + 1]); 89 | return positions; 90 | } 91 | 92 | _gridPositions(): [number, number][] { 93 | const positions: [number, number][] = []; 94 | if (this.shape === 'hex') { 95 | const topCorner = Math.ceil((Math.min(this.rows, this.columns) - 1) / 2); 96 | const bottomCorner = Math.floor((Math.min(this.rows, this.columns) - 1) / 2); 97 | const topRight = ['east-by-southwest', 'northeast-by-south'].includes(this.axes); 98 | times(this.rows, row => times(this.columns - Math.max(topCorner + 1 - row, 0) - Math.max(row - this.rows + bottomCorner, 0), col => { 99 | positions.push([col + Math.max(topRight ? bottomCorner + row - this.rows : topCorner - row + 1, 0), row]); 100 | })); 101 | } else if (this.shape === 'square') { 102 | const squished = ['east-by-southeast', 'southeast-by-south'].includes(this.axes); 103 | if (['east-by-southwest', 'east-by-southeast'].includes(this.axes)) { 104 | times(this.rows, row => times(this.columns - (row % 2 ? 0 : 1), col => { 105 | positions.push([col + (squished ? 1 - Math.ceil(row / 2) : Math.floor(row / 2)), row]) 106 | })); 107 | } else { 108 | times(this.columns, col => times(this.rows - (col % 2 ? 0 : 1), row => { 109 | positions.push([col, row + (squished ? Math.floor(col / 2) : 1 - Math.ceil(col / 2))]) 110 | })); 111 | } 112 | } else { 113 | times(this.rows, row => times(this.columns, col => positions.push([col, row]))); 114 | } 115 | return positions; 116 | } 117 | 118 | _cornerPositions(): [number, number][] { 119 | if (this.shape === 'hex') { 120 | const topCorner = Math.ceil((Math.min(this.rows, this.columns) - 1) / 2); 121 | const bottomCorner = Math.floor((Math.min(this.rows, this.columns) - 1) / 2); 122 | if (['east-by-southwest', 'northeast-by-south'].includes(this.axes)) { 123 | return [ 124 | [1, 1], 125 | [this.columns - topCorner, 1], 126 | [1, Math.floor(this.rows / 2) + 1], 127 | [this.columns, Math.floor(this.rows / 2) + 1], 128 | [1 + bottomCorner, this.rows], 129 | [this.columns, this.rows], 130 | ]; 131 | } else { 132 | return [ 133 | [1 + topCorner, 1], 134 | [this.columns, 1], 135 | [1, Math.floor(this.rows / 2) + 1], 136 | [this.columns, Math.floor(this.rows / 2) + 1], 137 | [1, this.rows], 138 | [this.columns - bottomCorner, this.rows], 139 | ]; 140 | } 141 | } else if (this.shape === 'square') { 142 | if (['east-by-southwest'].includes(this.axes)) { 143 | return [ 144 | [1, 1], 145 | [this.columns, 1], 146 | [1 + Math.floor(this.rows / 2), this.rows], 147 | [this.columns - 1 + Math.ceil(this.rows / 2), this.rows], 148 | ]; 149 | } else if (['east-by-southeast'].includes(this.axes)) { 150 | return [ 151 | [1, 1], 152 | [this.columns, 1], 153 | [2 - Math.ceil(this.rows / 2), this.rows], 154 | [this.columns - Math.floor(this.rows / 2), this.rows], 155 | ]; 156 | } else if (['southeast-by-south'].includes(this.axes)) { 157 | return [ 158 | [1, 1], 159 | [1, this.rows], 160 | [this.columns, 2 - Math.ceil(this.columns / 2)], 161 | [this.columns, this.rows - Math.floor(this.columns / 2)], 162 | ]; 163 | } else { 164 | return [ 165 | [1, 1], 166 | [1, this.rows], 167 | [this.columns, 1 + Math.floor(this.columns / 2)], 168 | [this.columns, this.rows - 1 + Math.ceil(this.columns / 2)], 169 | ]; 170 | } 171 | } 172 | return [ 173 | [1, 1], 174 | [this.columns, 1], 175 | [1, this.rows], 176 | [this.columns, this.rows], 177 | ]; 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /src/board/index.ts: -------------------------------------------------------------------------------- 1 | import GameElement from './element.js'; 2 | import ElementCollection from './element-collection.js'; 3 | export { GameElement, ElementCollection }; 4 | export { default as Space } from './space.js'; 5 | export { default as Piece } from './piece.js'; 6 | export { default as Stack } from './stack.js'; 7 | export { default as AdjacencySpace } from './adjacency-space.js'; 8 | export { default as ConnectedSpaceMap } from './connected-space-map.js'; 9 | export { default as FixedGrid } from './fixed-grid.js'; 10 | export { default as SquareGrid } from './square-grid.js'; 11 | export { default as HexGrid } from './hex-grid.js'; 12 | export { default as PieceGrid } from './piece-grid.js'; 13 | export { default as Game } from './game.js'; 14 | 15 | export type { ActionLayout } from './game.js'; 16 | export type { LayoutAttributes, Box, Vector } from './element.js'; 17 | export type { ElementFinder, Sorter } from './element-collection.js'; 18 | 19 | /** 20 | * Returns an {@link ElementCollection} by combining a list of {@link 21 | * GameElement}'s or {@link ElementCollection}'s, 22 | * @category Flow 23 | */ 24 | export function union(...queries: (T | ElementCollection | undefined)[]): ElementCollection { 25 | let c = new ElementCollection(); 26 | for (const q of queries) { 27 | if (q) { 28 | if ('forEach' in q) { 29 | q.forEach(e => c.includes(e) || c.push(e)); 30 | } else if (!c.includes(q)) { 31 | c.push(q); 32 | } 33 | } 34 | } 35 | return c; 36 | } 37 | -------------------------------------------------------------------------------- /src/board/piece.ts: -------------------------------------------------------------------------------- 1 | import GameElement from './element.js' 2 | import Space from './space.js' 3 | 4 | import type { ElementAttributes, ElementClass } from './element.js' 5 | import type Game from './game.js' 6 | import type Player from '../player/player.js'; 7 | import type { BaseGame } from './game.js'; 8 | 9 | /** 10 | * Pieces are game elements that can move during play 11 | * @category Board 12 | */ 13 | export default class Piece> extends GameElement { 14 | 15 | _visible?: { 16 | default: boolean, 17 | except?: number[] 18 | } 19 | 20 | createElement(className: ElementClass, name: string, attrs?: ElementAttributes): T { 21 | if (className === Space as unknown as ElementClass || Object.prototype.isPrototypeOf.call(Space, className)) { 22 | throw Error(`May not create Space "${name}" in Piece "${this.name}"`); 23 | } 24 | return super.createElement(className, name, attrs); 25 | } 26 | 27 | /** 28 | * Show this piece to all players 29 | * @category Visibility 30 | */ 31 | showToAll() { 32 | delete(this._visible); 33 | } 34 | 35 | /** 36 | * Show this piece only to the given player 37 | * @category Visibility 38 | */ 39 | showOnlyTo(player: Player | number) { 40 | if (typeof player !== 'number') player = player.position; 41 | this._visible = { 42 | default: false, 43 | except: [player] 44 | }; 45 | } 46 | 47 | /** 48 | * Show this piece to the given players without changing it's visibility to 49 | * any other players. 50 | * @category Visibility 51 | */ 52 | showTo(...player: Player[] | number[]) { 53 | if (typeof player[0] !== 'number') player = (player as Player[]).map(p => p.position); 54 | if (this._visible === undefined) return; 55 | if (this._visible.default) { 56 | if (!this._visible.except) return; 57 | this._visible.except = this._visible.except.filter(i => !(player as number[]).includes(i)); 58 | } else { 59 | this._visible.except = Array.from(new Set([...(this._visible.except instanceof Array ? this._visible.except : []), ...(player as number[])])) 60 | } 61 | } 62 | 63 | /** 64 | * Hide this piece from all players 65 | * @category Visibility 66 | */ 67 | hideFromAll() { 68 | this._visible = {default: false}; 69 | } 70 | 71 | /** 72 | * Hide this piece from the given players without changing it's visibility to 73 | * any other players. 74 | * @category Visibility 75 | */ 76 | hideFrom(...player: Player[] | number[]) { 77 | if (typeof player[0] !== 'number') player = (player as Player[]).map(p => p.position); 78 | if (this._visible?.default === false && !this._visible.except) return; 79 | if (this._visible === undefined || this._visible.default === true) { 80 | this._visible = { 81 | default: true, 82 | except: Array.from(new Set([...(this._visible?.except instanceof Array ? this._visible.except : []), ...(player as number[])])) 83 | }; 84 | } else { 85 | if (!this._visible.except) return; 86 | this._visible.except = this._visible.except.filter(i => !(player as number[]).includes(i)); 87 | } 88 | } 89 | 90 | /** 91 | * Returns whether this piece is visible to the given player 92 | * @category Visibility 93 | */ 94 | isVisibleTo(player: Player | number) { 95 | if (typeof player !== 'number') player = player.position; 96 | if (this._visible === undefined) return true; 97 | if (this._visible.default) { 98 | return !this._visible.except || !(this._visible.except.includes(player)); 99 | } else { 100 | return this._visible.except?.includes(player) || false; 101 | } 102 | } 103 | 104 | /** 105 | * Returns whether this piece is visible to all players, or to the current 106 | * player if called when in a player context (during an action taken by a 107 | * player or while the game is viewed by a given player.) 108 | * @category Visibility 109 | */ 110 | isVisible() { 111 | if (this._ctx.player) return this.isVisibleTo(this._ctx.player.position); 112 | return this._visible?.default !== false && (this._visible?.except ?? []).length === 0; 113 | } 114 | 115 | /** 116 | * Provide list of attributes that remain visible even when these pieces are 117 | * not visible to players. E.g. In a game with multiple card decks with 118 | * different backs, identified by Card#deck, the identity of the card when 119 | * face-down is hidden, but the deck it belongs to is not, since the card art 120 | * on the back would identify the deck. In this case calling 121 | * `Card.revealWhenHidden('deck')` will cause all attributes other than 'deck' 122 | * to be hidden when the card is face down, while still revealing which deck 123 | * it is. 124 | * @category Visibility 125 | */ 126 | static revealWhenHidden>(this: ElementClass, ...attrs: (string & keyof T)[]): void { 127 | this.visibleAttributes = attrs; 128 | } 129 | 130 | /** 131 | * Move this piece into another element. This triggers any {@link 132 | * Space#onEnter | onEnter} callbacks in the destination. 133 | * @category Structure 134 | * 135 | * @param to - Destination element 136 | * @param options.position - Place the piece into a specific numbered position 137 | * relative to the other elements in this space. Positive numbers count from 138 | * the beginning. Negative numbers count from the end. 139 | * @param options.fromTop - Place the piece into a specific numbered position counting 140 | * from the first element 141 | * @param options.fromBottom - Place the piece into a specific numbered position 142 | * counting from the last element 143 | */ 144 | putInto(to: GameElement, options?: {position?: number, row?: number, column?: number, fromTop?: number, fromBottom?: number}) { 145 | if (to.isDescendantOf(this)) throw Error(`Cannot put ${this} into itself`); 146 | let pos: number = to._t.order === 'stacking' ? 0 : to._t.children.length; 147 | if (options?.position !== undefined) pos = options.position >= 0 ? options.position : to._t.children.length + options.position + 1; 148 | if (options?.fromTop !== undefined) pos = options.fromTop; 149 | if (options?.fromBottom !== undefined) pos = to._t.children.length - options.fromBottom; 150 | const previousParent = this._t.parent; 151 | const position = this.position(); 152 | if (this.hasMoved() || to.hasMoved()) this.game.addDelay(); 153 | const refs = previousParent === to && options?.row === undefined && options?.column === undefined && to.childRefsIfObscured(); 154 | this._t.parent!._t.children.splice(position, 1); 155 | this._t.parent = to; 156 | to._t.children.splice(pos, 0, this); 157 | if (refs) to.assignChildRefs(refs); 158 | 159 | if (previousParent !== to && previousParent instanceof Space) previousParent.triggerEvent("exit", this); 160 | if (previousParent !== to && this._ctx.trackMovement) this._t.moved = true; 161 | 162 | delete this.column; 163 | delete this.row; 164 | if (options?.row !== undefined) this.row = options.row; 165 | if (options?.column !== undefined) this.column = options.column; 166 | 167 | if (previousParent !== to && to instanceof Space) to.triggerEvent("enter", this); 168 | } 169 | 170 | cloneInto(this: T, into: GameElement): T { 171 | let attrs = this.attributeList(); 172 | delete attrs.column; 173 | delete attrs.row; 174 | 175 | const clone = into.createElement(this.constructor as ElementClass, this.name, attrs); 176 | if (into._t.order === 'stacking') { 177 | into._t.children.unshift(clone); 178 | } else { 179 | into._t.children.push(clone); 180 | } 181 | clone._t.parent = into; 182 | clone._t.order = this._t.order; 183 | for (const child of this._t.children) if (child instanceof Piece) child.cloneInto(clone); 184 | return clone; 185 | } 186 | 187 | /** 188 | * Remove this piece from the playing area and place it into {@link 189 | * Game#pile} 190 | * @category Structure 191 | */ 192 | remove() { 193 | return this.putInto(this._ctx.removed); 194 | } 195 | } 196 | -------------------------------------------------------------------------------- /src/board/single-layout.ts: -------------------------------------------------------------------------------- 1 | import Space from './space.js'; 2 | 3 | import type { BaseGame } from './game.js'; 4 | import type { ElementUI } from './element.js'; 5 | 6 | /** 7 | * Abstract base class for all adjacency spaces 8 | */ 9 | export default abstract class SingleLayout extends Space { 10 | 11 | _ui: ElementUI = { 12 | layouts: [], 13 | appearance: {}, 14 | getBaseLayout: () => ({ 15 | alignment: 'center', 16 | direction: 'square' 17 | }) 18 | }; 19 | 20 | /** 21 | * Single layout space can only contain elements of a certain type. Rather 22 | * than adding multiple, overlapping layouts for different elements, there is 23 | * a single layout that can be modified using {@link configureLayout}. 24 | * @category UI 25 | */ 26 | layout() { 27 | throw Error("Space cannot have additional layouts added. The layout can instead be configured with configureLayout."); 28 | } 29 | 30 | resetUI() { 31 | if (!this._ui.layouts.length) this.configureLayout({}); 32 | this._ui.appearance = {}; 33 | for (const child of this._t.children) child.resetUI(); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /src/board/space.ts: -------------------------------------------------------------------------------- 1 | import GameElement from './element.js' 2 | 3 | import type { BaseGame } from './game.js'; 4 | import type Player from '../player/player.js'; 5 | import type { ElementClass, ElementAttributes } from './element.js'; 6 | import { Piece } from '../index.js'; 7 | 8 | export type ElementEventHandler = {callback: (el: T) => void} & Record; 9 | 10 | /** 11 | * Spaces are areas of the game. The spaces of your game are declared during 12 | * setup in {@link createGame} and never change during play. 13 | * @category Board 14 | */ 15 | export default class Space> extends GameElement { 16 | 17 | static unserializableAttributes = [...GameElement.unserializableAttributes, '_eventHandlers', '_visOnEnter', '_screen']; 18 | 19 | _eventHandlers: { 20 | enter: ElementEventHandler[], 21 | exit: ElementEventHandler[], 22 | } = { enter: [], exit: [] }; 23 | 24 | _visOnEnter?: { 25 | default: boolean, 26 | except?: number[] | 'owner' 27 | } 28 | 29 | _screen?: 'all' | 'all-but-owner' | number[]; 30 | 31 | /** 32 | * Show pieces to all players when they enter this space 33 | * @category Visibility 34 | */ 35 | contentsWillBeShown() { 36 | this._visOnEnter = {default: true}; 37 | } 38 | 39 | /** 40 | * Show pieces when they enter this space to its owner 41 | * @category Visibility 42 | */ 43 | contentsWillBeShownToOwner() { 44 | this._visOnEnter = {default: false, except: 'owner'}; 45 | } 46 | 47 | /** 48 | * Show piece to these players when they enter this space 49 | * @category Visibility 50 | */ 51 | contentsWillBeShownTo(...players: P[]) { 52 | this._visOnEnter = {default: false, except: players.map(p => p.position)}; 53 | } 54 | 55 | /** 56 | * Hide pieces to all players when they enter this space 57 | * @category Visibility 58 | */ 59 | contentsWillBeHidden() { 60 | this._visOnEnter = {default: false}; 61 | } 62 | 63 | /** 64 | * Hide piece to these players when they enter this space 65 | * @category Visibility 66 | */ 67 | contentsWillBeHiddenFrom(...players: P[]) { 68 | this._visOnEnter = {default: true, except: players.map(p => p.position)}; 69 | } 70 | 71 | /** 72 | * Call this to screen view completely from players. Blocked spaces completely 73 | * hide their contents, like a physical screen. No information about the 74 | * number, type or movement of contents inside this Space will be revealed to 75 | * the specified players 76 | * 77 | * @param players = Players for whom the view is blocked 78 | * @category Visibility 79 | */ 80 | blockViewFor(players: 'all' | 'none' | 'all-but-owner' | Player[]) { 81 | this._screen = players === 'none' ? undefined : players instanceof Array ? players.map(p => p.position) : players 82 | } 83 | 84 | isSpace() { return true; } 85 | 86 | create(className: ElementClass, name: string, attributes?: ElementAttributes): T { 87 | const el = super.create(className, name, attributes); 88 | if ('showTo' in el) this.triggerEvent("enter", el as unknown as Piece); 89 | return el; 90 | } 91 | 92 | addEventHandler(type: keyof Space['_eventHandlers'], handler: ElementEventHandler) { 93 | if (this._ctx.gameManager?.phase === 'started') throw Error('Event handlers cannot be added once game has started.'); 94 | this._eventHandlers[type].push(handler); 95 | } 96 | 97 | /** 98 | * Attach a callback to this space for every element that enters or is created 99 | * within. 100 | * @category Structure 101 | * 102 | * @param type - the class of element that will trigger this callback 103 | * @param callback - Callback will be called each time an element enters, with 104 | * the entering element as the only argument. 105 | * 106 | * @example 107 | * deck.onEnter(Card, card => card.hideFromAll()) // card placed in the deck are automatically turned face down 108 | */ 109 | onEnter(type: ElementClass, callback: (el: T) => void) { 110 | this.addEventHandler("enter", { callback, type }); 111 | } 112 | 113 | /** 114 | * Attach a callback to this space for every element that is moved out of this 115 | * space. 116 | * @category Structure 117 | * 118 | * @param type - the class of element that will trigger this callback 119 | * @param callback - Callback will be called each time an element exits, with 120 | * the exiting element as the only argument. 121 | * 122 | * @example 123 | * deck.onExit(Card, card => card.showToAll()) // cards drawn from the deck are automatically turned face up 124 | */ 125 | onExit(type: ElementClass, callback: (el: T) => void) { 126 | this.addEventHandler("exit", { callback, type }); 127 | } 128 | 129 | triggerEvent(event: keyof Space['_eventHandlers'], element: Piece) { 130 | if (this._visOnEnter) { 131 | element._visible = { 132 | default: this._visOnEnter.default, 133 | except: this._visOnEnter.except === 'owner' ? (this.owner ? [this.owner.position] : undefined) : this._visOnEnter.except 134 | } 135 | } 136 | 137 | for (const handler of this._eventHandlers[event]) { 138 | if (event === 'enter' && !(element instanceof handler.type)) continue; 139 | if (event === 'exit' && !(element instanceof handler.type)) continue; 140 | handler.callback(element); 141 | } 142 | } 143 | } 144 | -------------------------------------------------------------------------------- /src/board/square-grid.ts: -------------------------------------------------------------------------------- 1 | import FixedGrid from "./fixed-grid.js"; 2 | import { times } from '../utils.js'; 3 | 4 | import type Game from './game.js'; 5 | 6 | /** 7 | * A Square grid. Create the SquareGrid with 'rows' and 'columns' values to 8 | * automatically create the spaces. 9 | * @category Board 10 | * 11 | * @example 12 | * game.create(SquareGrid, 'chess-board', { rows: 8, columns: 8 }) 13 | */ 14 | export default class SquareGrid extends FixedGrid { 15 | /** 16 | * Optionally add a measurement for diagonal adjacencies on this grid. If 17 | * undefined, diagonals are not considered directly adjacent. 18 | * @category Adjacency 19 | */ 20 | diagonalDistance?: number; 21 | 22 | _adjacentGridPositionsTo(column: number, row: number): [number, number, number?][] { 23 | const positions: [number, number, number?][] = []; 24 | if (column > 1) { 25 | positions.push([column - 1, row]); 26 | if (this.diagonalDistance !== undefined) { 27 | positions.push([column - 1, row - 1, this.diagonalDistance]); 28 | positions.push([column - 1, row + 1, this.diagonalDistance]); 29 | } 30 | } 31 | if (column < this.columns) { 32 | positions.push([column + 1, row]); 33 | if (this.diagonalDistance !== undefined) { 34 | positions.push([column + 1, row - 1, this.diagonalDistance]); 35 | positions.push([column + 1, row + 1, this.diagonalDistance]); 36 | } 37 | } 38 | positions.push([column, row - 1]); 39 | positions.push([column, row + 1]); 40 | return positions; 41 | } 42 | 43 | _gridPositions(): [number, number][] { 44 | const positions: [number, number][] = []; 45 | times(this.rows, row => times(this.columns, col => positions.push([col, row]))); 46 | return positions; 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /src/board/stack.ts: -------------------------------------------------------------------------------- 1 | import SingleLayout from './single-layout.js'; 2 | 3 | import type { BaseGame } from './game.js'; 4 | import type { ElementUI } from './element.js'; 5 | 6 | /** 7 | * A Stack hides all movement information within to avoid exposing the identity 8 | * of pieces inside the stack. Useful for decks of cards where calling 9 | * `shuffle()` should prevent players from knowing the order of the cards. By 10 | * default elements in a stack are hidden and are rendered as a stack with a 11 | * small offset and a limited number of items. Use configureLayout to change 12 | * this. 13 | */ 14 | export default class Stack extends SingleLayout { 15 | _ui: ElementUI = { 16 | layouts: [], 17 | appearance: {}, 18 | getBaseLayout: () => ({ 19 | columns: 1, 20 | offsetRow: { x: 2, y: 2 }, 21 | scaling: 'fit', 22 | alignment: 'center', 23 | direction: 'ltr', 24 | limit: 10, 25 | }) 26 | }; 27 | 28 | afterCreation() { 29 | this._t.order = 'stacking'; 30 | this.contentsWillBeHidden(); 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/board/utils.ts: -------------------------------------------------------------------------------- 1 | import type { Direction } from './element.js'; 2 | 3 | export function rotateDirection(dir: Direction, rotation: number) { 4 | rotation = (rotation % 360 + 360) % 360; 5 | if (rotation === 0) return dir; 6 | let angle: number; 7 | if (dir === 'up') { 8 | angle = rotation; 9 | } else if (dir === 'down') { 10 | angle = (rotation + 180) % 360; 11 | } else if (dir === 'right') { 12 | angle = (rotation + 90) % 360; 13 | } else { 14 | angle = (rotation + 270) % 360; 15 | } 16 | 17 | if (angle === 0) { 18 | return 'up'; 19 | } else if (angle === 90) { 20 | return 'right'; 21 | } else if (angle === 180) { 22 | return 'down'; 23 | } 24 | return 'left'; 25 | } 26 | -------------------------------------------------------------------------------- /src/components/d6/assets/dice.ogg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/boardzilla/boardzilla-core/c857b309ff6b96886475dd99f04ba380b2d0ccab/src/components/d6/assets/dice.ogg -------------------------------------------------------------------------------- /src/components/d6/assets/index.scss: -------------------------------------------------------------------------------- 1 | .D6 { 2 | > ol { 3 | display: grid; 4 | grid-template-columns: 1fr; 5 | grid-template-rows: 1fr; 6 | list-style-type: none; 7 | transform-style: preserve-3d; 8 | width: 100%; 9 | height: 100%; 10 | margin: 0; 11 | padding: 0; 12 | &[data-spin="up"] { 13 | transition: transform 2s ease-out; 14 | } 15 | &[data-spin="down"] { 16 | transition: transform 2s ease-out; 17 | } 18 | .die-face { 19 | background: #eee; 20 | box-shadow: inset 0 0 0.4em rgba(0, 0, 0, 0.4); 21 | border-radius: .3em; 22 | display: grid; 23 | grid-column: 1; 24 | grid-row: 1; 25 | grid-template-areas: 26 | "one two three" 27 | "four five six" 28 | "sup eight nine"; 29 | grid-template-columns: repeat(3, 1fr); 30 | grid-template-rows: repeat(3, 1fr); 31 | height: 100%; 32 | padding: .7em; 33 | width: 100%; 34 | outline: .01em solid #333; 35 | .dot { 36 | align-self: center; 37 | background-color: #333; 38 | border-radius: 50%; 39 | box-shadow: inset -0.12em 0.12em 0.25em rgba(255, 255, 255, 0.3); 40 | display: block; 41 | height: 1.25em; 42 | justify-self: center; 43 | width: 1.25em; 44 | } 45 | &[data-face="1"] { 46 | transform: rotate3d(0, 0, 0, 90deg) translateZ(3.1em); 47 | } 48 | &[data-face="2"] { 49 | transform: rotate3d(-1, 0, 0, 90deg) translateZ(3.1em); 50 | } 51 | &[data-face="3"] { 52 | transform: rotate3d(0, 1, 0, 90deg) translateZ(3.1em); 53 | } 54 | &[data-face="4"] { 55 | transform: rotate3d(0, -1, 0, 90deg) translateZ(3.1em); 56 | } 57 | &[data-face="5"] { 58 | transform: rotate3d(1, 0, 0, 90deg) translateZ(3.1em); 59 | } 60 | &[data-face="6"] { 61 | transform: rotate3d(0, 1, 0, 180deg) translateZ(3.1em); 62 | } 63 | &[data-face="1"] .dot:nth-of-type(1) { 64 | grid-area: five; 65 | } 66 | &[data-face="2"] .dot:nth-of-type(1) { 67 | grid-area: one; 68 | } 69 | &[data-face="2"] .dot:nth-of-type(2) { 70 | grid-area: nine; 71 | } 72 | &[data-face="3"] .dot:nth-of-type(1) { 73 | grid-area: one; 74 | } 75 | &[data-face="3"] .dot:nth-of-type(2) { 76 | grid-area: five; 77 | } 78 | &[data-face="3"] .dot:nth-of-type(3) { 79 | grid-area: nine; 80 | } 81 | &[data-face="4"] .dot:nth-of-type(1) { 82 | grid-area: one; 83 | } 84 | &[data-face="4"] .dot:nth-of-type(2) { 85 | grid-area: three; 86 | } 87 | &[data-face="4"] .dot:nth-of-type(3) { 88 | grid-area: sup; 89 | } 90 | &[data-face="4"] .dot:nth-of-type(4) { 91 | grid-area: nine; 92 | } 93 | &[data-face="5"] .dot:nth-of-type(1) { 94 | grid-area: one; 95 | } 96 | &[data-face="5"] .dot:nth-of-type(2) { 97 | grid-area: three; 98 | } 99 | &[data-face="5"] .dot:nth-of-type(3) { 100 | grid-area: five; 101 | } 102 | &[data-face="5"] .dot:nth-of-type(4) { 103 | grid-area: sup; 104 | } 105 | &[data-face="5"] .dot:nth-of-type(5) { 106 | grid-area: nine; 107 | } 108 | &[data-face="6"] .dot:nth-of-type(1) { 109 | grid-area: one; 110 | } 111 | &[data-face="6"] .dot:nth-of-type(2) { 112 | grid-area: three; 113 | } 114 | &[data-face="6"] .dot:nth-of-type(3) { 115 | grid-area: four; 116 | } 117 | &[data-face="6"] .dot:nth-of-type(4) { 118 | grid-area: six; 119 | } 120 | &[data-face="6"] .dot:nth-of-type(5) { 121 | grid-area: sup; 122 | } 123 | &[data-face="6"] .dot:nth-of-type(6) { 124 | grid-area: nine; 125 | } 126 | } 127 | } 128 | &[data-current="1"] [data-spin="up"] { 129 | transform: rotateX(360deg) rotateY(720deg) rotateZ(360deg); 130 | } 131 | &[data-current="2"] [data-spin="up"] { 132 | transform: rotateX(450deg) rotateY(720deg) rotateZ(360deg); 133 | } 134 | &[data-current="3"] [data-spin="up"] { 135 | transform: rotateX(360deg) rotateY(630deg) rotateZ(360deg); 136 | } 137 | &[data-current="4"] [data-spin="up"] { 138 | transform: rotateX(360deg) rotateY(810deg) rotateZ(360deg); 139 | } 140 | &[data-current="5"] [data-spin="up"] { 141 | transform: rotateX(270deg) rotateY(720deg) rotateZ(360deg); 142 | } 143 | &[data-current="6"] [data-spin="up"] { 144 | transform: rotateX(360deg) rotateY(900deg) rotateZ(360deg); 145 | } 146 | &[data-current="1"] [data-spin="down"] { 147 | transform: rotateX(-360deg) rotateY(-720deg) rotateZ(-360deg); 148 | } 149 | &[data-current="2"] [data-spin="down"] { 150 | transform: rotateX(-270deg) rotateY(-720deg) rotateZ(-360deg); 151 | } 152 | &[data-current="3"] [data-spin="down"] { 153 | transform: rotateX(-360deg) rotateY(-810deg) rotateZ(-360deg); 154 | } 155 | &[data-current="4"] [data-spin="down"] { 156 | transform: rotateX(-360deg) rotateY(-630deg) rotateZ(-360deg); 157 | } 158 | &[data-current="5"] [data-spin="down"] { 159 | transform: rotateX(-450deg) rotateY(-720deg) rotateZ(-360deg); 160 | } 161 | &[data-current="6"] [data-spin="down"] { 162 | transform: rotateX(-360deg) rotateY(-900deg) rotateZ(-360deg); 163 | } 164 | } 165 | -------------------------------------------------------------------------------- /src/components/d6/d6.ts: -------------------------------------------------------------------------------- 1 | import Piece from '../../board/piece.js'; 2 | 3 | import type Game from '../../board/game.js'; 4 | 5 | /** 6 | * Specialized piece for representing 6-sided dice 7 | * 8 | * @example 9 | * import { D6 } from '@boardzilla/core/components'; 10 | * ... 11 | * game.create(D6, 'my-die'); 12 | * @category Board 13 | */ 14 | export default class D6 extends Piece { 15 | sides: number = 6; 16 | 17 | /** 18 | * Currently shown face 19 | * @category D6 20 | */ 21 | current: number = 1; 22 | rollSequence: number = 0; 23 | 24 | /** 25 | * Randomly choose a new face, causing the roll animation 26 | * @category D6 27 | */ 28 | roll() { 29 | this.current = Math.ceil((this.game.random || Math.random)() * this.sides); 30 | this.rollSequence = this._ctx.gameManager.sequence; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /src/components/d6/index.ts: -------------------------------------------------------------------------------- 1 | export {default as D6} from './d6.js'; 2 | export {default as useD6} from './useD6.js'; 3 | -------------------------------------------------------------------------------- /src/components/d6/useD6.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect, useRef, useState } from 'react'; 2 | 3 | import dice from './assets/dice.ogg'; 4 | import { times } from '../../utils.js'; 5 | import D6 from './d6.js'; 6 | import './assets/index.scss'; 7 | 8 | import type { BaseGame } from '../../board/game.js'; 9 | 10 | /** 11 | * Adds an animated spinning appearance to the {@link D6} class 12 | * 13 | * @example 14 | * import { useD6 } from '@boardzilla/core/components'; 15 | * 16 | * // then in the layout() method 17 | * useD6(game); 18 | */ 19 | const D6Component = ({ die }: { die: D6 }) => { 20 | const diceAudio = useRef(null); 21 | const lastRollSequence = useRef(); 22 | const [flip, setFlip] = useState(false); 23 | 24 | useEffect(() => { 25 | if (die.rollSequence === Math.ceil(die._ctx.gameManager.sequence - 1) && lastRollSequence.current !== undefined && lastRollSequence.current !== die.rollSequence) { 26 | diceAudio.current?.play(); 27 | setFlip(!flip); 28 | } 29 | lastRollSequence.current = die.rollSequence; 30 | }, [die, die.rollSequence, flip, setFlip]); 31 | 32 | return ( 33 | <> 34 |