import {AbstractModal} from "tc-shared/ui/react-elements/modal/Definitions"; import React, {useContext, useEffect} from "react"; import {IpcRegistryDescription, Registry} from "tc-events"; import {ModalAboutEvents, ModalAboutVariables} from "tc-shared/ui/modal/about/Definitions"; import {UiVariableConsumer} from "tc-shared/ui/utils/Variable"; import {createIpcUiVariableConsumer, IpcVariableDescriptor} from "tc-shared/ui/utils/IpcVariable"; import {Translatable} from "tc-shared/ui/react-elements/i18n"; import {joinClassList, useTr} from "tc-shared/ui/react-elements/Helper"; import TeaCupAnimatedImage from "./TeaSpeakCupAnimated.png"; import {LogCategory, logError} from "tc-shared/log"; import {CallOnce} from "tc-shared/proto"; import {EventType, getKeyBoard, KeyEvent} from "tc-shared/PPTListener"; const cssStyle = require("./Renderer.scss"); const VariablesContext = React.createContext>(undefined); const EventsContext = React.createContext>(undefined); interface CanvasProperties { with: number, height: number, timestamp: number, } interface TickProperties { timestamp: number, } interface GameState { name() : string; initialize(game: SnakeGame); finalize(); handleKeyEvent(event: KeyEvent); gameTick(game: SnakeGame, properties: TickProperties); render(context: CanvasRenderingContext2D, properties: CanvasProperties); } class GameStateCriticalError implements GameState { constructor(readonly errorMessage: string) {} name(): string { return "Critical Error"; } initialize() { } finalize() { } handleKeyEvent(event: KeyEvent) {} gameTick(game: SnakeGame, properties: TickProperties) { } render(context: CanvasRenderingContext2D, properties: CanvasProperties) { context.fillStyle = "black"; context.fillRect(0, 0, properties.with, properties.height); const fontPixelSize = Math.max(10, Math.floor(properties.height * 0.025)); context.fillStyle = "red"; context.textAlign = "center"; context.textBaseline = "middle"; context.font = fontPixelSize + "px Lucida Console, monospace"; context.fillText(tr("A critical error happened") + ":", properties.with / 2, (properties.height - fontPixelSize - 2) / 2); context.fillText(this.errorMessage, properties.with / 2, (properties.height + fontPixelSize + 2) / 2); } } class GameStateStart implements GameState { private highScore: number; private spacePressed: boolean; name(): string { return "Start"; } initialize(game: SnakeGame) { this.highScore = game.getHighScore(); this.spacePressed = false; } finalize() { } handleKeyEvent(event: KeyEvent) { if(event.keyCode === "Space") { this.spacePressed = event.type !== EventType.KEY_RELEASE; } } gameTick(game: SnakeGame, properties: TickProperties) { if(this.spacePressed) { game.setState(new GameStateInGame()); return; } this.highScore = game.getHighScore(); } render(context: CanvasRenderingContext2D, properties: CanvasProperties) { const fontPixelSize = Math.max(10, Math.floor(properties.height * 0.05)); const dynamicFontPixelSize = fontPixelSize + Math.sin(properties.timestamp / 750) / 2; context.textAlign = "center"; context.textBaseline = "middle"; context.font = fontPixelSize + "px Lucida Console, monospace"; context.fillStyle = "white"; context.fillText(tr("Welcome to the snake game."), properties.with / 2, properties.height / 2 - fontPixelSize); if(this.highScore > 0) { context.fillText(tr("High score: ") + this.highScore, properties.with / 2, properties.height / 2); } context.font = dynamicFontPixelSize + "px Lucida Console, monospace"; context.fillStyle = "lightblue"; context.fillText(tr("Press 'Space' to start!"), properties.with / 2, properties.height / 2 + fontPixelSize * 2); } } class GameStateGameOver implements GameState { private spacePressed: boolean; constructor(readonly gameScore: number) { } name(): string { return "Start"; } initialize() { this.spacePressed = false; } finalize() { } handleKeyEvent(event: KeyEvent) { if(event.keyCode === "Space") { this.spacePressed = event.type !== EventType.KEY_RELEASE; } } gameTick(game: SnakeGame, properties: TickProperties) { if(this.spacePressed) { game.setState(new GameStateStart()); return; } } render(context: CanvasRenderingContext2D, properties: CanvasProperties) { const fontPixelSize = Math.max(10, Math.floor(properties.height * 0.04)); context.textAlign = "center"; context.textBaseline = "middle"; context.font = (fontPixelSize * 2) + "px Lucida Console, monospace"; context.fillStyle = "red"; context.fillText(tr("Game over!"), properties.with / 2, properties.height / 2 - fontPixelSize * 2); context.font = fontPixelSize + "px Lucida Console, monospace"; context.fillStyle = "white"; context.fillText(tr("Current score: ") + this.gameScore, properties.with / 2, properties.height / 2); context.fillText(tr("Press 'Space' to continue."), properties.with / 2, properties.height / 2 + fontPixelSize * 2); } } type GameDirection = "north" | "south" | "west" | "east"; class GameStateInGame implements GameState { private static readonly kGridWidth = 20; private static readonly kGridHeight = 20; private static readonly kSnakeSpeed = 300; private lastTileMove: number; private snake: GameDirection[]; private snakePosition: { x: number, y: number }; private snakeDirection: GameDirection; private applePosition: { x: number, y: number }; name(): string { return "InGame"; } initialize() { this.snake = []; this.snakePosition = { x: Math.floor(GameStateInGame.kGridWidth / 2), y: Math.floor(GameStateInGame.kGridHeight / 2) }; this.snakeDirection = "north"; this.generateApple([[this.snakePosition.x, this.snakePosition.y]]); } finalize() {} handleKeyEvent(event: KeyEvent) { if(event.type !== EventType.KEY_RELEASE) { switch (event.key) { case "ArrowRight": this.snakeDirection = "east"; break; case "ArrowLeft": this.snakeDirection = "west"; break; case "ArrowUp": this.snakeDirection = "north"; break; case "ArrowDown": this.snakeDirection = "south"; break; } } } gameTick(game: SnakeGame, properties: TickProperties) { if(typeof this.lastTileMove === "undefined") { this.lastTileMove = properties.timestamp; } else { let moveSteps = Math.floor((properties.timestamp - this.lastTileMove) / GameStateInGame.kSnakeSpeed); this.lastTileMove += moveSteps * GameStateInGame.kSnakeSpeed; let blockedTiles: [number, number][]; while(moveSteps-- > 0) { this.snake.unshift(this.snakeDirection); switch (this.snakeDirection) { case "north": this.snakePosition.y -= 1; break; case "east": this.snakePosition.x += 1; break; case "south": this.snakePosition.y += 1; break; case "west": this.snakePosition.x -= 1; break; } let generateApple = false; if(this.snakePosition.x === this.applePosition.x && this.snakePosition.y === this.applePosition.y) { generateApple = true; } else { this.snake.pop(); } blockedTiles = []; if(!this.validateSnake(blockedTiles)) { game.updateHighScore(this.snake.length + 1); game.setState(new GameStateGameOver(this.snake.length + 1)); return; } if(generateApple) { if(!this.generateApple(blockedTiles)) { game.updateHighScore(this.snake.length + 1); game.setState(new GameStateGameOver(this.snake.length + 1)); return; } } } } } render(context: CanvasRenderingContext2D, properties: CanvasProperties) { const fontPixelSize = Math.max(10, Math.floor(properties.height * 0.025)); const borderSize = 2; const paddingTop = 5; const paddingLeft = 5; const tileSize = Math.min( (properties.height - borderSize * 2 - paddingTop * 2) / GameStateInGame.kGridHeight, (properties.with - borderSize * 2 - paddingLeft * 2) / GameStateInGame.kGridWidth ); context.strokeStyle = "green"; context.lineWidth = borderSize; const gridWidth = tileSize * GameStateInGame.kGridWidth; const gridHeight = tileSize * GameStateInGame.kGridHeight; const gridOffsetX = (properties.with - gridWidth) / 2; const gridOffsetY = (properties.height - gridHeight) / 2; context.strokeRect(gridOffsetX - borderSize / 2, gridOffsetY - borderSize / 2, gridWidth + borderSize, gridHeight + borderSize); { context.save(); context.translate(gridOffsetX, gridOffsetY); context.scale(tileSize, tileSize); this.renderSnake(context); context.restore(); } { context.font = fontPixelSize + "px Lucida Console, monospace"; context.fillStyle = "green"; context.textAlign = "left"; context.textBaseline = "top"; const textScore = tr("Score") + ":"; const textScoreBounds = context.measureText(textScore) context.fillText(textScore, gridOffsetX + tileSize / 2, gridOffsetY + tileSize / 2); context.fillText(this.snake.length.toString(), gridOffsetX + tileSize / 2 + textScoreBounds.width + fontPixelSize * .25, gridOffsetY + tileSize / 2); } } renderSnake(context: CanvasRenderingContext2D) { let positionX = this.snakePosition.x, positionY = this.snakePosition.y; for(let tileIndex = 0; tileIndex <= this.snake.length; tileIndex++) { if(tileIndex === 0) { context.fillStyle = "lightgreen"; } else if(tileIndex === 1) { context.fillStyle = "green"; } context.fillRect(positionX, positionY, 1, 1); const tileDirection = this.snake[tileIndex]; switch (tileDirection) { case "north": positionY += 1; break; case "east": positionX -= 1; break; case "south": positionY -= 1; break; case "west": positionX += 1; break; } } context.fillStyle = "red"; context.fillRect(this.applePosition.x, this.applePosition.y, 1, 1); } private generateApple(blockedTiles: [number, number][]): boolean { const freeTiles = []; for(let posX = 0; posX < GameStateInGame.kGridWidth; posX++) { for(let posY = 0; posY < GameStateInGame.kGridHeight; posY++) { if(blockedTiles.findIndex(tile => posX === tile[0] && posY === tile[1]) === -1) { freeTiles.push([posX, posY]); } } } if(freeTiles.length === 0) { return false; } const tile = freeTiles[Math.floor(Math.random() * freeTiles.length)]; this.applePosition = { x: tile[0], y: tile[1] }; return true; } private validateSnake(blockedTiles: [number, number][]) : boolean { let positionX = this.snakePosition.x, positionY = this.snakePosition.y; for(let tileIndex = 0; tileIndex <= this.snake.length; tileIndex++) { if(positionY < 0 || positionY >= GameStateInGame.kGridHeight) { return false; } if(positionX < 0 || positionX >= GameStateInGame.kGridWidth) { return false; } if(blockedTiles.findIndex(tile => tile[0] === positionX && tile[1] === positionY) !== -1) { return false; } blockedTiles.push([positionX, positionY]); switch (this.snake[tileIndex]) { case "north": positionY += 1; break; case "east": positionX -= 1; break; case "south": positionY -= 1; break; case "west": positionX += 1; break; } } return true; } } class SnakeGame { private static readonly kDebugInfo = false; private readonly keyListener: () => void; private readonly canvasElement: HTMLCanvasElement; private readonly canvasContext: CanvasRenderingContext2D; private readonly renderTimings: number[]; private currentFps: number; private currentFrameTime: number; private animationId: number; private tickId: number; private highScore: number; private highScoreListener: (newValue: number) => void; private currentState: GameState; constructor(canvasElement: HTMLCanvasElement) { this.canvasElement = canvasElement; this.canvasContext = this.canvasElement.getContext("2d"); this.setState(new GameStateCriticalError("Missing initial state")); this.currentFps = 0; this.currentFrameTime = 0; this.renderTimings = []; this.canvasContext.imageSmoothingEnabled = false; //this.canvasContext.imageSmoothingQuality = "high"; this.animationId = requestAnimationFrame(() => { this.invokeRender(); }); this.tickId = setInterval(() => { try { this.currentState.gameTick(this, { timestamp: Date.now() }); } catch (error) { logError(LogCategory.GENERAL, tr("Failed to tick current game state: %o"), error); this.setState(new GameStateCriticalError(tr("game tick caused an error"))) } }, 50); { const keyboard = getKeyBoard(); const listener = event => { this.currentState.handleKeyEvent(event); }; keyboard.registerListener(listener); this.keyListener = () => keyboard.unregisterListener(listener); } this.highScore = 0; this.setState(new GameStateStart()); } getHighScore() : number { return this.highScore; } updateHighScore(gameScore: number) : boolean { if(gameScore <= this.highScore) { return false; } this.highScore = gameScore; if(this.highScoreListener) { this.highScoreListener(this.highScore); } return true; } setHighScoreListener(listener: (newValue: number) => void) { this.highScoreListener = listener; } @CallOnce destroy() { this.keyListener(); cancelAnimationFrame(this.animationId); clearInterval(this.tickId); this.tickId = 0; this.animationId = 0; } setState(state: GameState) { this.currentState?.finalize(); this.currentState = state; this.currentState.initialize(this); } private invokeRender() { const frameStart = performance.now(); while(this.renderTimings[0] + 1000 <= frameStart) { this.renderTimings.shift(); } this.renderTimings.push(frameStart); this.currentFps = this.currentFps * .8 + this.renderTimings.length * .2; try { this.render(); } catch (error) { logError(LogCategory.GENERAL, tr("Failed to render game: %o"), error); } const frameEnd = performance.now(); this.currentFrameTime = this.currentFrameTime * .8 + (frameEnd - frameStart) * .2; this.animationId = requestAnimationFrame(() => { this.invokeRender(); }); } private render() { this.canvasElement.width = this.canvasElement.clientWidth; this.canvasElement.height = this.canvasElement.clientHeight; const properties: CanvasProperties = { with: this.canvasElement.clientWidth, height: this.canvasElement.clientHeight, timestamp: performance.now() }; const ctx = this.canvasContext; ctx.fillStyle = "black"; ctx.fillRect(0, 0, properties.with, properties.height); this.currentState.render(this.canvasContext, properties); /* Debug Info */ if(SnakeGame.kDebugInfo) { const fontPixelSize = Math.max(10, Math.floor(properties.height * 0.025)); const keyWidth = 12 * fontPixelSize; ctx.fillStyle = "white"; ctx.textAlign = "left"; ctx.textBaseline = "top"; ctx.font = fontPixelSize + "px Lucida Console, monospace"; ctx.fillText("FPS:", 10, 10); ctx.fillText(this.currentFps.toFixed(2), keyWidth, 10); ctx.fillText("Frame Time (ms):", 10, 10 + fontPixelSize * 1.2); ctx.fillText(this.currentFrameTime.toFixed(2), keyWidth, 10 + fontPixelSize * 1.2); ctx.fillText("Stage Name:", 10, 10 + fontPixelSize * 1.2 * 2); ctx.fillText(this.currentState.name(), keyWidth, 10 + fontPixelSize * 1.2 * 2); } } } const SnakeGameRenderer = React.memo(() => { const events = useContext(EventsContext); const refCanvas = React.createRef(); useEffect(() => { const game = new SnakeGame(refCanvas.current); const listenerHighScore = events.on("notify_high_score", event => game.updateHighScore(event.score)); game.setHighScoreListener(newValue => events.fire("action_update_high_score", { score: newValue })); events.fire("query_high_score"); return () => { listenerHighScore(); game.destroy(); } }, []); return (
) }); const SnakeEasterEgg = React.memo(() => { const variables = useContext(VariablesContext); const eggShown = variables.useReadOnly("eggShown", undefined, false); if(eggShown) { return ; } else { return null; } }) const InfoTitle = React.memo(() => { const variables = useContext(VariablesContext); const uiVersion = variables.useReadOnly("uiVersion", undefined, useTr("loading")); return (

TeaSpeak-Client build {uiVersion}

); }); const ModalTitle = React.memo(() => { const variables = useContext(VariablesContext); const eggShown = variables.useReadOnly("eggShown", undefined, false); if(eggShown) { return The Snake Game; } else if(__build.target === "web") { return About TeaWeb; } else { return About TeaClient; } }); const SupportEmail = React.memo(() => { let targetMail; if(__build.target === "web") { targetMail = "web.support@teaspeak.de"; } else { targetMail = "client.support@teaspeak.de"; } return ( {targetMail} ); }); const VersionInfo = React.memo(() => { const variables = useContext(VariablesContext); const result = []; const uiVersion = variables.useReadOnly("uiVersion", undefined, useTr("loading")); if(__build.target === "web") { result.push(
TeaWeb
:
{uiVersion}
); } else { const nativeVersion = variables.useReadOnly("nativeVersion", undefined, useTr("loading")); result.push(
TeaClient
:
{nativeVersion}
); result.push(
User Interface
:
{uiVersion}
); } return ( {result} ); }); const LicenseInfo = React.memo(() => { let applicationName; if(__build.target === "web") { applicationName = "TeaWeb"; } else { applicationName = "TeaClient"; } return (

The {applicationName} application is licensed by MPL-2.0
More information here: https://github.com/TeaSpeak/TeaWeb/blob/master/LICENSE.TXT

) }); const MarkusHadenfeldt = React.memo(() => { const variables = useContext(VariablesContext); const variable = variables.useVariable("eggShown"); return ( variable.setValue(true)}> (Markus Hadenfeldt) ); }) class Modal extends AbstractModal { private readonly events: Registry; private readonly variables: UiVariableConsumer; constructor(events: IpcRegistryDescription, variables: IpcVariableDescriptor) { super(); this.events = Registry.fromIpcDescription(events); this.variables = createIpcUiVariableConsumer(variables); } renderBody(): React.ReactElement { return (
{useTr("TeaSpeak
Copyright (c) 2017-2021 TeaSpeak

Special thanks

"Яedeemer" (Janni K.)
Chromatic-Solutions (Sofian) for the lovely dark design

Contact

E-Mail:
WWW: https://teaspeak.de
Community: https://forum.teaspeak.de

License

); } renderTitle(): string | React.ReactElement { return ( ); } } export default Modal;