import React, { useEffect, useState, useContext, useRef, useMemo } from "react"; import equal from "fast-deep-equal"; import { assetsPath } from "./Common.js"; import "./Board.css"; import { GlobalContext } from "./GlobalContext.js"; import { Flock } from "./Bird.js"; import { Herd } from "./Sheep.js"; const rows = [3, 4, 5, 4, 3, 2]; /* The final row of 2 is to place roads and corners */ const hexRatio = 1.1547, tileWidth = 67, tileHalfWidth = tileWidth * 0.5, tileHeight = tileWidth * hexRatio, tileHalfHeight = tileHeight * 0.5, radius = tileHeight * 2, borderOffsetX = 86, /* ~1/10th border image width... hand tuned */ borderOffsetY = 3; /* Actual sizing */ const tileImageWidth = 90, /* Based on hand tuned and image width */ tileImageHeight = tileImageWidth/hexRatio, borderImageWidth = (2 + 2/3) * tileImageWidth, /* 2.667 * .Tile.width */ borderImageHeight = borderImageWidth * 0.29; /* 0.29 * .Border.height */ const Board = () => { const { ws } = useContext(GlobalContext); const board = useRef(); const [transform, setTransform] = useState(1.); const [pipElements, setPipElements] = useState(<>); const [borderElements, setBorderElements] = useState(<>); const [tileElements, setTileElements] = useState(<>); const [cornerElements, setCornerElements] = useState(<>); const [roadElements, setRoadElements] = useState(<>); const [ signature, setSignature ] = useState(""); const [ generated, setGenerated ] = useState(""); const [ robber, setRobber ] = useState(-1); const [ robberName, setRobberName ] = useState([]); const [ pips, setPips ] = useState(); const [ pipOrder, setPipOrder ] = useState(); const [ borders, setBorders ] = useState(); const [ borderOrder, setBorderOrder ] = useState(); const [animationSeeds, setAnimationSeeds] = useState(); const [ tiles, setTiles ] = useState(); const [ tileOrder, setTileOrder ] = useState([]); const [ placements, setPlacements ] = useState(undefined); const [ turn, setTurn ] = useState({}); const [ state, setState ] = useState(""); const [ color, setColor ] = useState(""); const [ rules, setRules ] = useState({}); const [ longestRoadLength, setLongestRoadLength ] = useState(0); const fields = useMemo(() => [ 'signature', 'robber', 'robberName', 'pips', 'pipOrder', 'borders', 'borderOrder', 'tiles', 'tileOrder', 'placements', 'turn', 'state', 'color', 'longestRoadLength', 'rules', 'animationSeeds' ], []); console.log(`board - ws`, ws); const onWsMessage = (event) => { if (ws && ws !== event.target) { console.error(`Disconnect occur?`); } const data = JSON.parse(event.data); switch (data.type) { case 'game-update': console.log(`board - game update`, data.update); if ('robber' in data.update && data.update.robber !== robber) { setRobber(data.update.robber); } if ('robberName' in data.update && data.update.robberName !== robberName) { setRobberName(data.update.robberName); } if ('state' in data.update && data.update.state !== state) { setState(data.update.state); } if ('rules' in data.update && !equal(data.update.rules, rules)) { setRules(data.update.rules); } if ('color' in data.update && data.update.color !== color) { setColor(data.update.color); } if ('longestRoadLength' in data.update && data.update.longestRoadLength !== longestRoadLength) { setLongestRoadLength(data.update.longestRoadLength); } if ('turn' in data.update) { if (!equal(data.update.turn, turn)) { console.log(`board - turn`, data.update.turn); setTurn(data.update.turn); } } if ('placements' in data.update && !equal(data.update.placements, placements)) { console.log(`board - placements`, data.update.placements); setPlacements(data.update.placements); } /* The following are only updated if there is a new game * signature */ if ('pipOrder' in data.update && !equal(data.update.pipOrder, pipOrder)) { console.log(`board - setting new pipOrder`); setPipOrder(data.update.pipOrder); } if ('borderOrder' in data.update && !equal(data.update.borderOrder, borderOrder)) { console.log(`board - setting new borderOrder`); setBorderOrder(data.update.borderOrder); } if ('animationSeeds' in data.update && !equal(data.update.animationSeeds, animationSeeds)) { console.log(`board - setting new animationSeeds`); setAnimationSeeds(data.update.animationSeeds); } if ('tileOrder' in data.update && !equal(data.update.tileOrder, tileOrder)) { console.log(`board - setting new tileOrder`); setTileOrder(data.update.tileOrder); } if (data.update.signature !== signature) { console.log(`board - setting new signature`); setSignature(data.update.signature); } /* This is permanent static data from the server -- do not update * once set */ if ('pips' in data.update && !pips) { console.log(`board - setting new static pips`); setPips(data.update.pips); } if ('tiles' in data.update && !tiles) { console.log(`board - setting new static tiles`); setTiles(data.update.tiles); } if ('borders' in data.update && !borders) { console.log(`board - setting new static borders`); setBorders(data.update.borders); } break; default: break; } }; const refWsMessage = useRef(onWsMessage); useEffect(() => { refWsMessage.current = onWsMessage; }); useEffect(() => { if (!ws) { return; } console.log('board - bind'); const cbMessage = e => refWsMessage.current(e); ws.addEventListener('message', cbMessage); return () => { console.log('board - unbind'); ws.removeEventListener('message', cbMessage); } }, [ws]); useEffect(() => { if (!ws) { return; } ws.send(JSON.stringify({ type: 'get', fields })); }, [ws, fields]); useEffect(() => { const boardBox = board.current.querySelector('.BoardBox'); if (boardBox) { console.log(`board - setting transform scale to ${transform}`); boardBox.style.transform = `scale(${transform})`; } }, [transform]); const onResize = () => { if (!board.current) { return; } /* Adjust the 'transform: scale' for the BoardBox * so the board fills the Board * * The board is H tall, and H * hexRatio wide */ const width = board.current.offsetWidth, height = board.current.offsetHeight; let _transform; if (height * hexRatio > width) { _transform = width / (450. * hexRatio); } else { _transform = height / (450.); } if (transform !== _transform) { setTransform(_transform); } }; const refOnResize = useRef(onResize); useEffect(() => { refOnResize.current = onResize; }); useEffect(() => { const cbOnResize = e => refOnResize.current(e); window.addEventListener('resize', cbOnResize); return () => { window.removeEventListener('resize', cbOnResize); } }, [refOnResize]); onResize(); useEffect(() => { if (!ws) { return; } console.log(`Generating static corner data... should only occur once per reload or socket reconnect.`); const onCornerClicked = (event, corner) => { let type; if (event.currentTarget.getAttribute('data-type') === 'settlement') { type = 'place-city'; } else { type = 'place-settlement'; } ws.send(JSON.stringify({ type, index: corner.index })); }; const Corner = ({corner}) => { return
{ onCornerClicked(event, corner) }} data-index={corner.index} style={{ top: `${corner.top}px`, left: `${corner.left}px` }} >
; }; const generateCorners = () => { let row = 0, rowCount = 0; let y = -8 + 0.5 * tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, x = -tileHalfHeight -(rows[row] - 1) * 0.5 * tileHeight; let index = 0; const corners = []; let corner; for (let i = 0; i < 21; i++) { if (row > 2 && rowCount === 0) { corner = { index: index++, top: y-0.5*tileHalfHeight, left: x-tileHalfHeight }; corners.push(); } corner = { index: index++, top: y, left: x }; corners.push(); corner = { index: index++, top: y-0.5*tileHalfHeight, left: x+tileHalfHeight }; corners.push(); if (++rowCount === rows[row]) { corner = { index: index++, top: y, left: x+2.*tileHalfHeight }; corners.push(); if (row > 2) { corner = { index: index++, top: y-0.5*tileHalfHeight, left: x+3.*tileHalfHeight }; corners.push(); } row++; rowCount = 0; y += tileHeight - 10.5; x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight; } else { x += tileHeight; } } return corners; }; setCornerElements(generateCorners()); }, [ws, setCornerElements]); useEffect(() => { if (!ws) { return; } console.log(`Generating static road data... should only occur once per reload or socket reconnect.`); const Road = ({road}) => { const onRoadClicked = (road) => { console.log(`Road clicked: ${road.index}`); if (!ws) { console.error(`board - onRoadClicked - ws is NULL`); return; } ws.send(JSON.stringify({ type: 'place-road', index: road.index })); }; return
{ onRoadClicked(road) }} data-index={road.index} style={{ transform: `translate(-50%, -50%) rotate(${road.angle}deg)`, top: `${road.top}px`, left: `${road.left}px` }} >
; }; const generateRoads = () => { let row = 0, rowCount = 0; let y = -2.5 + tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, x = -tileHalfHeight -(rows[row] - 1) * 0.5 * tileHeight; let index = 0; let road; const corners = []; for (let i = 0; i < 21; i++) { const lastRow = row === rows.length - 1; if (row > 2 && rowCount === 0) { road = { index: index++, angle: -60, top: y-0.5*tileHalfHeight, left: x-tileHalfHeight }; corners.push(); } road = { index: index++, angle: 240, top: y, left: x }; corners.push(); road = { index: index++, angle: -60, top: y-0.5*tileHalfHeight, left: x+tileHalfHeight }; corners.push(); if (!lastRow) { road = { index: index++, angle: 0, top: y, left: x }; corners.push(); } if (++rowCount === rows[row]) { if (!lastRow) { road = { index: index++, angle: 0, top: y, left: x+2.*tileHalfHeight }; corners.push(); } if (row > 2) { road = { index: index++, angle: 60, top: y-0.5*tileHalfHeight, left: x+3.*tileHalfHeight }; corners.push(); } row++; rowCount = 0; y += tileHeight - 10.5; x = -tileHalfHeight - (rows[row] - 1) * 0.5 * tileHeight; } else { x += tileHeight; } } return corners; } setRoadElements(generateRoads()); }, [ws, setRoadElements]); /* Generate Pip, Tile, and Border elements */ useEffect(() => { if (!ws) { return; } console.log(`board - Generate pip, border, and tile elements`); const Pip = ({pip, className}) => { const onPipClicked = (pip) => { if (!ws) { console.error(`board - sendPlacement - ws is NULL`); return; } ws.send(JSON.stringify({ type: 'place-robber', index: pip.index })); }; return
{ onPipClicked(pip) }} data-roll={pip.roll} data-index={pip.index} style={{ top: `${pip.top}px`, left: `${pip.left}px`, backgroundImage: `url(${assetsPath}/gfx/pip-numbers.png)`, backgroundPositionX: `${ 100. * (pip.order % 6) / 5.}%`, backgroundPositionY: `${ 100 * Math.floor(pip.order / 6) / 5. }%` }} >
; } const generatePips = function (pipOrder) { let row = 0, rowCount = 0; let y = tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, x = -(rows[row] - 1) * 0.5 * tileHeight; let index = 0; let pip; return pipOrder.map(order => { let volcano = false; pip = { roll: pips[order].roll, index: index++, top: y, left: x, order: order }; if ('volcano' in rules && rules[`volcano`].enabled && pip.roll === 7) { pip.order = pips.findIndex( pip => pip.roll === rules[`volcano`].number); pip.roll = rules[`volcano`].number; volcano = true; } const div = ; if (++rowCount === rows[row]) { row++; rowCount = 0; y += tileWidth; x = - (rows[row] - 1) * 0.5 * tileHeight; } else { x += tileHeight; } return div; }); }; const Tile = ({tile}) => { const style = { top: `${tile.top}px`, left: `${tile.left}px`, width: `${tileImageWidth}px`, height: `${tileImageHeight}px`, backgroundImage: `url(${assetsPath}/gfx/tiles-${tile.type}.png)`, backgroundPositionY: `-${tile.card * tileHeight}px` }; if (tile.type === 'volcano') { style.transform = `rotate(-90deg)`; style.top = `${tile.top + 6}px`; style.transformOrigin = '0% 50%'; } return
; }; const generateTiles = function (tileOrder, animationSeeds) { let row = 0, rowCount = 0; let y = tileHalfWidth - (rows.length - 1) * 0.5 * tileWidth, x = -(rows[row] - 1) * 0.5 * tileHeight; let index = 0; return tileOrder.map(order => { const tile = Object.assign({}, tiles[order], { index: index++, left: x, top: y}); const volcanoActive = 'volcano' in rules && rules[`volcano`].enabled; if ('tiles-start-facing-down' in rules && rules[`tiles-start-facing-down`].enabled && state !== 'normal' && state !== 'volcano' && state !== 'winner' && (!volcanoActive || tile.type !== 'desert')) { tile.type = 'jungle'; tile.card = 0; } if (volcanoActive && tile.type === 'desert') { tile.type = 'volcano'; tile.card = 0; } let div; if (tile.type === 'wheat') { div =
; } else if (tile.type === 'sheep') { div =
; } else { div = ; }; if (++rowCount === rows[row]) { row++; rowCount = 0; y += tileWidth; x = - (rows[row] - 1) * 0.5 * tileHeight; } else { x += tileHeight; } return div; }); }; const showTooltip = () => { document.querySelector('.Board .Tooltip').style.display = 'flex'; } const clearTooltip = () => { document.querySelector('.Board .Tooltip').style.display = 'none'; } const calculateBorderSlot = (side, e) => { const borderBox = document.querySelector('.Borders').getBoundingClientRect(); let angle = (360 + Math.floor(90 + Math.atan2(e.pageY - borderBox.top, e.pageX - borderBox.left) * 180 / Math.PI)) % 360 - (side * 60); if (angle > 180) { angle = angle - 360; } let slot = 0; if (angle > -20 && angle < 5) { slot = 1; } else if (angle > 5) { slot = 2; } return slot; } const mouseEnter = (border, side, e) => { const slot = calculateBorderSlot(side, e); if (!border[slot]) { clearTooltip(); return; } const tooltip = document.querySelector('.Board .Tooltip'); tooltip.textContent = border[slot] === 'bank' ? '3 of one kind for 1 resource' : `2 ${border[slot]} for 1 resource`; tooltip.style.top = `${e.pageY}px`; tooltip.style.left = `${e.pageX + 16}px`; showTooltip(); }; const mouseMove = (border, side, e) => { const slot = calculateBorderSlot(side, e); if (!border[slot]) { clearTooltip(); return; } const tooltip = document.querySelector('.Board .Tooltip'); tooltip.textContent = border[slot] === 'bank' ? '3 of one kind for 1 resource' : `2 ${border[slot]} for 1 resource`; tooltip.style.top = `${e.pageY}px`; tooltip.style.left = `${e.pageX + 16}px`; showTooltip(); } const mouseLeave = (border, e) => { clearTooltip(); }; const generateBorders = function(borderOrder) { const sides = 6; let side = -1; return borderOrder.map(order => { const border = borders[order]; side++; let x = + Math.sin(Math.PI - side / sides * 2. * Math.PI) * radius, y = Math.cos(Math.PI - side / sides * 2. * Math.PI) * radius; let prev = (order === 0) ? 6 : order; const file = `borders-${order+1}.${prev}.png`; const value = side; return
{ mouseEnter(border, value, e) }} onMouseMove={(e) => { mouseMove(border, value, e) }} onMouseLeave={mouseLeave} style={{ width: `${borderImageWidth}px`, height: `${borderImageHeight}px`, top: `${y}px`, left: `${x}px`, transform: `rotate(${side*(360/sides)}deg) translate(${borderOffsetX}px, ${borderOffsetY}px) scale(-1, -1)`, backgroundImage: `url(${assetsPath}/gfx/${file} )` }} />; }); }; if (borders && borderOrder) { console.log(`board - Generate board - borders`); setBorderElements(generateBorders(borderOrder)); } if (tiles && tileOrder && animationSeeds) { console.log(`board - Generate board - tiles`); setTileElements(generateTiles(tileOrder, animationSeeds)); } /* Regenerate pips every time; it uses ws */ if (pips && pipOrder) { console.log(`board - Generate board - pips`); setPipElements(generatePips(pipOrder)); } if (signature && signature !== generated) { console.log(`board - Regnerating for ${signature}`); setGenerated(signature); } }, [ signature, generated, pips, pipOrder, borders, borderOrder, tiles, tileOrder, animationSeeds, ws, state, rules ]); /* Re-render turn info after every render */ useEffect(() => { if (!turn) { return; } let nodes = document.querySelectorAll('.Active'); for (let i = 0; i < nodes.length; i++) { nodes[i].classList.remove('Active'); } if (turn.roll) { nodes = document.querySelectorAll(`.Pip[data-roll="${turn.roll}"]`); for (let i = 0; i < nodes.length; i++) { const index = nodes[i].getAttribute('data-index'); if (index !== null) { const tile = document.querySelector(`.Tile[data-index="${index}"]`); if (tile) { tile.classList.add('Active'); } } nodes[i].classList.add('Active'); } } }); /* Re-render placements after every render */ useEffect(() => { if (!placements) { return; } /* Set color and type based on placement data from the server */ placements.corners.forEach((corner, index) => { const el = document.querySelector(`.Corner[data-index="${index}"]`); if (!el) { return; } if (turn.volcano === index) { el.classList.add('Lava'); } else { el.classList.remove('Lava'); } if (!corner.color) { el.removeAttribute('data-color'); el.removeAttribute('data-type'); } else { el.setAttribute('data-color', corner.color); el.setAttribute('data-type', corner.type); } }); placements.roads.forEach((road, index) => { const el = document.querySelector(`.Road[data-index="${index}"]`); if (!el) { return; } if (!road.color) { el.removeAttribute('data-color'); } else { if (road.longestRoad) { if (road.longestRoad === longestRoadLength) { el.classList.add('LongestRoad'); } else { el.classList.remove('LongestRoad'); } el.setAttribute('data-longest', road.longestRoad); } else { el.removeAttribute('data-longest'); } el.setAttribute('data-color', road.color); } }); /* Clear all 'Option' targets */ let nodes = document.querySelectorAll(`.Option`); for (let i = 0; i < nodes.length; i++) { nodes[i].classList.remove('Option'); } /* Add 'Option' based on turn.limits */ if (turn && turn.limits) { if (turn.limits['roads']) { turn.limits['roads'].forEach(index => { const el = document.querySelector(`.Road[data-index="${index}"]`); if (!el) { return; } el.classList.add('Option'); el.setAttribute('data-color', turn.color); }); } if (turn.limits['corners']) { turn.limits['corners'].forEach(index => { const el = document.querySelector(`.Corner[data-index="${index}"]`); if (!el) { return; } el.classList.add('Option'); el.setAttribute('data-color', turn.color); }); } if (turn.limits['tiles']) { turn.limits['tiles'].forEach(index => { const el = document.querySelector(`.Tile[data-index="${index}"]`); if (!el) { return; } el.classList.add('Option'); }); } if (turn.limits['pips']) { turn.limits['pips'].forEach(index => { const el = document.querySelector(`.Pip[data-index="${index}"]`); if (!el) { return; } el.classList.add('Option'); }); } } /* Clear the robber */ nodes = document.querySelectorAll(`.Pip.Robber`); for (let i = 0; i < nodes.length; i++) { nodes[i].classList.remove('Robber'); [ 'Robert', 'Roberta', 'Velocirobber' ].forEach(robberName => nodes[i].classList.remove(robberName) ); } /* Place the robber */ if (robber !== undefined) { const el = document.querySelector(`.Pip[data-index="${robber}"]`); if (el) { el.classList.add('Robber'); el.classList.add(robberName); } } }); const canAction = (action) => { return (turn && Array.isArray(turn.actions) && turn.actions.indexOf(action) !== -1); }; const canRoad = (canAction('place-road') && turn.color === color && (state === 'initial-placement' || state === 'normal')); const canCorner = ((canAction('place-settlement') || canAction('place-city')) && turn.color === color && (state === 'initial-placement' || state === 'normal')); const canPip = (canAction('place-robber') && turn.color === color && (state === 'initial-placement' || state === 'normal')); console.log(`board - tile elements`, tileElements); return (
tooltip
{ borderElements }
{ tileElements }
{ pipElements }
{ cornerElements }
{ roadElements }
); }; export { Board };