From 50529a4eeadbd79d2e8977a439c6a4bcf0580514 Mon Sep 17 00:00:00 2001 From: Andrio Celos Date: Wed, 12 Jul 2023 23:03:47 +1000 Subject: [PATCH] Add ink placement and special space animations --- TableturfBattleClient/src/Board.ts | 98 +++++++++++++++++++-- TableturfBattleClient/src/Pages/GamePage.ts | 35 ++++++-- TableturfBattleClient/src/PlayerBar.ts | 17 ++++ TableturfBattleClient/tableturf.css | 70 +++++++++++++-- 4 files changed, 201 insertions(+), 19 deletions(-) diff --git a/TableturfBattleClient/src/Board.ts b/TableturfBattleClient/src/Board.ts index 803ef8d..7426f36 100644 --- a/TableturfBattleClient/src/Board.ts +++ b/TableturfBattleClient/src/Board.ts @@ -6,17 +6,19 @@ class Board { playerIndex: number | null = 0; cardPlaying: Card | null = null; cardRotation = 0; - specialAttack = false; + _specialAttack = false; autoHighlight = true; flip = false; highlightX = NaN; highlightY = NaN; - private highlightedCells: [x: number, y: number][] = [ ]; + private highlightedCells: Point[] = [ ]; + private animatedCells: Point[] = [ ]; + private specialAnimatedCells: [point: Point, elements: HTMLElement[]][] = [ ]; private touch: [index: number, x: number, y: number, highlightX: number, highlightY: number] | null = null; - startSpaces: { x: number, y: number }[] = [ ]; + startSpaces: Point[] = [ ]; onsubmit: ((x: number, y: number) => void) | null = null; oncancel: (() => void) | null = null; @@ -100,6 +102,15 @@ class Board { }); } + get specialAttack() { return this._specialAttack; } + set specialAttack(value: boolean) { + this._specialAttack = value; + if (value) + this.table.classList.add('specialAttackVisual'); + else + this.table.classList.remove('specialAttackVisual'); + } + moveHighlight(move: (x: number, y: number, r: number) => [number, number, number], clamp: boolean) { if (this.playerIndex == null) return; if (this.highlightedCells.length == 0 || isNaN(this.highlightX) || isNaN(this.highlightY) || isNaN(this.cardRotation)) { @@ -185,7 +196,7 @@ class Board { cell.classList.add('hoverillegal'); if (space == Space.SpecialInactive1) cell.classList.add('hoverspecial'); - this.highlightedCells.push([x2, y2]); + this.highlightedCells.push({ x: x2, y: y2 }); } } } @@ -201,18 +212,82 @@ class Board { } private internalClearHighlight() { - for (const [x, y] of this.highlightedCells) { - this.cells[x][y].setAttribute('class', Space[this.grid[x][y]] ); + for (const s of this.highlightedCells) { + this.cells[s.x][s.y].setAttribute('class', Space[this.grid[s.x][s.y]] ); } this.highlightedCells.splice(0); } + enableInkAnimations() { + this.table.classList.add('enableInkAnimation'); + } + + showInkAnimation(x: number, y: number) { + this.animatedCells.push({ x, y }); + this.cells[x][y].classList.add('inkAnimation'); + } + + clearInkAnimations() { + for (const s of this.animatedCells) + this.cells[s.x][s.y].classList.remove('inkAnimation'); + this.animatedCells.splice(0); + this.table.classList.remove('enableInkAnimation'); + } + + showSubmitAnimation() { + for (const s of this.highlightedCells) + this.cells[s.x][s.y].classList.add('submitted'); + } + + private showSpecialAnimation(x: number, y: number) { + if ((this.grid[x][y] & Space.SpecialActive1) != Space.SpecialActive1 || this.specialAnimatedCells.find(el => el[0].x == x && el[0].y == y)) + return; + + const parent = this.cells[x][y]; + const els = [ ]; + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + els.push(this.createSpecialAnimationElement(parent)); + this.specialAnimatedCells.push([{ x, y }, els]); + } + + private createSpecialAnimationElement(parent: Element) { + const el = document.createElement('div'); + parent.appendChild(el); + return el; + } + + private clearSpecialAnimation(x: number, y: number) { + const i = this.specialAnimatedCells.findIndex(el => el[0].x == x && el[0].y == y); + if (i < 0) return; + + for (const el of this.specialAnimatedCells[i][1]) + el.parentElement!.removeChild(el); + this.specialAnimatedCells.splice(i, 1); + } + + clearAllSpecialAnimations() { + for (const entry of this.specialAnimatedCells) { + for (const el of entry[1]) + el.parentElement!.removeChild(el); + } + this.specialAnimatedCells.splice(0); + } + resize(grid?: Space[][]) { if (grid) this.grid = grid; clearChildren(this.table); this.cells.splice(0); this.highlightedCells.splice(0); + this.specialAnimatedCells.splice(0); const boardWidth = this.grid.length; const boardHeight = this.grid[0].length; @@ -229,7 +304,8 @@ class Board { col.push(td); td.addEventListener('click', () => { if (this.autoHighlight && this.cardPlaying != null && this.onsubmit && !isNaN(this.highlightX) && !isNaN(this.highlightY)) { - this.onsubmit(this.highlightX, this.highlightY); + if (this.onsubmit) + this.onsubmit(this.highlightX, this.highlightY); } }); td.addEventListener('contextmenu', e => e.preventDefault()); @@ -291,15 +367,21 @@ class Board { refresh() { this.clearHighlight(); + this.clearInkAnimations(); for (let x = 0; x < this.grid.length; x++) { for (let y = 0; y < this.grid[x].length; y++) { - this.cells[x][y].setAttribute('class', Space[this.grid[x][y]] ); + this.setDisplayedSpace(x, y, this.grid[x][y]); } } } setDisplayedSpace(x: number, y: number, newState: Space) { this.cells[x][y].setAttribute('class', Space[newState]); + if (this.cells[x][y].childNodes.length > 0) { + if ((newState & Space.SpecialActive1) != Space.SpecialActive1) + this.clearSpecialAnimation(x, y); + } else + this.showSpecialAnimation(x, y); } getScores() { diff --git a/TableturfBattleClient/src/Pages/GamePage.ts b/TableturfBattleClient/src/Pages/GamePage.ts index 020704c..5ef03e4 100644 --- a/TableturfBattleClient/src/Pages/GamePage.ts +++ b/TableturfBattleClient/src/Pages/GamePage.ts @@ -229,7 +229,7 @@ replayNextButton.addEventListener('click', _ => { replayAnimationAbortController = new AbortController(); (async () => { - await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.turnNumber, players: currentGame.players }, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, anySpecialAttacks, replayAnimationAbortController.signal); + await playInkAnimations({ game: { state: GameState.Ongoing, board: null, turnNumber: currentGame.turnNumber, players: currentGame.players }, moves, placements: result.placements, specialSpacesActivated: result.specialSpacesActivated }, anySpecialAttacks, replayAnimationAbortController.signal); turnNumberLabel.setTurnNumber(currentGame.turnNumber); clearPlayContainers(); if (currentGame.turnNumber > 12) { @@ -527,6 +527,7 @@ function lockGamePage() { async function playInkAnimations(data: { game: { state: GameState, board: Space[][] | null, turnNumber: number, players: Player[] }, + moves: Move[], placements: Placement[], specialSpacesActivated: Point[] }, anySpecialAttacks: boolean, abortSignal?: AbortSignal) { @@ -535,9 +536,16 @@ async function playInkAnimations(data: { board.clearHighlight(); board.cardPlaying = null; board.autoHighlight = false; + board.specialAttack = false; canPlay = false; timeLabel.faded = true; await delay(anySpecialAttacks ? 3000 : 1000, abortSignal); + for (let i = 0; i < data.game.players.length; i++) { + if ((data.moves[i] as PlayMove).isSpecialAttack) + playerBars[i].specialPoints -= data.moves[i].card.specialCost; + playerBars[i].highlightSpecialPoints = 0; + } + board.enableInkAnimations(); for (const placement of placements) { // Skip the delay when cards don't overlap. if (placement.spacesAffected.find(p => inkPlaced.has(p.space.y * 37 + p.space.x))) { @@ -548,9 +556,12 @@ async function playInkAnimations(data: { for (const p of placement.spacesAffected) { inkPlaced.add(p.space.y * 37 + p.space.x); board.setDisplayedSpace(p.space.x, p.space.y, p.newState); + board.showInkAnimation(p.space.x, p.space.y); } } - await delay(1000, abortSignal); + await delay(500, abortSignal); + board.clearInkAnimations(); + await delay(500, abortSignal); // Show special spaces. if (data.game.board) @@ -679,6 +690,8 @@ function updateHandAndDeck(playerData: PlayerData) { } } else { board.cardPlaying = card; + if (specialButton.checked) + playerBars[currentGame!.me!.playerIndex].highlightSpecialPoints = card.specialCost; if (isNaN(board.highlightX) || isNaN(board.highlightY)) { board.highlightX = board.startSpaces[board.playerIndex!].x - (board.flip ? 4 : 3); board.highlightY = board.startSpaces[board.playerIndex!].y - (board.flip ? 4 : 3); @@ -784,6 +797,7 @@ function passButton_click(e: Event) { clearPlayHint(); specialButton.checked = false; board.cardPlaying = null; + playerBars[currentGame!.me!.playerIndex].highlightSpecialPoints = 0; board.specialAttack = false; board.clearHighlight(); handButtons.deselect(); @@ -809,6 +823,7 @@ function specialButton_click(e: Event) { specialButton.checked = !specialButton.checked; board.specialAttack = specialButton.checked; handButtons.deselect(); + playerBars[currentGame!.me!.playerIndex].highlightSpecialPoints = 0; if (specialButton.checked) { passHint.hidden = true; showPlayHint('Unleash it next to
 
!'); @@ -857,9 +872,16 @@ board.onsubmit = (x, y) => { } if (testMode) { const move: PlayMove = { card: board.cardPlaying, isPass: false, isTimeout: false, x, y, rotation: board.cardRotation, isSpecialAttack: false }; - const r = board.makePlacements([ move ]); - testPlacements.push({ card: board.cardPlaying, placementResults: r }); - board.refresh(); + const result = board.makePlacements([ move ]); + testPlacements.push({ card: board.cardPlaying, placementResults: result }); + board.enableInkAnimations(); + for (const p of result.placements[0].spacesAffected) { + board.setDisplayedSpace(p.space.x, p.space.y, p.newState); + board.showInkAnimation(p.space.x, p.space.y); + } + + if (result.specialSpacesActivated.length > 0) + setTimeout(() => board.refresh(), 333); var li = document.createElement('div'); li.innerText = board.cardPlaying.name; @@ -879,6 +901,7 @@ board.onsubmit = (x, y) => { board.cardPlaying = null; testUndoButton.enabled = true; } else if (canPlay) { + board.showSubmitAnimation(); timeLabel.faded = true; lockGamePage(); let req = new XMLHttpRequest(); @@ -903,6 +926,8 @@ board.onsubmit = (x, y) => { board.oncancel = () => { board.cardPlaying = null; + if (currentGame?.me) + playerBars[currentGame.me.playerIndex].highlightSpecialPoints = 0; board.clearHighlight(); for (const button of handButtons.buttons) { if (button.checked) { diff --git a/TableturfBattleClient/src/PlayerBar.ts b/TableturfBattleClient/src/PlayerBar.ts index e766cb0..a6fd255 100644 --- a/TableturfBattleClient/src/PlayerBar.ts +++ b/TableturfBattleClient/src/PlayerBar.ts @@ -66,6 +66,23 @@ class PlayerBar { } } + set highlightSpecialPoints(value: number) { + const points = this.specialPointsContainer.getElementsByClassName('specialPoint'); + for (let i = 0; i < points.length; i++) { + if (i < value) { + points[i].classList.add('specialAnimation'); + for (let j = 0; j < 10; j++) { + const el = document.createElement('div'); + points[i].appendChild(el); + } + } else { + points[i].classList.remove('specialAnimation'); + clearChildren(points[i]); + (points[i] as HTMLElement).innerText = `${i + 1}`; + } + } + } + get pointsTo() { return this.pointsToContainer.hidden ? null : parseInt(this.pointsToElement.innerText); } set pointsTo(value: number | null) { if (value == null || value == 0) // A player can never actually have 0 points because they start with a permanent special space. diff --git a/TableturfBattleClient/tableturf.css b/TableturfBattleClient/tableturf.css index 18a961c..1c6c90f 100644 --- a/TableturfBattleClient/tableturf.css +++ b/TableturfBattleClient/tableturf.css @@ -422,14 +422,60 @@ dialog::backdrop { #gameBoard td.SpecialInactive2 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-2); } #gameBoard td.SpecialInactive3 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-3); } #gameBoard td.SpecialInactive4 { background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour-4); } -#gameBoard td.SpecialActive1 { background: url('assets/SpecialOverlay.png') center/contain, radial-gradient(circle, var(--special-accent-colour-1) 25%, var(--special-colour-1) 75%); } +#gameBoard td.SpecialActive1 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-1) 25%, var(--special-colour-1) 75%); } #gameBoard td.SpecialActive2 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-2) 25%, var(--special-colour-2) 75%); } #gameBoard td.SpecialActive3 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-3) 25%, var(--special-colour-3) 75%); } #gameBoard td.SpecialActive4 { background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour-4) 25%, var(--special-colour-4) 75%); } +#gameBoard.specialAttackVisual td:is(.Ink1, .Ink2, .Ink3, .Ink4) { + opacity: 0.5; +} + +#gameBoard.enableInkAnimation td.inkAnimation { animation: 0.333s inkPlaceAnimation; } +#gameBoard td.submitted { animation: 0.333s inkPlaceAnimation; } + +@keyframes inkPlaceAnimation { + from { box-shadow: 0 0 0px 0 #ffffffff; } + to { box-shadow: 0 0 50px 10px #ffffff00; } +} + +:is(#gameBoard td, .specialAnimation) > div { + position: absolute; + border-radius: var(--anim-size); + filter: blur(calc(var(--anim-size) * 0.1)); + animation: 1.5s linear infinite specialSpaceAnimation; + left: calc(var(--x) * 0.01 * var(--anim-size)); + top: calc(var(--y) * 0.01 * var(--anim-size)); + width: calc(var(--w) * 0.01 * var(--anim-size)); + height: calc(var(--w) * 0.01 * var(--anim-size)); + z-index: 1; +} +#gameBoard td > div { --anim-size: min(3.5vh, 3.5vw); } +#gameBoard td.SpecialActive1 > div { background: color-mix(in srgb, var(--special-colour-1), var(--special-accent-colour-1) 75%); } +#gameBoard td.SpecialActive2 > div { background: color-mix(in srgb, var(--special-colour-2), var(--special-accent-colour-2) 75%); } +#gameBoard td.SpecialActive3 > div { background: color-mix(in srgb, var(--special-colour-3), var(--special-accent-colour-3) 75%); } +#gameBoard td.SpecialActive4 > div { background: color-mix(in srgb, var(--special-colour-4), var(--special-accent-colour-4) 75%); } + +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 1) { --x: -12; --y: 29; --w: 36; animation-delay: -900ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 2) { --x: -10; --y: 20; --w: 52; animation-delay: -300ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 3) { --x: 1; --y: 4; --w: 48; animation-delay: -600ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 4) { --x: 12; --y: 7; --w: 46; animation-delay: -750ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 5) { --x: 20; --y: -1; --w: 52; animation-delay: -1350ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 6) { --x: 29; --y: 3; --w: 52; animation-delay: -150ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 7) { --x: 44; --y: 11; --w: 42; animation-delay: -450ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 8) { --x: 50; --y: 9; --w: 50; animation-delay: -1200ms; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type( 9) { --x: 66; --y: 20; --w: 36; animation-delay: 0 ; } +:is(#gameBoard td, .specialAnimation) > div:nth-of-type(10) { --x: 72; --y: 16; --w: 46; animation-delay: -1050ms; } + +@keyframes specialSpaceAnimation { + from { transform: translateY(0) scale(0); } + 20% { transform: translateY(calc(var(--anim-size) * -0.15)) scale(1); } + 60% { transform: translateY(calc(var(--anim-size) * -0.45)) scale(1); } + to { transform: translateY(calc(var(--anim-size) * -0.75)) scale(0); } +} + #gameBoard td { position: relative; - overflow: hidden; } #gameBoard td.hover::after { @@ -443,6 +489,7 @@ dialog::backdrop { background: repeating-linear-gradient(135deg, var(--hover-colour) 0, var(--hover-colour) 0.6ex, transparent 0.6ex, transparent 1.2ex); background-attachment: fixed; } +#gameBoard td.hoverspecial { overflow: hidden; } #gameBoard td.hoverspecial::after { left: -50%; top: -50%; @@ -500,21 +547,25 @@ dialog::backdrop { .playerBar[data-index="0"] { --colour: var(--primary-colour-1); --special-colour: var(--special-colour-1); + --special-accent-colour: var(--special-accent-colour-1); grid-row: player-row-0; } .playerBar[data-index="1"] { --colour: var(--primary-colour-2); --special-colour: var(--special-colour-2); + --special-accent-colour: var(--special-accent-colour-2); grid-row: player-row-1; } .playerBar[data-index="2"] { --colour: var(--primary-colour-3); --special-colour: var(--special-colour-3); + --special-accent-colour: var(--special-accent-colour-3); grid-row: player-row-2; } .playerBar[data-index="3"] { --colour: var(--primary-colour-4); --special-colour: var(--special-colour-4); + --special-accent-colour: var(--special-accent-colour-4); grid-row: player-row-3; } @@ -847,15 +898,22 @@ dialog::backdrop { .specialPoints div { display: inline-block; - width: 1em; - height: 1em; - border: 1px solid; + width: 1.25em; + height: 1.25em; text-align: center; } .playerBar .specialPoint { + position: relative; color: transparent; - background: var(--special-colour); + background: url('assets/SpecialOverlay.png') center/cover, var(--special-colour); +} +.playerBar .specialPoint.specialAnimation { + background: url('assets/SpecialOverlay.png') center/cover, radial-gradient(circle, var(--special-accent-colour) 25%, var(--special-colour) 75%); +} +.playerBar .specialPoint.specialAnimation > div { + background: color-mix(in srgb, var(--special-colour), var(--special-accent-colour) 75%); + --anim-size: 1.25em; } .specialPoints div {