TeaWeb/shared/js/ui/modal/about/Renderer.tsx
2021-04-19 13:27:09 +02:00

752 lines
No EOL
25 KiB
TypeScript

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<UiVariableConsumer<ModalAboutVariables>>(undefined);
const EventsContext = React.createContext<Registry<ModalAboutEvents>>(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<HTMLCanvasElement>();
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 (
<div className={cssStyle.gameContainer}>
<canvas ref={refCanvas} />
</div>
)
});
const SnakeEasterEgg = React.memo(() => {
const variables = useContext(VariablesContext);
const eggShown = variables.useReadOnly("eggShown", undefined, false);
if(eggShown) {
return <SnakeGameRenderer />;
} else {
return null;
}
})
const InfoTitle = React.memo(() => {
const variables = useContext(VariablesContext);
const uiVersion = variables.useReadOnly("uiVersion", undefined, useTr("loading"));
return (
<h1>
TeaSpeak-Client build {uiVersion}
</h1>
);
});
const ModalTitle = React.memo(() => {
const variables = useContext(VariablesContext);
const eggShown = variables.useReadOnly("eggShown", undefined, false);
if(eggShown) {
return <Translatable key={"snake"}>The Snake Game</Translatable>;
} else if(__build.target === "web") {
return <Translatable key={"web"}>About TeaWeb</Translatable>;
} else {
return <Translatable key={"client"}>About TeaClient</Translatable>;
}
});
const SupportEmail = React.memo(() => {
let targetMail;
if(__build.target === "web") {
targetMail = "web.support@teaspeak.de";
} else {
targetMail = "client.support@teaspeak.de";
}
return (
<a href={"mailto:" + targetMail}>{targetMail}</a>
);
});
const VersionInfo = React.memo(() => {
const variables = useContext(VariablesContext);
const result = [];
const uiVersion = variables.useReadOnly("uiVersion", undefined, useTr("loading"));
if(__build.target === "web") {
result.push(
<div className={cssStyle.version} key={"web"}>
<div className={cssStyle.key}><Translatable>TeaWeb</Translatable></div>:
<div className={cssStyle.value}>{uiVersion}</div>
</div>
);
} else {
const nativeVersion = variables.useReadOnly("nativeVersion", undefined, useTr("loading"));
result.push(
<div className={cssStyle.version} key={"native"}>
<div className={cssStyle.key}><Translatable>TeaClient</Translatable></div>:
<div className={cssStyle.value}>{nativeVersion}</div>
</div>
);
result.push(
<div className={cssStyle.version} key={"ui"}>
<div className={cssStyle.key}><Translatable>User Interface</Translatable></div>:
<div className={cssStyle.value}>{uiVersion}</div>
</div>
);
}
return (
<React.Fragment>
{result}
</React.Fragment>
);
});
const LicenseInfo = React.memo(() => {
let applicationName;
if(__build.target === "web") {
applicationName = "TeaWeb";
} else {
applicationName = "TeaClient";
}
return (
<p>
The {applicationName} application is licensed by MPL-2.0<br />
More information here: <a href="https://github.com/TeaSpeak/TeaWeb/blob/master/LICENSE.TXT" target="_blank">https://github.com/TeaSpeak/TeaWeb/blob/master/LICENSE.TXT</a>
</p>
)
});
const MarkusHadenfeldt = React.memo(() => {
const variables = useContext(VariablesContext);
const variable = variables.useVariable("eggShown");
return (
<span onDoubleClick={() => variable.setValue(true)}>
(Markus Hadenfeldt)
</span>
);
})
class Modal extends AbstractModal {
private readonly events: Registry<ModalAboutEvents>;
private readonly variables: UiVariableConsumer<ModalAboutVariables>;
constructor(events: IpcRegistryDescription<ModalAboutEvents>, variables: IpcVariableDescriptor<ModalAboutVariables>) {
super();
this.events = Registry.fromIpcDescription(events);
this.variables = createIpcUiVariableConsumer(variables);
}
renderBody(): React.ReactElement {
return (
<EventsContext.Provider value={this.events}>
<VariablesContext.Provider value={this.variables}>
<div className={joinClassList(cssStyle.container, this.properties.windowed && cssStyle.windowed)}>
<div className={cssStyle.containerLeft}>
<div>
<img src={TeaCupAnimatedImage} alt={useTr("TeaSpeak - Logo")} draggable={false} />
</div>
<div>
Copyright (c) 2017-2021 TeaSpeak <br/>
<MarkusHadenfeldt />
</div>
<div>
<VersionInfo/>
</div>
</div>
<div className={cssStyle.containerRight}>
<InfoTitle/>
<h2><Translatable>Special thanks</Translatable></h2>
<p>
"Яedeemer" (Janni K.)<br />
Chromatic-Solutions (Sofian) for the lovely dark design
</p>
<h2><Translatable>Contact</Translatable></h2>
<p>
<Translatable>E-Mail</Translatable>: <SupportEmail /><br />
<Translatable>WWW</Translatable>: <a href="https://teaspeak.de" target="_blank">https://teaspeak.de</a><br/>
<Translatable>Community</Translatable>: <a href="https://forum.teaspeak.de" target="_blank">https://forum.teaspeak.de</a>
</p>
<h2><Translatable>License</Translatable></h2>
<LicenseInfo />
</div>
<SnakeEasterEgg />
</div>
</VariablesContext.Provider>
</EventsContext.Provider>
);
}
renderTitle(): string | React.ReactElement {
return (
<VariablesContext.Provider value={this.variables}>
<ModalTitle />
</VariablesContext.Provider>
);
}
}
export default Modal;