@reis/seki
Version:
Seki – A modern javascript based Go board renderer and player, that is simple to use, extensible, compact and intuitive.
1,066 lines (891 loc) • 22.3 kB
JavaScript
import Base from './base.js'
import BoardLayerFactory from './board-layer-factory.js'
import StoneFactory from './stone-factory.js'
import MarkupFactory from './markup-factory.js'
import Theme from './theme.js'
import {defaultBoardConfig} from '../constants/defaults.js'
import {boardLayerTypes} from '../constants/board.js'
import {swapColor} from '../helpers/color.js'
import {
addClass,
removeClass,
throttle,
getPixelRatio,
createElement,
createCanvasContext,
mergeCanvases
} from '../helpers/util.js'
/**
* This class represents the Go board. It is a placeholder for all the various
* board layers and is used for placing and removing objects on the board.
* The class has helpers to figure out the correct size of the grid cells and
* to toggle coordinates on or off. This class is responsible for drawing all
* layers on the board.
*/
export default class Board extends Base {
//Layer order
layerOrder = [
boardLayerTypes.BACKGROUND,
boardLayerTypes.GRID,
boardLayerTypes.COORDINATES,
boardLayerTypes.SHADOW,
boardLayerTypes.STONES,
boardLayerTypes.SCORE,
boardLayerTypes.MARKUP,
boardLayerTypes.DRAW,
boardLayerTypes.HOVER,
]
//Board draw dimensions in pixels
cellSize = 0
drawWidth = 0
drawHeight = 0
drawMarginHor = 0
drawMarginVer = 0
gridDrawWidth = 0
gridDrawHeight = 0
//Last draw width/height values for change tracking
lastDrawWidth = 0
lastDrawHeight = 0
/**
* Board constructor
*/
constructor(boardConfig, themeConfig) {
//Parent constructor
super()
//Theme config passed via board config
if (!themeConfig && boardConfig.theme) {
themeConfig = boardConfig.theme
}
//Instantiate theme and layers map
this.theme = new Theme(themeConfig)
this.layers = new Map()
this.elements = {}
//Debug
this.debug('initialising board')
//Initialize board
this.init()
this.initConfig(boardConfig)
//Create config event listeners
this.setupConfigListeners()
}
/**
* Initialize board
*/
init() {
//Initialize board size
this.width = 19
this.height = 19
}
/**
* Reset board
*/
reset() {
//Preserve config
const {config} = this
//Reinitialise board (no need to recreate layers)
this.removeAll()
this.init()
this.initConfig(config)
}
/**************************************************************************
* Virtual getters
***/
/**
* Whether we have been bootstrapped (e.g. elements linked)
*/
get isBootstrapped() {
return (
this.elements &&
this.elements.container
)
}
/**
* Cut-off config
*/
get cutOffLeft() {
return this.getConfig('cutOffLeft', false)
}
get cutOffRight() {
return this.getConfig('cutOffRight', false)
}
get cutOffTop() {
return this.getConfig('cutOffTop', false)
}
get cutOffBottom() {
return this.getConfig('cutOffBottom', false)
}
/**
* Actual grid width and height, factoring in cut-off
*/
get gridWidth() {
const {width, cutOffLeft, cutOffRight} = this
return width - cutOffLeft - cutOffRight
}
get gridHeight() {
const {height, cutOffTop, cutOffBottom} = this
return height - cutOffTop - cutOffBottom
}
/**
* Board's x & y coordinates
*/
get xLeft() {
return 0 + this.cutOffLeft
}
get xRight() {
return this.width - 1 - this.cutOffRight
}
get yTop() {
return 0 + this.cutOffTop
}
get yBottom() {
return this.height - 1 - this.cutOffBottom
}
/**
* Get margin from theme
*/
get margin() {
//Get data
const {theme} = this
const showCoordinates = this.getConfig('showCoordinates')
//Check if showing coordinates
if (showCoordinates) {
return theme.get('coordinates.margin', 0)
}
return theme.get('board.margin', 0)
}
/**************************************************************************
* Layer handling
***/
/**
* Create layers
*/
createLayers() {
for (const type of this.layerOrder) {
this.createLayer(type)
}
}
/**
* Create layer of given type
*/
createLayer(type) {
//Create layer
const {layers, width, height} = this
const layer = BoardLayerFactory.create(type, this)
//Set grid size and store layer in map
layer.setGridSize(width, height)
layers.set(type, layer)
}
/**
* Get layer of given type
*/
getLayer(type) {
return this.layers.get(type)
}
/*****************************************************************************
* Configuration
***/
/**
* Initialise config
*/
initConfig(config) {
//Extend from default config
super.initConfig(config, defaultBoardConfig)
//Load size from config
this.loadSizeFromConfig()
}
/**
* Load config from game info
*/
loadConfigFromGame(game) {
//Get board config
const config = game.getBoardConfig()
//Load it and redraw
this.loadConfig(config)
this.loadSizeFromConfig()
}
/**
* Load size from config
*/
loadSizeFromConfig() {
//Get sizing
const {size, width, height} = this.config
//Set size
if (width && height) {
this.setSize(width, height)
}
else if (size) {
this.setSize(size)
}
}
/**
* Set board (grid) size. This will clear the board objects.
*/
setSize(width, height) {
//Check what's given
width = parseInt(width || 0)
height = parseInt(height || width || 0)
//Invalid?
if (isNaN(width) || isNaN(height)) {
return
}
//No change
if (width === this.width && height === this.height) {
return
}
//Remember size
this.width = width
this.height = height
//Set size in each layer
this.layers.forEach(layer => layer.setGridSize(width, height))
//Compute and redraw
this.computeAndRedraw('setSize')
}
/**
* Set new draw size
*/
setDrawSize(drawWidth, drawHeight) {
//No change
if (drawWidth === this.drawWidth && drawHeight === this.drawHeight) {
return
}
//Set
this.drawWidth = drawWidth
this.drawHeight = drawHeight
//Redraw
this.computeAndRedraw('setDrawSize')
}
/**
* Get display color for a stone
*/
getDisplayColor(color) {
if (this.getConfig('swapColors')) {
return swapColor(color)
}
return color
}
/*****************************************************************************
* Object handling
***/
/**
* Has layer check
*/
hasLayer(type) {
return this.layers.has(type)
}
/**
* Add an object to a board layer
*/
add(type, x, y, value) {
const layer = this.getLayer(type)
if (layer) {
layer.add(x, y, value)
}
}
/**
* Remove an object from a board layer
*/
remove(type, x, y) {
const layer = this.getLayer(type)
if (layer) {
layer.remove(x, y)
}
}
/**
* Get something from a board layer
*/
get(type, x, y) {
const layer = this.getLayer(type)
if (layer) {
return layer.get(x, y)
}
return null
}
/**
* Check if we have something at given coordinates for a given layer
*/
has(type, x, y) {
const layer = this.getLayer(type)
if (layer) {
return layer.has(x, y)
}
return false
}
/**
* Set all objects (grid) for a given layer
*/
setAll(type, ...args) {
const layer = this.getLayer(type)
if (layer) {
layer.setAll(...args)
}
}
/**
* Remove all objects from the board, optionally for a given layer
*/
removeAll(type) {
//Specific layer type
if (type) {
const layer = this.getLayer(type)
if (layer) {
layer.removeAll()
}
return
}
//All layers
this.layers.forEach(layer => layer.removeAll())
}
/*****************************************************************************
* Position handling
***/
/**
* Update the board with a new position
*/
updatePosition(position) {
//If we have no grid size yet, use what's in the position
if (!this.width || !this.height) {
this.setSize(position.width, position.height)
}
//Get theme
const {theme} = this
const style = theme.get('board.stoneStyle')
//Transform stones grid into actual stone instances of given style
const stones = position.stones
.map(color => StoneFactory
.create(style, color, this))
//Do the same for markup
const markup = position.markup
.map(({type, text}) => MarkupFactory
.create(type, this, {text}))
//Get lines
const {lines} = position
//Redraw gird
this.redrawLayer(boardLayerTypes.GRID)
//Set new stones and markup grids
this.setAll(boardLayerTypes.STONES, stones)
this.setAll(boardLayerTypes.MARKUP, markup)
this.setAll(boardLayerTypes.DRAW, lines)
}
/*****************************************************************************
* Drawing control
***/
/**
* Erase the whole board
*/
erase() {
this.layers
.forEach(layer => layer.erase())
}
/**
* Erase a specific layer
*/
eraseLayer(type) {
const layer = this.layers.get(type)
if (layer) {
layer.erase()
}
}
/**
* Redraw the whole board
*/
redraw() {
//Check if can draw
if (!this.canDraw()) {
return
}
//Debug
this.debug('🎨 redrawing')
//Redraw all layers
this.layers.forEach(layer => layer.redraw())
}
/**
* Redraw layer
*/
redrawLayer(type) {
const layer = this.layers.get(type)
if (layer) {
layer.redraw()
}
}
/**
* Redraw cell on given layer
*/
redrawCell(type, x, y) {
const layer = this.layers.get(type)
if (layer) {
layer.redrawCell(x, y)
}
}
/**
* Can draw check
*/
canDraw() {
const {width, height, drawWidth, drawHeight} = this
return Boolean(width && height && drawWidth && drawHeight)
}
/**************************************************************************
* Helper to perform common actions on specific layers
***/
/**
* Set a specific hover cell
*/
setHoverCell(x, y, object) {
this.add(boardLayerTypes.HOVER, x, y, object)
}
/**
* Add objects to the hover layer
*/
setHoverArea(area, object) {
for (const {x, y} of area) {
this.setHoverCell(x, y, object)
}
}
/**
* Clear a specific hover cell
*/
clearHoverCell(x, y) {
this.remove(boardLayerTypes.HOVER, x, y)
this.redrawGridCell(x, y)
}
/**
* Clear a hover area
*/
clearHoverArea(area) {
for (const {x, y} of area) {
this.clearHoverCell(x, y)
}
}
/**
* Clear entire hover layer
*/
clearHoverLayer() {
this.removeAll(boardLayerTypes.HOVER)
}
/**
* Remove markup from a specific cell
*/
removeMarkup(x, y) {
this.remove(boardLayerTypes.MARKUP, x, y)
this.redrawGridCell(x, y)
}
/**
* Remove markup from area
*/
removeMarkupFromArea(area) {
for (const {x, y} of area) {
this.removeMarkup(x, y)
}
}
/**
* Remove all markup
*/
removeAllMarkup() {
this.removeAll(boardLayerTypes.MARKUP)
}
/**
* Remove stone from a specific cell
*/
removeStone(x, y) {
this.remove(boardLayerTypes.STONES, x, y)
}
/**
* Remove stones from area
*/
removeStonesFromArea(area) {
for (const {x, y} of area) {
this.removeStone(x, y)
}
}
/**
* Redraw a grid cell if needed
*/
redrawGridCell(x, y) {
//Stone here, not needed
if (this.has(boardLayerTypes.STONES, x, y)) {
return
}
//Markup here, keep as is
if (this.has(boardLayerTypes.MARKUP, x, y)) {
return
}
//Redraw cell
this
.getLayer(boardLayerTypes.GRID)
?.redrawCell(x, y)
}
/**
* Draw line directly on board
*/
drawLine(fromX, fromY, toX, toY, color) {
this
.getLayer(boardLayerTypes.DRAW)
.drawLine(fromX, fromY, toX, toY, color)
}
/**
* Remove all lines
*/
removeAllLines() {
this.removeAll(boardLayerTypes.DRAW)
}
/*****************************************************************************
* Drawing helpers
***/
/**
* Compute draw parameters and redraw board
* Called after a board size change, draw size change, section change or margin change
*/
computeAndRedraw(calledFrom) {
//If we can't redraw, then this doesn't make sense either
if (!this.canDraw()) {
return
}
//Debug
this.debug(`compute & redraw, called from: ${calledFrom}`)
//Get data
const {
gridWidth, gridHeight,
drawWidth, drawHeight,
margin,
} = this
//Determine number of cells horizontally and vertically
//The margin is a factor of the cell size, so let's add it to the number of cells
const numCellsHor = gridWidth + margin
const numCellsVer = gridHeight + margin
//Determine cell size now
const cellSize = Math.floor(Math.min(
drawWidth / numCellsHor,
drawHeight / numCellsVer
))
//Determine actual grid draw size (taking off the margin again)
const gridDrawWidth = cellSize * (numCellsHor - margin - 1)
const gridDrawHeight = cellSize * (numCellsVer - margin - 1)
//Determine draw margins
const drawMarginHor = Math.floor((drawWidth - gridDrawWidth) / 2)
const drawMarginVer = Math.floor((drawHeight - gridDrawHeight) / 2)
//Debug
// this.debug({
// margin,
// cellSize,
// cellSizeHor: drawWidth / numCellsHor,
// cellSizeVer: drawHeight / numCellsVer,
// gridWidth,
// gridHeight,
// numCellsHor,
// numCellsVer,
// gridDrawWidth,
// gridDrawHeight,
// drawWidth,
// drawHeight,
// drawMarginHor,
// drawMarginVer,
// })
//Set values
this.cellSize = cellSize
this.gridDrawWidth = gridDrawWidth
this.gridDrawHeight = gridDrawHeight
this.drawMarginHor = drawMarginHor
this.drawMarginVer = drawMarginVer
//Redraw
this.redraw()
}
/**
* Get the current cell size
*/
getCellSize() {
return this.cellSize
}
/**
* Convert grid coordinate to pixel coordinate
*/
getAbsX(x) {
const {cutOffLeft, drawMarginHor, cellSize} = this
const offset = -cutOffLeft
return drawMarginHor + Math.round((x + offset) * cellSize)
}
/**
* Convert grid coordinate to pixel coordinate
*/
getAbsY(y) {
const {cutOffTop, drawMarginVer, cellSize} = this
const offset = -cutOffTop
return drawMarginVer + Math.round((y + offset) * cellSize)
}
/**
* Convert pixel coordinate to grid coordinate
*/
getGridX(absX, rounded = true) {
const {cutOffLeft, drawMarginHor, cellSize} = this
const offset = -cutOffLeft
const val = (absX - drawMarginHor) / cellSize - offset
const x = rounded ? Math.round(val) : val
return Object.is(x, -0) ? 0 : x
}
/**
* Convert pixel coordinate to grid coordinate
*/
getGridY(absY, rounded = true) {
const {cutOffTop, drawMarginVer, cellSize} = this
const offset = -cutOffTop
const val = (absY - drawMarginVer) / cellSize - offset
const y = rounded ? Math.round(val) : val
return Object.is(y, -0) ? 0 : y
}
/**
* Check if given grid coordinates are on board
*/
isOnBoard(x, y) {
const {xLeft, xRight, yTop, yBottom} = this
return (
x >= xLeft && y >= yTop &&
x <= xRight && y <= yBottom
)
}
/**************************************************************************
* Bootstrapping
***/
/**
* Bootstrap board onto element
*/
bootstrap(container) {
this.setupElements(container)
this.createLayers()
this.createLayerContexts()
this.setupResizeObserver()
this.makeVisible()
}
/**
* Setup board elements
*/
setupElements(container) {
//Reset elements
this.elements = {}
//Create elements
const wrapper = createElement(container, `seki-board-wrapper`)
const board = createElement(wrapper, `seki-board`)
const canvasses = createElement(board, `seki-board-canvas-container`)
//Set element references
this.elements = {
container,
wrapper,
board,
canvasses,
}
}
/**
* Add class to board element
*/
addClass(className) {
addClass(this.elements.board, className)
}
/**
* Remove class from board element
*/
removeClass(className) {
removeClass(this.elements.board, className)
}
/**
* Make visible
*/
makeVisible() {
const {board} = this.elements
setTimeout(() => {
board.style.visibility = 'visible'
}, 150)
}
/**
* Create layer contexts
*/
createLayerContexts() {
//Get data
const {elements, layers} = this
const {canvasses} = elements
//Create for each layer
layers.forEach(layer => {
const context = createCanvasContext(
canvasses, `seki-board-layer-${layer.type}`
)
layer.setContext(context)
})
//Store canvases as elements array
elements.canvasses = Array.from(
canvasses.getElementsByTagName('canvas')
)
}
/**
* Setup window listeners
*/
setupResizeObserver() {
//Create throttled resize handler
const fn = throttle(() => {
this.recalculateDrawSize()
}, 100)
//Create observer
const resizeObserver = new ResizeObserver(fn)
//Observe the document body
resizeObserver.observe(document.body)
}
/**
* Recalculate draw size
*/
recalculateDrawSize() {
//Can only recalculate when we've been bootstrapped onto a container element
if (!this.elements.container) {
return
}
//Get data
const {lastDrawWidth, lastDrawHeight} = this
const {drawWidth, drawHeight} = this.getDrawSize()
const hasChanged = (
lastDrawWidth !== drawWidth ||
lastDrawHeight !== drawHeight
)
//Propagate if it has changed
if (hasChanged) {
this.propagateDrawSize(drawWidth, drawHeight)
}
}
/**
* Get available width and height within parent container
*/
getAvailableSize() {
//Get data
const {container} = this.elements
//Return size of canvas container
return {
availableWidth: container.clientWidth,
availableHeight: container.clientHeight,
}
}
/**
* Determine draw width and height
*/
getDrawSize() {
//Get data
const {availableWidth, availableHeight} = this.getAvailableSize()
const {gridWidth, gridHeight, margin} = this
//Grid size known?
if (gridWidth && gridHeight) {
//Determine number of cells horizontally and vertically
//The margin is a factor of the cell size, so let's add it to the number of cells
const numCellsHor = gridWidth + margin
const numCellsVer = gridHeight + margin
//Determine cell size now
const cellSize = Math.min(
availableWidth / numCellsHor,
availableHeight / numCellsVer
)
//Set draw size
const drawWidth = Math.floor(cellSize * numCellsHor)
const drawHeight = Math.floor(cellSize * numCellsVer)
//Return
return {drawWidth, drawHeight}
}
//Use the lesser of available width/height
const drawWidth = Math.min(availableWidth, availableHeight)
const drawHeight = drawWidth
//Return
return {drawWidth, drawHeight}
}
/**
* Propagate draw size
*/
propagateDrawSize(width, height) {
//Store last draw width/height (unmodified by pixel ratio)
this.lastDrawWidth = width
this.lastDrawHeight = height
//Not bootstrapped yet
if (!this.isBootstrapped) {
return
}
//Get elements
const {board, canvasses} = this.elements
const pixelRatio = getPixelRatio()
//Set the new dimension on the main board element
board.style.width = `${width}px`
board.style.height = `${height}px`
//Set the new dimensions on the canvas elements
canvasses
.forEach(canvas => {
canvas.width = width * pixelRatio
canvas.height = height * pixelRatio
})
//Now set the draw size on the board itself
//This will trigger a compute and redraw
this.setDrawSize(
width * pixelRatio,
height * pixelRatio
)
}
/**
* Setup config change listeners
*/
setupConfigListeners() {
//These need recalculation of draw size
const needsDrawSize = [
'cutOffTop',
'cutOffBottom',
'cutOffLeft',
'cutOffRight',
]
//These need a redraw
const needsRedraw = [
'showCoordinates',
'showStarPoints',
'swapColors',
]
//Create throttled config change handler
const fn = throttle(event => {
//Check what has changed
const {key, value} = event.detail
this.debug(`${key} changed to ${value}`)
//Need to recalculate draw size?
if (needsDrawSize.includes(key)) {
this.recalculateDrawSize()
this.computeAndRedraw(`${key} value changed`)
}
//Need to reprocess position?
else if (needsRedraw.includes(key)) {
this.computeAndRedraw(`${key} value changed`)
}
}, 100)
//Config change
this.on('config', fn)
}
/**
* Link to player
*/
linkPlayer(player) {
//Link player
this.player = player
//Config to pass from player to board
const boardConfig = [
'showCoordinates',
'showStarPoints',
'swapColors',
]
//Set up event listener
player.on('config', event => {
//Check what has changed
const {key, value} = event.detail
//Pass on to board
if (boardConfig.includes(key)) {
this.setConfig(key, value)
}
})
//Grab initial settings
for (const key of boardConfig) {
this.setConfig(key, player.getConfig(key))
}
}
/**
* Get merged canvas of board
*/
getCanvas() {
//Get canvases and merged them
const {layers} = this
const canvases = Array
.from(layers.values())
.map(layer => layer.context.canvas)
//Return merged canvasses
return mergeCanvases(canvases)
}
}