diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 76740ce..042b0d3 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -32,6 +32,9 @@ jobs: - name: Setup Node.js environment uses: actions/setup-node@v3.4.1 + - name: Install react-scripts + run: npm add react-scripts + - name: Install packages with NPM run: npm install diff --git a/package.json b/package.json index 8b02006..87ba09c 100644 --- a/package.json +++ b/package.json @@ -26,7 +26,7 @@ "react": "^18.2.0", "react-app-polyfill": "^3.0.0", "react-dom": "^18.2.0", - "react-scripts": "^5.0.1", + "react-spring": "^9.5.2", "web-vitals": "^2.1.4", "xstate": "^4.32.1" }, diff --git a/public/favicon.ico b/public/favicon.ico index a11777c..2c3fef4 100644 Binary files a/public/favicon.ico and b/public/favicon.ico differ diff --git a/public/index.html b/public/index.html index e933c56..1425ccb 100644 --- a/public/index.html +++ b/public/index.html @@ -2,19 +2,19 @@ - + - + - + - React App + Wordtris diff --git a/public/logo192.png b/public/logo192.png index fc44b0a..32e9cae 100644 Binary files a/public/logo192.png and b/public/logo192.png differ diff --git a/public/logo512.png b/public/logo512.png index a4e47a6..b9f34a0 100644 Binary files a/public/logo512.png and b/public/logo512.png differ diff --git a/public/manifest.json b/public/manifest.json index f01493f..fb56ffb 100644 --- a/public/manifest.json +++ b/public/manifest.json @@ -1,6 +1,6 @@ { - "short_name": "React App", - "name": "Create React App Sample", + "short_name": "Wordtris", + "name": "Wordtris", "icons": [ { "src": "favicon.ico", diff --git a/src/App.css b/src/App.css index 74b5e05..adc2ed5 100644 --- a/src/App.css +++ b/src/App.css @@ -1,38 +1,25 @@ -.App { +.cell { + display: flex; + border-radius: 0.4vmin; + justify-content: center; + justify-items: center; + align-content: center; + align-items: center; text-align: center; + line-height: normal; } -.App-logo { - height: 40vmin; - pointer-events: none; -} - -@media (prefers-reduced-motion: no-preference) { - .App-logo { - animation: App-logo-spin infinite 20s linear; - } -} - -.App-header { - background-color: #282c34; - min-height: 100vh; - display: flex; - flex-direction: column; - align-items: center; - justify-content: center; - font-size: calc(10px + 2vmin); - color: white; +.cell.with-margin { + margin: 0.4vmin; } -.App-link { - color: #61dafb; +.with-text-style { + font-family: "Arial", "Courier New", "Lucida Console", monospace; + font-weight: bold; + text-transform: uppercase; } -@keyframes App-logo-spin { - from { - transform: rotate(0deg); - } - to { - transform: rotate(360deg); - } +@font-face { + font-family: 'Press Start 2P'; + src: local('Press Start 2P'), url(./fonts/PressStart2P-Regular.ttf) format('truetype'); } diff --git a/src/GameLoop.tsx b/src/GameLoop.tsx index 7c1c6fc..2b99f65 100644 --- a/src/GameLoop.tsx +++ b/src/GameLoop.tsx @@ -1,6 +1,5 @@ import * as React from "react"; -import { useEffect, useState } from "react"; -import styled from "styled-components"; +import { useEffect, useReducer, useState } from "react"; import "./App.css"; import { createMachine, interpret } from "xstate"; import { PlayerBlock } from "./components/PlayerBlock"; @@ -28,13 +27,19 @@ import { WordList } from "./components/WordList"; import { useInterval } from "./util/useInterval"; import { GameOverOverlay, PlayAgainButton } from "./components/GameOverOverlay"; import { CountdownOverlay } from "./components/CountdownOverlay"; +import { FallingBlock } from "./components/FallingBlock"; import { _ENABLE_UP_KEY, _IS_PRINTING_STATE, + BOARD_CELL_COLOR, + BOARD_COLOR, BOARD_COLS, BOARD_ROWS, + boardCellFallDurationMillisecondsRate, + CELL_SIZE, countdownTotalSecs, EMPTY, + EMPTY_CELL_COLOR, ENABLE_INSTANT_DROP, ENABLE_SMOOTH_FALL, frameStep, @@ -43,19 +48,17 @@ import { interpKeydownMult, interpMax, interpRate, + LARGE_TEXT_SIZE, lockMax, matchAnimLength, MIN_WORD_LENGTH, + PLAYER_COLOR, + playerCellFallDurationMillisecondsRate, + UNIVERSAL_BORDER_RADIUS, } from "./setup"; - -// Style of encompassing board. -const BoardStyled = styled.div` - display: inline-grid; - grid-template-rows: repeat(${BOARD_ROWS}, 30px); - grid-template-columns: repeat(${BOARD_COLS}, 30px); - border: solid red 4px; - position: relative; -`; +import { UserCell } from "./UserCell"; +import { Header } from "./components/Header"; +import { Prompt } from "./components/Prompt"; // Terminology: https://tetris.fandom.com/wiki/Glossary // Declaration of game states. @@ -66,10 +69,18 @@ const stateMachine = createMachine({ countdown: { on: { DONE: "spawningBlock" } }, spawningBlock: { on: { SPAWN: "placingBlock" } }, placingBlock: { - on: { TOUCHINGBLOCK: "lockDelay", BLOCKED: "gameOver" }, + on: { + TOUCHING_BLOCK: "lockDelay", + BLOCKED: "gameOver", + DO_INSTANT_DROP_ANIM: "playerInstantDropAnim", + }, + }, + playerInstantDropAnim: { + on: { TOUCHING_BLOCK: "lockDelay" }, }, lockDelay: { on: { LOCK: "fallingLetters", UNLOCK: "placingBlock" } }, - fallingLetters: { on: { GROUNDED: "checkingMatches" } }, + fallingLetters: { on: { DO_ANIM: "fallingLettersAnim" } }, + fallingLettersAnim: { on: { GROUNDED: "checkingMatches" } }, checkingMatches: { on: { PLAYING_ANIM: "playMatchAnimation", @@ -77,7 +88,10 @@ const stateMachine = createMachine({ }, }, playMatchAnimation: { - on: { CHECK_FOR_CHAIN: "checkingMatches" }, + on: { + CHECK_FOR_CHAIN: "fallingLetters", + SKIP_ANIM: "postMatchAnimation", + }, }, postMatchAnimation: { on: { DONE: "spawningBlock" }, @@ -100,12 +114,90 @@ const timestamps = { accumFrameTime: 0, prevFrameTime: performance.now(), countdownMillisecondsElapsed: 0, + fallingLettersAnimStartMilliseconds: 0, + fallingLettersAnimDurationMilliseconds: 0, + playerInstantDropAnimStart: 0, + playerInstantDropAnimDurationMilliseconds: 0, }; +type PlayerState = { + pos: [number, number]; + cells: UserCell[]; + adjustedCells: UserCell[]; +}; + +type PlayerAction = + | { type: "resetPlayer" } + | { type: "setCells"; newCells: UserCell[]; newAdjustedCells: UserCell[] } + | { type: "movePlayer"; posUpdate: [number, number] } + | { type: "groundPlayer"; playerRowPos: number }; + export function GameLoop() { + const [player, dispatchPlayer] = useReducer( + (state: PlayerState, action: PlayerAction): PlayerState => { + let newPos; + switch (action.type) { + case "resetPlayer": { + newPos = [...spawnPos] as const; + const initCells = generateUserCells(); + return { + ...state, + pos: newPos.slice() as [number, number], + cells: initCells, + adjustedCells: convertCellsToAdjusted( + initCells, + newPos, + ), + }; + } + case "setCells": { + return { + ...state, + cells: action.newCells, + adjustedCells: action.newAdjustedCells, + }; + } + case "movePlayer": { + newPos = [ + state.pos[0] + action.posUpdate[0], + state.pos[1] + action.posUpdate[1], + ] as [number, number]; + return { + ...state, + pos: newPos, + adjustedCells: convertCellsToAdjusted( + state.cells, + newPos, + ), + }; + } + case "groundPlayer": { + newPos = [action.playerRowPos, state.pos[1]] as [ + number, + number, + ]; + return { + ...state, + pos: newPos, + adjustedCells: convertCellsToAdjusted( + state.cells, + newPos, + ), + }; + } + } + }, + { + pos: [...spawnPos], + cells: [], + adjustedCells: [], + }, + ); + const [validWords, setValidWords] = useState(new Set()); useEffect(() => { + dispatchPlayer({ type: "resetPlayer" }); // Fetch validWords during countdown. fetch( "https://raw.githubusercontent.com/khivy/wordtris/main/lexicons/Scrabble80K.txt", @@ -119,12 +211,6 @@ export function GameLoop() { createBoard(BOARD_ROWS, BOARD_COLS), ); - // Player state. - const [playerPos, setPlayerPos] = useState([...spawnPos] as const); - const [playerCells, setPlayerCells] = useState(generateUserCells()); - const [playerAdjustedCells, setPlayerAdjustedCells] = useState( - convertCellsToAdjusted(playerCells, playerPos), - ); const [isPlayerVisible, setPlayerVisibility] = useState(false); const [isPlayerMovementEnabled, setIsPlayerMovementEnabled] = useState( false, @@ -151,10 +237,19 @@ export function GameLoop() { const [didInstantDrop, setDidInstantDrop] = useState(false); + const [ + fallingBoardLettersBeforeAndAfter, + setFallingBoardLettersBeforeAndAfter, + ] = useState([]); + const [ + fallingPlayerLettersBeforeAndAfter, + setFallingPlayerLettersBeforeAndAfter, + ] = useState([]); + useEffect(() => { - globalThis.addEventListener("keydown", updatePlayerPos); + globalThis.addEventListener("keydown", updatepos); return () => { - globalThis.removeEventListener("keydown", updatePlayerPos); + globalThis.removeEventListener("keydown", updatepos); }; }); @@ -164,12 +259,12 @@ export function GameLoop() { function rotatePlayerBlock(isClockwise: boolean, board: BoardCell[][]) { const rotatedCells = rotateCells( - playerCells, + player.cells, isClockwise, ); let rotatedCellsAdjusted = rotatedCells.map((cell) => - getAdjustedUserCell(cell, playerPos) + getAdjustedUserCell(cell, player.pos) ); // Get the overlapping cell's respective index in non-adjusted array. @@ -188,10 +283,13 @@ export function GameLoop() { if (isAdjacentToGround) { interp.val = 0; } - setPlayerCells(rotatedCells); - setPlayerAdjustedCells(rotatedCellsAdjusted); + dispatchPlayer({ + type: "setCells", + newCells: rotatedCells, + newAdjustedCells: rotatedCellsAdjusted, + }); } else { - console.assert(playerAdjustedCells.length === 2); + console.assert(player.adjustedCells.length === 2); // Get direction of overlapping cell. const dr = Math.floor(layout.length / 2) - rotatedCells[overlappingCellIndex].r; @@ -203,7 +301,7 @@ export function GameLoop() { cell.c += dc; } rotatedCellsAdjusted = rotatedCells.map((cell) => - getAdjustedUserCell(cell, playerPos) + getAdjustedUserCell(cell, player.pos) ); // Check for overlaps with shifted cells. const isOverlapping = rotatedCellsAdjusted.some((cell) => @@ -212,13 +310,16 @@ export function GameLoop() { board[cell.r][cell.c].char !== EMPTY ); if (!isOverlapping) { - setPlayerCells(rotatedCells); - setPlayerAdjustedCells(rotatedCellsAdjusted); + dispatchPlayer({ + type: "setCells", + newCells: rotatedCells, + newAdjustedCells: rotatedCellsAdjusted, + }); } } } - function updatePlayerPos( + function updatepos( { code }: { code: string }, ): void { if (!isPlayerMovementEnabled) { @@ -228,19 +329,19 @@ export function GameLoop() { const areTargetSpacesEmpty = ( dr: -1 | 0 | 1 | number, dc: -1 | 0 | 1, - ) => playerAdjustedCells.every((cell) => { + ) => player.adjustedCells.every((cell) => { return board[cell.r + dr][cell.c + dc].char === EMPTY; }); if ("ArrowLeft" === code) { // Move left. if ( isInCBounds( - getAdjustedLeftmostC(playerAdjustedCells) - 1, + getAdjustedLeftmostC(player.adjustedCells) - 1, ) && // Ensure blocks don't cross over to ground higher than it, regarding interpolation. (!ENABLE_SMOOTH_FALL || isInRBounds( - getAdjustedBottomR(playerAdjustedCells) + + getAdjustedBottomR(player.adjustedCells) + Math.ceil(interp.val / interpMax), )) && areTargetSpacesEmpty( @@ -248,24 +349,18 @@ export function GameLoop() { -1, ) ) { - setPlayerPos((prev) => { - const pos = [prev[0], prev[1] - 1] as [number, number]; - setPlayerAdjustedCells( - convertCellsToAdjusted(playerCells, pos), - ); - return pos; - }); + dispatchPlayer({ type: "movePlayer", posUpdate: [0, -1] }); } } else if ("ArrowRight" === code) { // Move right. if ( isInCBounds( - getAdjustedRightmostC(playerAdjustedCells) + 1, + getAdjustedRightmostC(player.adjustedCells) + 1, ) && // Ensure blocks don't cross over to ground higher than it, regarding interpolation. (!ENABLE_SMOOTH_FALL || isInRBounds( - getAdjustedBottomR(playerAdjustedCells) + + getAdjustedBottomR(player.adjustedCells) + Math.ceil(interp.val / interpMax), )) && areTargetSpacesEmpty( @@ -273,30 +368,18 @@ export function GameLoop() { 1, ) ) { - setPlayerPos((prev) => { - const pos = [prev[0], prev[1] + 1] as [number, number]; - setPlayerAdjustedCells( - convertCellsToAdjusted(playerCells, pos), - ); - return pos; - }); + dispatchPlayer({ type: "movePlayer", posUpdate: [0, 1] }); } } else if ("ArrowDown" === code) { // Move down faster. if ( - getAdjustedBottomR(playerAdjustedCells) + 1 < BOARD_ROWS && + getAdjustedBottomR(player.adjustedCells) + 1 < BOARD_ROWS && areTargetSpacesEmpty(1, 0) ) { if (ENABLE_SMOOTH_FALL) { interp.val += interpRate * interpKeydownMult; } else { - setPlayerPos((prev) => { - const pos = [prev[0] + 1, prev[1]] as [number, number]; - setPlayerAdjustedCells( - convertCellsToAdjusted(playerCells, pos), - ); - return pos; - }); + dispatchPlayer({ type: "movePlayer", posUpdate: [1, 0] }); // Reset interp. interp.val = 0; } @@ -310,37 +393,13 @@ export function GameLoop() { } else if ("Space" === code) { // Instant drop. if (ENABLE_INSTANT_DROP) { - let ground_row = BOARD_ROWS; - playerAdjustedCells.forEach((cell) => - ground_row = Math.min( - ground_row, - getGroundHeight(cell.c, cell.r, boardCellMatrix), - ) - ); - const mid = Math.floor(layout.length / 2); - // Offset with the lowest cell, centered around layout's midpoint. - let dy = 0; - playerCells.forEach((cell) => dy = Math.max(dy, cell.r - mid)); - setPlayerPos((prev) => { - const pos = [ground_row - dy, prev[1]] as [number, number]; - setPlayerAdjustedCells( - convertCellsToAdjusted(playerCells, pos), - ); - return pos; - }); setDidInstantDrop(true); } else if ( _ENABLE_UP_KEY && - 0 <= getAdjustedTopR(playerAdjustedCells) - 1 && + 0 <= getAdjustedTopR(player.adjustedCells) - 1 && areTargetSpacesEmpty(-1, 0) ) { - setPlayerPos((prev) => { - const pos = [prev[0] - 1, prev[1]] as [number, number]; - setPlayerAdjustedCells( - convertCellsToAdjusted(playerCells, pos), - ); - return pos; - }); + dispatchPlayer({ type: "movePlayer", posUpdate: [-1, 0] }); } } } @@ -396,9 +455,23 @@ export function GameLoop() { // Reset Word List. setMatchedWords([]); + setMatchedCells(new Set()); + + setFallingBoardLettersBeforeAndAfter([]); + setFallingPlayerLettersBeforeAndAfter([]); setGameOverVisibility(false); + // Temporary fix for lingering hasMatched cells. See Github issue #55. + setBoardCellMatrix((matrix) => + matrix.map((row) => { + return row.map((cell) => { + cell.hasMatched = false; + return cell; + }); + }) + ); + setCountdownVisibility(true); timestamps.countdownStartTime = performance.now(); stateHandler.send("START"); @@ -413,6 +486,7 @@ export function GameLoop() { stateHandler.send("DONE"); } } else if ("spawningBlock" === stateHandler.state.value) { + // Wait while validWords fetches data. if (validWords.size === 0) { return; } @@ -420,18 +494,10 @@ export function GameLoop() { setCountdownVisibility(false); // Reset player. - // This nested structure prevents desync between the given state variables. - setPlayerPos(() => { - const pos = [...spawnPos] as const; - setPlayerCells(() => { - const cells = generateUserCells(); - setPlayerAdjustedCells(convertCellsToAdjusted(cells, pos)); - return cells; - }); - return pos; - }); + dispatchPlayer({ type: "resetPlayer" }); setIsPlayerMovementEnabled(true); setPlayerVisibility(true); + setMatchedCells(new Set()); // Reset penalty. setGroundExitPenalty(0); @@ -457,34 +523,83 @@ export function GameLoop() { if (isPlayerMovementEnabled) { const dr = doGradualFall( boardCellMatrix, - playerAdjustedCells, - ); - setPlayerPos([ - playerPos[0] + dr, - playerPos[1], - ]); - setPlayerAdjustedCells( - convertCellsToAdjusted(playerCells, playerPos), + player.adjustedCells, ); + dispatchPlayer({ type: "movePlayer", posUpdate: [dr, 0] }); } // Check if player is touching ground. - if (isPlayerTouchingGround(playerAdjustedCells, boardCellMatrix)) { + if (isPlayerTouchingGround(player.adjustedCells, boardCellMatrix)) { timestamps.lockStart = performance.now(); - stateHandler.send("TOUCHINGBLOCK"); + stateHandler.send("TOUCHING_BLOCK"); + } + + if (didInstantDrop) { + setPlayerVisibility(false); + const closestPlayerCellToGround = player.adjustedCells.reduce(( + prev, + cur, + ) => getGroundHeight(prev.c, prev.r, boardCellMatrix) - prev.r < + getGroundHeight(cur.c, cur.r, boardCellMatrix) - cur.r + ? prev + : cur + ); + const closestGround = getGroundHeight( + closestPlayerCellToGround.c, + closestPlayerCellToGround.r, + boardCellMatrix, + ); + const minDist = closestGround - closestPlayerCellToGround.r; + timestamps.playerInstantDropAnimStart = performance.now(); + timestamps.playerInstantDropAnimDurationMilliseconds = + playerCellFallDurationMillisecondsRate * minDist; + setFallingPlayerLettersBeforeAndAfter( + player.adjustedCells.map((cell) => [ + { ...cell }, + { ...cell, r: closestGround }, + ]), + ); + setIsPlayerMovementEnabled(false); + stateHandler.send("DO_INSTANT_DROP_ANIM"); + } + } else if ("playerInstantDropAnim" === stateHandler.state.value) { + if ( + timestamps.playerInstantDropAnimDurationMilliseconds < + performance.now() - timestamps.playerInstantDropAnimStart + ) { + setPlayerVisibility(true); + let ground_row = BOARD_ROWS; + player.adjustedCells.forEach((cell) => + ground_row = Math.min( + ground_row, + getGroundHeight(cell.c, cell.r, boardCellMatrix), + ) + ); + const mid = Math.floor(layout.length / 2); + // Offset with the lowest cell, centered around layout's midpoint. + let dy = 0; + player.cells.forEach((cell) => dy = Math.max(dy, cell.r - mid)); + dispatchPlayer({ + type: "groundPlayer", + playerRowPos: ground_row - dy, + }); + stateHandler.send("TOUCHING_BLOCK"); } } else if ("lockDelay" === stateHandler.state.value) { const lockTime = performance.now() - timestamps.lockStart + groundExitPenalty; - if (!isPlayerTouchingGround(playerAdjustedCells, boardCellMatrix)) { + if ( + !isPlayerTouchingGround(player.adjustedCells, boardCellMatrix) + ) { // Player has moved off of ground. setGroundExitPenalty((prev) => prev + groundExitPenaltyRate); stateHandler.send("UNLOCK"); } else if (lockMax <= lockTime || didInstantDrop) { // Lock in block. + setFallingPlayerLettersBeforeAndAfter([]); const newBoard = boardCellMatrix.slice(); setPlacedCells((prev) => { - playerAdjustedCells.forEach((cell) => { + player.adjustedCells.forEach((cell) => { prev.add([cell.r, cell.c]); // Give player cells to board. newBoard[cell.r][cell.c].char = cell.char; @@ -502,15 +617,65 @@ export function GameLoop() { } } else if ("fallingLetters" === stateHandler.state.value) { // For each floating block, move it 1 + the ground. - const [newBoardWithDrops, added, _removed] = dropFloatingCells( - boardCellMatrix, + const { boardWithoutFallCells, postFallCells, preFallCells } = + dropFloatingCells( + boardCellMatrix, + ); + + // Update falling letters & animation information. + const newFallingBoardLettersBeforeAndAfter = preFallCells.map(( + k, + i, + ) => [k, postFallCells[i]]); + // Handle animation duration. + let animDuration = 0; + if (postFallCells.length !== 0) { + const [maxFallBeforeCell, maxFallAfterCell] = + newFallingBoardLettersBeforeAndAfter.reduce((prev, cur) => + prev[1].r - prev[0].r > cur[1].r - cur[0].r ? prev : cur + ); + animDuration = boardCellFallDurationMillisecondsRate * + (maxFallAfterCell.r - maxFallBeforeCell.r); + } + setFallingBoardLettersBeforeAndAfter( + newFallingBoardLettersBeforeAndAfter, ); - setBoardCellMatrix(newBoardWithDrops); + timestamps.fallingLettersAnimDurationMilliseconds = animDuration; + timestamps.fallingLettersAnimStartMilliseconds = performance.now(); + + setBoardCellMatrix(boardWithoutFallCells); + setPlacedCells((prev) => { - added.forEach((coord) => prev.add(coord)); + postFallCells.forEach((boardCell) => + prev.add([boardCell.r, boardCell.c]) + ); return prev; }); - stateHandler.send("GROUNDED"); + + stateHandler.send("DO_ANIM"); + } else if ("fallingLettersAnim" === stateHandler.state.value) { + if ( + timestamps.fallingLettersAnimDurationMilliseconds < + performance.now() - + timestamps.fallingLettersAnimStartMilliseconds + ) { + // Drops floating cells again in-case + const { boardWithoutFallCells, _postFallCells, _preFallCells } = + dropFloatingCells( + boardCellMatrix, + ); + setBoardCellMatrix(boardWithoutFallCells); + + const newBoard = boardCellMatrix.slice(); + fallingBoardLettersBeforeAndAfter.forEach((beforeAndAfter) => { + const [before, after] = beforeAndAfter; + newBoard[before.r][before.c].char = EMPTY; + newBoard[after.r][after.c].char = after.char; + }); + setFallingBoardLettersBeforeAndAfter([]); + setBoardCellMatrix(newBoard); + stateHandler.send("GROUNDED"); + } } else if ("checkingMatches" === stateHandler.state.value) { // Allocate a newBoard to avoid desync between render and board (React, pls). const newBoard = boardCellMatrix.slice(); @@ -593,14 +758,12 @@ export function GameLoop() { return prev; }); - timestamps.matchAnimStart = performance.now(); setBoardCellMatrix(newBoard); if (hasRemovedWord) { - stateHandler.send("PLAYING_ANIM"); - } else { - stateHandler.send("SKIP_ANIM"); + timestamps.matchAnimStart = performance.now(); } + stateHandler.send("PLAYING_ANIM"); } else if ("playMatchAnimation" === stateHandler.state.value) { const animTime = performance.now() - timestamps.matchAnimStart; if (matchAnimLength <= animTime) { @@ -614,26 +777,31 @@ export function GameLoop() { } }); }); + setBoardCellMatrix(newBoard); - // Drop all characters. - const [newBoardWithDrops, added, _removed] = dropFloatingCells( - newBoard, - ); - setBoardCellMatrix(newBoardWithDrops); setPlacedCells((prev) => { - prev.clear(); - added.forEach((coord) => prev.add(coord)); - return prev; + return structuredClone(prev); }); - // Go back to checkingMatches to see if dropped letters causes more matches. - setMatchedCells((prev) => { - prev.clear(); - return prev; - }); - stateHandler.send("CHECK_FOR_CHAIN"); + if (matchedCells.size !== 0) { + setMatchedCells(new Set()); + stateHandler.send("CHECK_FOR_CHAIN"); + } + stateHandler.send("SKIP_ANIM"); } } else if ("postMatchAnimation" === stateHandler.state.value) { + // Remove matched characters again. + const newBoard = boardCellMatrix.slice(); + newBoard.forEach((row, r) => { + row.forEach((cell, c) => { + if (matchedCells.has([r, c].toString())) { + cell.char = EMPTY; + cell.hasMatched = false; + } + }); + }); + setBoardCellMatrix(newBoard); + setPlacedCells((prev) => { prev.clear(); return prev; @@ -641,35 +809,93 @@ export function GameLoop() { stateHandler.send("DONE"); } } + const pageStyle = { + background: BOARD_COLOR, + height: "100%", + width: "100%", + position: "absolute", + // Allow `containerStyle` div to grow downwards, filling the page. + display: "flex", + flexDirection: "column", + } as const; + + const containerStyle = { + display: "flex", + alignItems: "center", + justifyContent: "center", + height: "100%", + width: "100%", + // Prevents `
` from pushing game downwards. + position: "absolute", + } as const; const appStyle = { display: "flex", - border: "solid green 4px", flexWrap: "wrap", flexDirection: "row", + border: `1vmin solid ${EMPTY_CELL_COLOR}`, + padding: "0.4vmin", + top: 0, + borderRadius: UNIVERSAL_BORDER_RADIUS, + } as const; + + // Style of encompassing board. + const boardStyle = { + display: "inline-grid", + gridTemplateColumns: `repeat(${BOARD_COLS}, ${CELL_SIZE})`, + gridTemplateRows: `repeat(${BOARD_ROWS}, ${CELL_SIZE})`, + position: "relative", + background: BOARD_COLOR, + } as const; + + const gameOverTextStyle = { + color: "white", + fontSize: LARGE_TEXT_SIZE, + WebkitTextStroke: "0.2vmin", + WebkitTextStrokeColor: BOARD_CELL_COLOR, } as const; return ( -
- - - - - - - Game Over - - - - +
+
+
+ +
+
+ + + + + + + + + +
Game Over
+ + +
+
+ +
+
+
); } diff --git a/src/components/BoardCells.tsx b/src/components/BoardCells.tsx index 6f945c7..d87fc85 100644 --- a/src/components/BoardCells.tsx +++ b/src/components/BoardCells.tsx @@ -1,6 +1,14 @@ import * as React from "react"; import { BoardCell } from "../util/BoardCell"; -import { EMPTY } from "../setup"; +import { + BOARD_CELL_COLOR, + BOARD_CELL_TEXT_COLOR, + EMPTY, + EMPTY_CELL_COLOR, + MATCH_COLOR, + MATCH_TEXT_COLOR, + NORMAL_TEXT_SIZE, +} from "../setup"; export const BoardCells = React.memo( ({ boardCellMatrix }: { boardCellMatrix: BoardCell[][] }) => { @@ -8,26 +16,32 @@ export const BoardCells = React.memo( row.map((cell, c) => { const bg = () => { if (cell.char === EMPTY) { - return "none"; + return EMPTY_CELL_COLOR; } else if (cell.hasMatched) { - return "lightgreen"; + return MATCH_COLOR; } else { - return "red"; + return BOARD_CELL_COLOR; + } + }; + const textColor = () => { + if (cell.hasMatched) { + return MATCH_TEXT_COLOR; + } else { + return BOARD_CELL_TEXT_COLOR; } }; const divStyle = { - width: "auto", - text: cell.char === EMPTY ? "none" : "red", - border: "2px solid", gridRow: r + 1, gridColumn: c + 1, - textAlign: "center", background: bg(), + color: textColor(), + fontSize: NORMAL_TEXT_SIZE, } as const; return (
{cell.char} diff --git a/src/components/CountdownOverlay.tsx b/src/components/CountdownOverlay.tsx index 61b519d..8168b04 100644 --- a/src/components/CountdownOverlay.tsx +++ b/src/components/CountdownOverlay.tsx @@ -1,4 +1,5 @@ import * as React from "react"; +import { BOARD_CELL_COLOR, LARGE_TEXT_SIZE, MENU_TEXT_COLOR } from "../setup"; export const CountdownOverlay = React.memo( ( @@ -14,8 +15,10 @@ export const CountdownOverlay = React.memo( left: "50%", transform: "translate(-50%, -50%)", zIndex: 2, - color: "red", - fontSize: "200%", + color: MENU_TEXT_COLOR, + fontSize: "13vmin", + WebkitTextStroke: "0.2vmin", + WebkitTextStrokeColor: BOARD_CELL_COLOR, } as const; return (
diff --git a/src/components/FallingBlock.tsx b/src/components/FallingBlock.tsx new file mode 100644 index 0000000..284efa3 --- /dev/null +++ b/src/components/FallingBlock.tsx @@ -0,0 +1,77 @@ +import * as React from "react"; +import { animated, useSpring } from "react-spring"; +import { BoardCell } from "../util/BoardCell"; +import { BOARD_CELL_TEXT_COLOR, NORMAL_TEXT_SIZE } from "../setup"; + +export const FallingBlock = React.memo( + ( + { fallingLetters, durationRate, color }: { + fallingLetters: BoardCell[]; + durationRate: number; + color: string; + }, + ) => { + const fallenLetters = fallingLetters + .map((fallingLetterBeforeAndAfter) => ( + + )); + return <>{fallenLetters}; + }, +); + +const FallingLetter = React.memo( + ( + { fallingLetterBeforeAndAfter, durationRate, color }: { + fallingLetterBeforeAndAfter: BoardCell[]; + durationRate: number; + color: string; + }, + ) => { + console.assert(fallingLetterBeforeAndAfter.length == 2); + const [before, after] = fallingLetterBeforeAndAfter; + const margin = 100 * Math.abs(after.r - before.r); + + const styles = useSpring({ + from: { + gridRow: before.r + 1, + gridColumn: before.c + 1, + zIndex: 5, + marginTop: "0%", + marginBottom: "0%", + }, + to: { + marginTop: `${margin}%`, + marginBottom: `-${margin}%`, + }, + reset: true, + config: { + duration: durationRate * (after.r - before.r), + }, + }); + + const innerStyle = { + height: "88%", + background: color, + color: BOARD_CELL_TEXT_COLOR, + fontSize: NORMAL_TEXT_SIZE, + } as const; + + return ( + +
+ {before.char} +
+
+ ); + }, +); diff --git a/src/components/GameOverOverlay.tsx b/src/components/GameOverOverlay.tsx index aa86664..61174b9 100644 --- a/src/components/GameOverOverlay.tsx +++ b/src/components/GameOverOverlay.tsx @@ -1,5 +1,11 @@ import * as React from "react"; import { ReactNode } from "react"; +import { + MENU_TEXT_COLOR, + NORMAL_TEXT_SIZE, + PLAYER_COLOR, + UNIVERSAL_BORDER_RADIUS, +} from "../setup"; export const GameOverOverlay = React.memo( ( @@ -11,11 +17,12 @@ export const GameOverOverlay = React.memo( const divStyle = { visibility: isVisible ? "visible" as const : "hidden" as const, position: "absolute", - top: "35%", + top: "50%", left: "50%", - transform: "translate(-25%, -25%)", + whiteSpace: "nowrap", + transform: "translate(-50%, -50%)", zIndex: 2, - color: "red", + color: MENU_TEXT_COLOR, fontSize: "200%", } as const; return ( @@ -33,17 +40,24 @@ export const PlayAgainButton = React.memo( const buttonStyle = { cursor: "pointer", border: "none", - display: "inline-block", + background: PLAYER_COLOR, + color: MENU_TEXT_COLOR, + borderRadius: UNIVERSAL_BORDER_RADIUS, + padding: "0.4vmin", + textAlign: "center", + marginTop: "0.4vmin", + fontSize: NORMAL_TEXT_SIZE, }; return ( - +
); }, ); diff --git a/src/components/Header.tsx b/src/components/Header.tsx new file mode 100644 index 0000000..edaf830 --- /dev/null +++ b/src/components/Header.tsx @@ -0,0 +1,42 @@ +import * as React from "react"; +import { BOARD_CELL_COLOR } from "../setup"; + +export const Header = React.memo(() => { + const style = { + zIndex: 20, + } as const; + + return ( +
+ + </div> + ); +}); + +export const Title = React.memo(() => { + const containerStyle = { + marginTop: "3vmin", + marginLeft: "2vmin", + zIndex: 20, + } as const; + + const textStyle = { + fontSize: "30px", + textTransform: "uppercase", + fontWeight: "bolder", + color: BOARD_CELL_COLOR, + padding: "10px", + fontFamily: `"Press Start 2P"`, + } as const; + + return ( + <div style={containerStyle}> + <a + href={"https://github.com/khivy/wordtris"} + style={{ textDecoration: "none" } as const} + > + <span style={textStyle}>Wordtris</span> + </a> + </div> + ); +}); diff --git a/src/components/PlayerBlock.tsx b/src/components/PlayerBlock.tsx index 3b8e476..c5e73b0 100644 --- a/src/components/PlayerBlock.tsx +++ b/src/components/PlayerBlock.tsx @@ -1,5 +1,13 @@ import * as React from "react"; -import { _ENABLE_UP_KEY, ENABLE_SMOOTH_FALL, interp } from "../setup"; +import { + _ENABLE_UP_KEY, + BOARD_CELL_TEXT_COLOR, + ENABLE_SMOOTH_FALL, + interp, + NORMAL_TEXT_SIZE, + PLAYER_COLOR, + UNIVERSAL_BORDER_RADIUS, +} from "../setup"; import { UserCell } from "../util/UserCell"; export const PlayerBlock = React.memo( @@ -9,26 +17,28 @@ export const PlayerBlock = React.memo( adjustedCells: UserCell[]; }, ) => { - // This function contains player information. - const adjustedCellsStyled = adjustedCells.map((cell) => { - const margin = ENABLE_SMOOTH_FALL ? interp.val : 0; const divStyle = { - background: "lightblue", - border: 2, - borderStyle: "solid", + background: PLAYER_COLOR, + color: BOARD_CELL_TEXT_COLOR, + fontSize: NORMAL_TEXT_SIZE, gridRow: cell.r + 1, gridColumn: cell.c + 1, - display: "flex", - marginTop: `${margin}%`, - marginBottom: `${-margin}%`, - justifyContent: "center", + marginTop: ENABLE_SMOOTH_FALL + ? `${interp.val + UNIVERSAL_BORDER_RADIUS}%` + : "0.4vmin", + marginBottom: ENABLE_SMOOTH_FALL + ? `${-interp.val - UNIVERSAL_BORDER_RADIUS}%` + : "0.4vmin", + marginLeft: "0.4vmin", + marginRight: "0.4vmin", visibility: isVisible ? "visible" as const : "hidden" as const, zIndex: 1, }; return ( <div key={cell.uid} + className={"cell with-text-style"} style={divStyle} > {cell.char} diff --git a/src/components/Prompt.tsx b/src/components/Prompt.tsx new file mode 100644 index 0000000..6199b9e --- /dev/null +++ b/src/components/Prompt.tsx @@ -0,0 +1,35 @@ +import * as React from "react"; +import { ReactNode } from "react"; +import { BOARD_CELL_COLOR, SMALL_TEXT_SIZE } from "../setup"; + +export const Prompt = React.memo(({ children }: { children: ReactNode }) => { + // To align `<Prompt/>` above the game. + const promptContainerStyle = { + flexDirection: "column", + display: "flex", + } as const; + + const promptSize = SMALL_TEXT_SIZE; + const paddingSize = SMALL_TEXT_SIZE; + + const promptStyle = { + textAlign: "center", + fontSize: promptSize, + paddingBottom: paddingSize, + color: BOARD_CELL_COLOR, + } as const; + + // This div allows the children to stay centered in `<Prompt/>`'s parent. + const counterBalanceStyle = { + height: promptSize, + paddingBottom: paddingSize, + } as const; + + return ( + <div style={promptContainerStyle}> + <span style={promptStyle}>Create words of 3+ letters</span> + {children} + <div style={counterBalanceStyle} /> + </div> + ); +}); diff --git a/src/components/WordList.tsx b/src/components/WordList.tsx index 3dd1528..9cd489d 100644 --- a/src/components/WordList.tsx +++ b/src/components/WordList.tsx @@ -1,14 +1,31 @@ import * as React from "react"; +import { + BOARD_CELL_COLOR, + MENU_TEXT_COLOR, + NORMAL_TEXT_SIZE, + PLAYER_COLOR, + SMALL_TEXT_SIZE, + UNIVERSAL_BORDER_RADIUS, +} from "../setup"; export const WordList = React.memo( ({ displayedWords }: { displayedWords: string[] }) => { const wordStyle = { - background: "yellow", + background: BOARD_CELL_COLOR, + padding: UNIVERSAL_BORDER_RADIUS, + margin: UNIVERSAL_BORDER_RADIUS, + borderRadius: UNIVERSAL_BORDER_RADIUS, + fontSize: SMALL_TEXT_SIZE, + fontStyle: "italic", } as const; const outerStyle = { display: "flex", flexDirection: "column", + color: MENU_TEXT_COLOR, + paddingLeft: UNIVERSAL_BORDER_RADIUS, + paddingRight: UNIVERSAL_BORDER_RADIUS, + marginBottom: UNIVERSAL_BORDER_RADIUS, } as const; const scrollBoxStyle = { @@ -17,15 +34,32 @@ export const WordList = React.memo( height: "0px", } as const; + const titleStyle = { + color: BOARD_CELL_COLOR, + fontSize: NORMAL_TEXT_SIZE, + } as const; + + const pointsStyle = { + color: PLAYER_COLOR, + fontSize: NORMAL_TEXT_SIZE, + } as const; + return ( <div style={outerStyle}> - Matched Words ({displayedWords.length}) + <div className={"with-text-style"} style={titleStyle}> + MATCHES [ + <span className={"with-text-style"} style={pointsStyle}> + {displayedWords.length} + </span> + ] + </div> <article style={scrollBoxStyle}> <> {displayedWords.map((word, i) => ( // Invert the key to keep scroll bar at bottom if set to bottom. <div key={displayedWords.length - i} + className={"with-text-style"} style={wordStyle} > {word} diff --git a/src/fonts/PressStart2P-Regular.ttf b/src/fonts/PressStart2P-Regular.ttf new file mode 100644 index 0000000..2442aff Binary files /dev/null and b/src/fonts/PressStart2P-Regular.ttf differ diff --git a/src/index.tsx b/src/index.tsx index 16b65b0..6f95f57 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,4 +1,4 @@ -import 'react-app-polyfill/stable'; +import "react-app-polyfill/stable"; import * as React from "react"; import { Suspense } from "react"; import * as ReactDOM from "react-dom/client"; diff --git a/src/setup.ts b/src/setup.ts index 9f53064..620d6de 100644 --- a/src/setup.ts +++ b/src/setup.ts @@ -26,3 +26,22 @@ export const lockMax = 1500; export const matchAnimLength = 750; export const groundExitPenaltyRate = 250; export const countdownTotalSecs = 3; + +export const boardCellFallDurationMillisecondsRate = 75; +export const playerCellFallDurationMillisecondsRate = 10; + +export const FONT_COLOR = "#FFFFFF"; +export const BOARD_COLOR = "#FDEDD8"; +export const EMPTY_CELL_COLOR = "#F4A261"; +export const BOARD_CELL_COLOR = "#2B5F8C"; +export const PLAYER_COLOR = "#499F68"; +export const MATCH_COLOR = "#FFEA00"; +export const MATCH_TEXT_COLOR = BOARD_CELL_COLOR; +export const MENU_TEXT_COLOR = "#FFFFFF"; +export const BOARD_CELL_TEXT_COLOR = "#EBF5EE"; + +export const UNIVERSAL_BORDER_RADIUS = "1vmin"; +export const CELL_SIZE = "7vmin"; +export const LARGE_TEXT_SIZE = "7vmin"; +export const NORMAL_TEXT_SIZE = "3.5vmin"; +export const SMALL_TEXT_SIZE = "3.0vmin"; diff --git a/src/util/playerUtil.tsx b/src/util/playerUtil.tsx index c608a98..ce7cfa7 100644 --- a/src/util/playerUtil.tsx +++ b/src/util/playerUtil.tsx @@ -13,7 +13,7 @@ import { UserCell } from "./UserCell"; import { BoardCell } from "./BoardCell"; import { getGroundHeight } from "./boardUtil"; -export const spawnPos: readonly [number, number] = [1, 3]; +export const spawnPos: readonly [number, number] = [1, 4]; export const layout = [ [EMPTY, EMPTY, EMPTY, EMPTY, EMPTY], [EMPTY, EMPTY, TBD, EMPTY, EMPTY], @@ -139,14 +139,14 @@ export function isPlayerTouchingGround( export function dropFloatingCells( board: BoardCell[][], -): [BoardCell[][], [number, number][], [number, number][]] { - // Returns an array of 3 arrays: - // Array 1: The resulting board with drops. - // Array 2: The array for the coords of the floating cells, post-drop. - // Array 3: Array for the old coords of the floating cells. +): { + boardWithoutFallCells: BoardCell[][]; + postFallCells: BoardCell[]; + preFallCells: BoardCell[]; +} { const newBoard = board.slice(); - const added: [number, number][] = []; - const removed: [number, number][] = []; + const postFallCells: BoardCell[] = []; + const preFallCells: BoardCell[] = []; for (let r = BOARD_ROWS - 2; r >= 0; --r) { for (let c = BOARD_COLS - 1; c >= 0; --c) { if ( @@ -154,13 +154,19 @@ export function dropFloatingCells( newBoard[r + 1][c].char === EMPTY ) { const g = getGroundHeight(c, r, newBoard); - newBoard[g][c].char = newBoard[r][c].char; + const char = newBoard[r][c].char; + newBoard[g][c].char = char; newBoard[r][c].char = EMPTY; // Update cell in placedCells. - added.push([g, c]); - removed.push([r, c]); + postFallCells.push({ r: g, c, char, hasMatched: false }); + preFallCells.push({ r, c, char, hasMatched: false }); } } } - return [newBoard, added, removed]; + // Remove chars here, since the iteration logic above depends on changes. + const boardWithoutFallCells = newBoard; + postFallCells.forEach((cell) => + boardWithoutFallCells[cell.r][cell.c].char = EMPTY + ); + return { boardWithoutFallCells, postFallCells, preFallCells }; }