Skip to content

Commit

Permalink
Add falling block animation with <FallingBlock/>
Browse files Browse the repository at this point in the history
  • Loading branch information
bytewife committed Sep 6, 2022
1 parent e1f6535 commit 809ae14
Show file tree
Hide file tree
Showing 5 changed files with 190 additions and 91 deletions.
82 changes: 41 additions & 41 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,44 +1,44 @@
{
"name": "wordtris",
"version": "0.1.2",
"description": "tetris but with words",
"keywords": [
"game",
"tetris"
],
"homepage": "https://github.com/ivyraine/wordtris",
"bugs": "https://github.com/ivyraine/wordtris/issues",
"license": "MIT",
"contributors": [
{
"name": "Ivy Raine"
},
{
"name": "Khyber Sen",
"email": "[email protected]"
}
],
"type": "module",
"browser": "src/index.tsx",
"repository": "github:ivyraine/wordtris",
"dependencies": {
"a-set-of-english-words": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"styled-components": "^5.3.5",
"web-vitals": "^2.1.4",
"xstate": "^4.32.1"
"name": "wordtris",
"version": "0.1.2",
"description": "tetris but with words",
"keywords": [
"game",
"tetris"
],
"homepage": "https://github.com/ivyraine/wordtris",
"bugs": "https://github.com/ivyraine/wordtris/issues",
"license": "MIT",
"contributors": [
{
"name": "Ivy Raine"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"devDependencies": {
"react-refresh": "0.10.0",
"typescript": "latest",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/styled-components": "^5.1.25"
{
"name": "Khyber Sen",
"email": "[email protected]"
}
}
],
"type": "module",
"browser": "src/index.tsx",
"repository": "github:ivyraine/wordtris",
"dependencies": {
"a-set-of-english-words": "^1.0.0",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-spring": "^9.5.2",
"web-vitals": "^2.1.4",
"xstate": "^4.32.1"
},
"eslintConfig": {
"extends": [
"react-app"
]
},
"devDependencies": {
"react-refresh": "0.10.0",
"typescript": "latest",
"@types/react": "^18.0.15",
"@types/react-dom": "^18.0.6",
"@types/styled-components": "^5.1.25"
}
}
113 changes: 74 additions & 39 deletions src/GameLoop.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import * as React from "react";
import { useEffect, useState } from "react";
import styled from "styled-components";
import "./App.css";
import { createMachine, interpret } from "xstate";
import { PlayerBlock } from "./components/PlayerBlock";
Expand All @@ -22,12 +21,13 @@ import {
rotateCells,
spawnPos,
} from "./util/playerUtil";
import { createBoard, getGroundHeight } from "./util/boardUtil";
import { createBoard, getGroundHeight, getFallDurationMilliseconds } from "./util/boardUtil";
import { BoardCell } from "./util/BoardCell";
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,
Expand All @@ -48,14 +48,6 @@ import {
MIN_WORD_LENGTH,
} 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;
`;

// Terminology: https://tetris.fandom.com/wiki/Glossary
// Declaration of game states.
Expand All @@ -66,18 +58,19 @@ const stateMachine = createMachine({
countdown: { on: { DONE: "spawningBlock" } },
spawningBlock: { on: { SPAWN: "placingBlock" } },
placingBlock: {
on: { TOUCHINGBLOCK: "lockDelay", BLOCKED: "gameOver" },
on: { TOUCHING_BLOCK: "lockDelay", BLOCKED: "gameOver" },
},
lockDelay: { on: { LOCK: "fallingLetters", UNLOCK: "placingBlock" } },
fallingLetters: { on: { GROUNDED: "checkingMatches" } },
fallingLetters: { on: { DO_ANIM: "fallingLettersAnim" } },
fallingLettersAnim: { on: { GROUNDED: "checkingMatches" } },
checkingMatches: {
on: {
PLAYING_ANIM: "playMatchAnimation",
SKIP_ANIM: "postMatchAnimation",
},
},
playMatchAnimation: {
on: { CHECK_FOR_CHAIN: "checkingMatches" },
on: { CHECK_FOR_CHAIN: "fallingLetters", SKIP_ANIM: "postMatchAnimation"},
},
postMatchAnimation: {
on: { DONE: "spawningBlock" },
Expand All @@ -100,6 +93,8 @@ const timestamps = {
accumFrameTime: 0,
prevFrameTime: performance.now(),
countdownMillisecondsElapsed: 0,
fallingLettersAnimStartMilliseconds: 0,
fallingLettersAnimDurationMilliseconds: 0,
};

export function GameLoop() {
Expand Down Expand Up @@ -151,6 +146,8 @@ export function GameLoop() {

const [didInstantDrop, setDidInstantDrop] = useState(false);

const [fallingLettersBeforeAndAfter, setFallingLettersBeforeAndAfter] = useState([] as [BoardCell, BoardCell][]);

useEffect(() => {
globalThis.addEventListener("keydown", updatePlayerPos);
return () => {
Expand Down Expand Up @@ -396,6 +393,7 @@ export function GameLoop() {

// Reset Word List.
setMatchedWords([]);
setMatchedCells(new Set());

setGameOverVisibility(false);

Expand Down Expand Up @@ -471,7 +469,7 @@ export function GameLoop() {
// Check if player is touching ground.
if (isPlayerTouchingGround(playerAdjustedCells, boardCellMatrix)) {
timestamps.lockStart = performance.now();
stateHandler.send("TOUCHINGBLOCK");
stateHandler.send("TOUCHING_BLOCK");
}
} else if ("lockDelay" === stateHandler.state.value) {
const lockTime = performance.now() - timestamps.lockStart +
Expand Down Expand Up @@ -502,15 +500,48 @@ export function GameLoop() {
}
} else if ("fallingLetters" === stateHandler.state.value) {
// For each floating block, move it 1 + the ground.
const [newBoardWithDrops, added, _removed] = dropFloatingCells(
const [newBoardWithDrops, added, removed] = dropFloatingCells(
boardCellMatrix,
);
setBoardCellMatrix(newBoardWithDrops);

// Update falling letters & animation information.
setFallingLettersBeforeAndAfter(_ => {
const newFallingLettersBeforeAndAfter = removed.map((k, i) => [k, added[i]]);

// Handle animation duration.
let animDuration = 0;
if (added.length !== 0) {
const [maxFallBeforeCell, maxFallAfterCell] = newFallingLettersBeforeAndAfter.reduce((prev, cur) =>
prev[1].r - prev[0].r > cur[1].r - cur[0].r ? prev : cur
);
animDuration = getFallDurationMilliseconds(maxFallBeforeCell.r, maxFallAfterCell.r);
}
timestamps.fallingLettersAnimDurationMilliseconds = animDuration;
timestamps.fallingLettersAnimStartMilliseconds = performance.now();

return newFallingLettersBeforeAndAfter;
});

setBoardCellMatrix(newBoardWithDrops);

setPlacedCells((prev) => {
added.forEach((coord) => prev.add(coord));
added.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) {
// Add in fallen-block changes. TODO remove from board above.
let newBoard = boardCellMatrix.slice();
fallingLettersBeforeAndAfter.forEach(beforeAndAfter => {
const [before, after] = beforeAndAfter;
newBoard[before.r][before.c].char = EMPTY;
newBoard[after.r][after.c].char = after.char;
})
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();
Expand Down Expand Up @@ -593,14 +624,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) {
Expand All @@ -615,23 +644,15 @@ export function GameLoop() {
});
});

// Drop all characters.
const [newBoardWithDrops, added, _removed] = dropFloatingCells(
newBoard,
);
setBoardCellMatrix(newBoardWithDrops);
setBoardCellMatrix(newBoard);
setPlacedCells((prev) => {
prev.clear();
added.forEach((coord) => prev.add(coord));
return prev;
});

// Go back to checkingMatches to see if dropped letters causes more matches.
setMatchedCells((prev) => {
prev.clear();
return prev;
return structuredClone(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) {
setPlacedCells((prev) => {
Expand All @@ -649,9 +670,18 @@ export function GameLoop() {
flexDirection: "row",
} as const;

// Style of encompassing board.
const boardStyle = {
display: "inline-grid",
gridTemplateRows: `repeat(${BOARD_ROWS}, 30px)`,
gridTemplateColumns: `repeat(${BOARD_COLS}, 30px)`,
border: "solid red 4px",
position: "relative",
} as const;

return (
<div style={appStyle}>
<BoardStyled>
<div style={boardStyle}>
<CountdownOverlay
isVisible={isCountdownVisible}
countdownSec={countdownSec}
Expand All @@ -661,14 +691,19 @@ export function GameLoop() {
isVisible={isPlayerVisible}
adjustedCells={playerAdjustedCells}
/>

<FallingBlock
fallingLetters={fallingLettersBeforeAndAfter}
/>

<BoardCells
boardCellMatrix={boardCellMatrix}
/>
<GameOverOverlay isVisible={isGameOverVisible}>
Game Over
<PlayAgainButton stateHandler={stateHandler} />
</GameOverOverlay>
</BoardStyled>
</div>
<WordList displayedWords={matchedWords} />
</div>
);
Expand Down
60 changes: 60 additions & 0 deletions src/components/FallingBlock.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import * as React from "react";
import { animated, useSpring } from "react-spring";
import { BoardCell } from "../util/BoardCell";

export const FallingBlock = React.memo( ({ fallingLetters, durationRate }: {fallingLetters: BoardCell[], durationRate: number }) => {
const fallenLetters =
fallingLetters.
map((fallingLetterBeforeAndAfter) => (
<FallingLetter
fallingLetterBeforeAndAfter={fallingLetterBeforeAndAfter}
durationRate={durationRate}
key={`f${fallingLetterBeforeAndAfter[0].r}${fallingLetterBeforeAndAfter[0].c}`}
/>)
);
return <>{fallenLetters}</>;
},
);

const FallingLetter = React.memo( ({ fallingLetterBeforeAndAfter, durationRate }: {fallingLetterBeforeAndAfter: BoardCell[], durationRate: number}) => {
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,
marginTop: '0%',
marginBottom: '0%',
},
to: {
gridRow: before.r + 1,
gridColumn: before.c + 1,
marginTop: `${margin}%`,
marginBottom: `-${margin}%`,
},
reset: true,
config: {
duration: durationRate * (after.r - before.r),
}
});

const innerStyle = {
zIndex: 5,
height: "88%",
background: "orange",
border: 2,
borderStyle: "solid",
display: "flex",
justifyContent: "center",
} as const;

return (
<animated.div style={styles} >
<div style={innerStyle} >
{before.char}
</div>
</animated.div>
);
});
4 changes: 4 additions & 0 deletions src/util/boardUtil.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,7 @@ export function getGroundHeight(
}
return board.length - 1;
}

export function getFallDurationMilliseconds(startRow: number, endRow: number) {
return 75 * Math.abs(startRow - endRow);
}
Loading

0 comments on commit 809ae14

Please sign in to comment.