update snake and add about this server to system general

This commit is contained in:
Matt Hill
2026-02-25 17:15:20 -07:00
parent 803dd38d96
commit 827458562b
8 changed files with 407 additions and 209 deletions

View File

@@ -696,4 +696,9 @@ export default {
766: 'Ihr Server ist jetzt erreichbar unter', 766: 'Ihr Server ist jetzt erreichbar unter',
767: 'Servername', 767: 'Servername',
768: 'Adressanforderungen', 768: 'Adressanforderungen',
769: 'Version, Root-CA und mehr',
770: 'Details',
771: 'Spiel vorbei',
772: 'Beliebige Taste drücken oder tippen zum Starten',
773: 'Beliebige Taste drücken oder tippen zum Neustarten',
} satisfies i18n } satisfies i18n

View File

@@ -696,4 +696,9 @@ export const ENGLISH: Record<string, number> = {
'Your server is now reachable at': 766, 'Your server is now reachable at': 766,
'Server Name': 767, 'Server Name': 767,
'Address Requirements': 768, 'Address Requirements': 768,
'Version, Root CA, and more': 769,
'Details': 770,
'Game Over': 771,
'Press any key or tap to start': 772,
'Press any key or tap to play again': 773,
} }

View File

@@ -696,4 +696,9 @@ export default {
766: 'Su servidor ahora es accesible en', 766: 'Su servidor ahora es accesible en',
767: 'Nombre del servidor', 767: 'Nombre del servidor',
768: 'Requisitos de dirección', 768: 'Requisitos de dirección',
769: 'Versión, CA raíz y más',
770: 'Detalles',
771: 'Fin del juego',
772: 'Pulsa cualquier tecla o toca para empezar',
773: 'Pulsa cualquier tecla o toca para jugar de nuevo',
} satisfies i18n } satisfies i18n

View File

@@ -696,4 +696,9 @@ export default {
766: 'Votre serveur est maintenant accessible à', 766: 'Votre serveur est maintenant accessible à',
767: 'Nom du serveur', 767: 'Nom du serveur',
768: "Exigences de l'adresse", 768: "Exigences de l'adresse",
769: 'Version, CA racine et plus',
770: 'Détails',
771: 'Partie terminée',
772: "Appuyez sur une touche ou touchez l'écran pour commencer",
773: "Appuyez sur une touche ou touchez l'écran pour rejouer",
} satisfies i18n } satisfies i18n

View File

@@ -696,4 +696,9 @@ export default {
766: 'Twój serwer jest teraz dostępny pod adresem', 766: 'Twój serwer jest teraz dostępny pod adresem',
767: 'Nazwa serwera', 767: 'Nazwa serwera',
768: 'Wymagania adresu', 768: 'Wymagania adresu',
769: 'Wersja, Root CA i więcej',
770: 'Szczegóły',
771: 'Koniec gry',
772: 'Naciśnij dowolny klawisz lub dotknij, aby rozpocząć',
773: 'Naciśnij dowolny klawisz lub dotknij, aby zagrać ponownie',
} satisfies i18n } satisfies i18n

View File

@@ -48,6 +48,7 @@ import { ApiService } from 'src/app/services/api/embassy-api.service'
import { OSService } from 'src/app/services/os.service' import { OSService } from 'src/app/services/os.service'
import { DataModel } from 'src/app/services/patch-db/data-model' import { DataModel } from 'src/app/services/patch-db/data-model'
import { TitleDirective } from 'src/app/services/title.service' import { TitleDirective } from 'src/app/services/title.service'
import { ABOUT } from 'src/app/routes/portal/components/header/about.component'
import { SnekDirective } from './snek.directive' import { SnekDirective } from './snek.directive'
import { UPDATE } from './update.component' import { UPDATE } from './update.component'
import { KeyboardSelectComponent } from './keyboard-select.component' import { KeyboardSelectComponent } from './keyboard-select.component'
@@ -62,18 +63,29 @@ import { ServerNameDialog } from './server-name.dialog'
{{ 'General Settings' | i18n }} {{ 'General Settings' | i18n }}
</ng-container> </ng-container>
@if (server(); as server) { @if (server(); as server) {
<div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.info" />
<span tuiTitle>
<strong>{{ 'About this server' | i18n }}</strong>
<span tuiSubtitle>
{{ 'Version, Root CA, and more' | i18n }}
</span>
</span>
<button tuiButton (click)="about()">
{{ 'Details' | i18n }}
</button>
</div>
<div tuiCell tuiAppearance="outline-grayscale"> <div tuiCell tuiAppearance="outline-grayscale">
<tui-icon icon="@tui.zap" (click)="count = count + 1" /> <tui-icon icon="@tui.zap" (click)="count = count + 1" />
<span tuiTitle> <span tuiTitle>
<strong>{{ 'Software Update' | i18n }}</strong> <strong>
<span tuiSubtitle [style.flex-wrap]="'wrap'"> {{ 'Software Update' | i18n }}
{{ server.version }}
@if (os.showUpdate$ | async) { @if (os.showUpdate$ | async) {
<tui-badge-notification> <tui-badge-notification>
{{ 'Update available' | i18n }} {{ 'Update available' | i18n }}
</tui-badge-notification> </tui-badge-notification>
} }
</span> </strong>
</span> </span>
<button <button
tuiButton tuiButton
@@ -277,6 +289,10 @@ export default class SystemGeneralComponent {
count = 0 count = 0
about() {
this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe()
}
readonly server = toSignal(this.patch.watch$('serverInfo')) readonly server = toSignal(this.patch.watch$('serverInfo'))
readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore')) readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore'))
readonly os = inject(OSService) readonly os = inject(OSService)

View File

@@ -1,21 +1,69 @@
import { import {
AfterViewInit, afterNextRender,
ChangeDetectionStrategy,
Component, Component,
DestroyRef,
ElementRef,
HostListener, HostListener,
inject, inject,
OnDestroy, signal,
DOCUMENT, viewChild,
} from '@angular/core' } from '@angular/core'
import { i18nPipe, pauseFor } from '@start9labs/shared' import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core' import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
import { injectContext } from '@taiga-ui/polymorpheus' import { injectContext } from '@taiga-ui/polymorpheus'
type GameState = 'ready' | 'playing' | 'dead'
interface Point {
x: number
y: number
}
interface Snake {
cells: Point[]
dx: number
dy: number
maxCells: number
}
type RGB = [number, number, number]
const HEAD_COLOR: RGB = [47, 223, 117] // #2fdf75
const TAIL_COLOR: RGB = [20, 90, 48] // #145a30
const GRID_W = 40
const GRID_H = 26
const SPEED = 45
const STARTING_LENGTH = 4
function lerpColor(from: RGB, to: RGB, t: number): string {
const r = Math.round(from[0] + (to[0] - from[0]) * t)
const g = Math.round(from[1] + (to[1] - from[1]) * t)
const b = Math.round(from[2] + (to[2] - from[2]) * t)
return `rgb(${r},${g},${b})`
}
@Component({ @Component({
template: ` template: `
<div class="canvas-center"> <div class="game-container">
<canvas id="game"></canvas> <canvas #game></canvas>
@if (state() === 'ready') {
<div class="overlay">
<strong>{{ 'Press any key or tap to start' | i18n }}</strong>
<span class="arrows">← ↑ ↓ →</span>
</div>
}
@if (state() === 'dead') {
<div class="overlay">
<strong class="game-over">{{ 'Game Over' | i18n }}</strong>
<span>{{ 'Score' | i18n }}: {{ score }}</span>
<span class="hint">
{{ 'Press any key or tap to play again' | i18n }}
</span>
</div>
}
</div> </div>
<footer class="footer"> <footer>
<strong>{{ 'Score' | i18n }}: {{ score }}</strong> <strong>{{ 'Score' | i18n }}: {{ score }}</strong>
<span>{{ 'High score' | i18n }}: {{ highScore }}</span> <span>{{ 'High score' | i18n }}: {{ highScore }}</span>
<button tuiButton (click)="dismiss()"> <button tuiButton (click)="dismiss()">
@@ -24,272 +72,380 @@ import { injectContext } from '@taiga-ui/polymorpheus'
</footer> </footer>
`, `,
styles: ` styles: `
.canvas-center { :host {
min-height: 50vh; display: flex;
padding-top: 20px; flex-direction: column;
gap: 1rem;
}
.game-container {
position: relative;
background: #111;
border-radius: 0.5rem;
display: flex; display: flex;
align-items: center;
justify-content: center; justify-content: center;
} }
.footer { canvas {
display: block;
}
.overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 0.75rem;
background: rgba(0, 0, 0, 0.7);
border-radius: 0.5rem;
font-size: 1.125rem;
color: #fff;
}
.game-over {
font-size: 1.5rem;
}
.arrows {
font-size: 1.5rem;
letter-spacing: 0.5rem;
opacity: 0.5;
}
.hint {
opacity: 0.6;
font-size: 0.875rem;
}
footer {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding-top: 32px;
} }
`, `,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe], imports: [TuiButton, i18nPipe],
}) })
export class SnekComponent implements AfterViewInit, OnDestroy { export class SnekComponent {
private readonly document = inject(DOCUMENT) private readonly destroyRef = inject(DestroyRef)
private readonly dialog = injectContext<TuiDialogContext<number, number>>() private readonly dialog = injectContext<TuiDialogContext<number, number>>()
private readonly canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('game')
readonly state = signal<GameState>('ready')
highScore: number = this.dialog.data highScore: number = this.dialog.data
score = 0 score = 0
private readonly speed = 45
private readonly width = 40
private readonly height = 26
private grid = NaN private grid = NaN
private ctx!: CanvasRenderingContext2D
private image = new Image()
private imageLoaded = false
private animationId = 0
private lastTime = 0
private dead = false
private readonly startingLength = 4 private snake!: Snake
private bitcoin: Point = { x: NaN, y: NaN }
private moveQueue: string[] = []
private xDown?: number constructor() {
private yDown?: number this.image.onload = () => {
private canvas!: HTMLCanvasElement this.imageLoaded = true
private image!: HTMLImageElement }
private context!: CanvasRenderingContext2D this.image.src = 'assets/img/icons/bitcoin.svg'
private snake: any afterNextRender(() => {
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN } this.initCanvas()
this.snake = this.createSnake()
this.spawnBitcoin()
this.drawFrame()
this.animationId = requestAnimationFrame(t => this.loop(t))
})
private moveQueue: String[] = [] this.destroyRef.onDestroy(() => {
private destroyed = false cancelAnimationFrame(this.animationId)
})
}
dismiss() { dismiss() {
this.dialog.completeWith(this.highScore) this.dialog.completeWith(this.highScore)
} }
@HostListener('document:keydown', ['$event']) @HostListener('document:keydown', ['$event'])
keyEvent(e: KeyboardEvent) { onKeydown(e: KeyboardEvent) {
this.moveQueue.push(e.key) if (
} e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
@HostListener('touchstart', ['$event']) e.key === 'ArrowLeft' ||
touchStart(e: TouchEvent) { e.key === 'ArrowRight'
this.handleTouchStart(e) ) {
} e.preventDefault()
@HostListener('touchmove', ['$event'])
touchMove(e: TouchEvent) {
this.handleTouchMove(e)
}
@HostListener('window:resize')
sizeChange() {
this.init()
}
ngOnDestroy() {
this.destroyed = true
}
ngAfterViewInit() {
this.init()
this.image = new Image()
this.image.onload = () => {
requestAnimationFrame(async () => await this.loop())
}
this.image.src = '../../../../../../assets/img/icons/bitcoin.svg'
}
init() {
this.canvas = this.document.querySelector('canvas#game')!
this.canvas.style.border = '1px solid #e0e0e0'
this.context = this.canvas.getContext('2d')!
const container = this.document.querySelector('.canvas-center')!
this.grid = Math.min(
Math.floor(container.clientWidth / this.width),
Math.floor(container.clientHeight / this.height),
)
this.snake = {
x: this.grid * (Math.floor(this.width / 2) - this.startingLength),
y: this.grid * Math.floor(this.height / 2),
// snake velocity. moves one grid length every frame in either the x or y direction
dx: this.grid,
dy: 0,
// keep track of all grids the snake body occupies
cells: [],
// length of the snake. grows when eating an bitcoin
maxCells: this.startingLength,
}
this.bitcoin = {
x: this.getRandomInt(0, this.width) * this.grid,
y: this.getRandomInt(0, this.height) * this.grid,
} }
this.canvas.width = this.grid * this.width const current = this.state()
this.canvas.height = this.grid * this.height
this.context.imageSmoothingEnabled = false
}
getTouches(evt: TouchEvent) { if (current === 'ready') {
return evt.touches this.state.set('playing')
} this.lastTime = 0
// Queue directional input so first keypress sets direction
handleTouchStart(evt: TouchEvent) { if (e.key.startsWith('Arrow')) {
const firstTouch = this.getTouches(evt)[0] this.moveQueue.push(e.key)
this.xDown = firstTouch?.clientX }
this.yDown = firstTouch?.clientY
}
handleTouchMove(evt: TouchEvent) {
if (!this.xDown || !this.yDown) {
return return
} }
var xUp = evt.touches[0]?.clientX || 0 if (current === 'dead' && !this.dead) {
var yUp = evt.touches[0]?.clientY || 0 this.restart()
return
var xDiff = this.xDown - xUp }
var yDiff = this.yDown - yUp
if (current === 'playing') {
if (Math.abs(xDiff) > Math.abs(yDiff)) { this.moveQueue.push(e.key)
/*most significant*/
if (xDiff > 0) {
this.moveQueue.push('ArrowLeft')
} else {
this.moveQueue.push('ArrowRight')
}
} else {
if (yDiff > 0) {
this.moveQueue.push('ArrowUp')
} else {
this.moveQueue.push('ArrowDown')
}
} }
/* reset values */
this.xDown = undefined
this.yDown = undefined
} }
// game loop @HostListener('touchstart', ['$event'])
async loop() { onTouchStart(e: TouchEvent) {
if (this.destroyed) return const current = this.state()
await pauseFor(this.speed) if (current === 'ready') {
this.state.set('playing')
this.lastTime = 0
return
}
requestAnimationFrame(async () => await this.loop()) if (current === 'dead' && !this.dead) {
this.restart()
return
}
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height) this.touchStart = {
x: e.touches[0]?.clientX ?? 0,
y: e.touches[0]?.clientY ?? 0,
}
}
// move snake by its velocity @HostListener('touchmove', ['$event'])
this.snake.x += this.snake.dx onTouchMove(e: TouchEvent) {
this.snake.y += this.snake.dy if (!this.touchStart || this.state() !== 'playing') return
const xUp = e.touches[0]?.clientX ?? 0
const yUp = e.touches[0]?.clientY ?? 0
const xDiff = this.touchStart.x - xUp
const yDiff = this.touchStart.y - yUp
if (Math.abs(xDiff) > Math.abs(yDiff)) {
this.moveQueue.push(xDiff > 0 ? 'ArrowLeft' : 'ArrowRight')
} else {
this.moveQueue.push(yDiff > 0 ? 'ArrowUp' : 'ArrowDown')
}
this.touchStart = null
}
@HostListener('window:resize')
onResize() {
this.initCanvas()
this.drawFrame()
}
private touchStart: Point | null = null
private initCanvas() {
const canvas = this.canvasRef()?.nativeElement
if (!canvas) return
this.ctx = canvas.getContext('2d')!
const container = canvas.parentElement!
// Size grid based on available width, cap so canvas height stays reasonable
const maxHeight = window.innerHeight * 0.55
this.grid = Math.min(
Math.floor(container.clientWidth / GRID_W),
Math.floor(maxHeight / GRID_H),
)
canvas.width = this.grid * GRID_W
canvas.height = this.grid * GRID_H
}
private createSnake(): Snake {
return {
cells: [],
dx: this.grid,
dy: 0,
maxCells: STARTING_LENGTH,
}
}
private getStartX(): number {
return this.grid * (Math.floor(GRID_W / 2) - STARTING_LENGTH)
}
private getStartY(): number {
return this.grid * Math.floor(GRID_H / 2)
}
private spawnBitcoin() {
this.bitcoin = {
x: this.randomInt(0, GRID_W) * this.grid,
y: this.randomInt(0, GRID_H) * this.grid,
}
}
private restart() {
this.score = 0
this.snake = this.createSnake()
this.moveQueue = []
this.spawnBitcoin()
this.lastTime = 0
this.state.set('playing')
}
private loop(timestamp: number) {
this.animationId = requestAnimationFrame(t => this.loop(t))
if (this.state() !== 'playing') return
if (this.lastTime && timestamp - this.lastTime < SPEED) return
this.lastTime = timestamp
this.update()
this.drawFrame()
}
private update() {
// Process next queued move
if (this.moveQueue.length) { if (this.moveQueue.length) {
const move = this.moveQueue.shift() const move = this.moveQueue.shift()!
// left arrow key
if (move === 'ArrowLeft' && this.snake.dx === 0) { if (move === 'ArrowLeft' && this.snake.dx === 0) {
this.snake.dx = -this.grid this.snake.dx = -this.grid
this.snake.dy = 0 this.snake.dy = 0
} } else if (move === 'ArrowUp' && this.snake.dy === 0) {
// up arrow key
else if (move === 'ArrowUp' && this.snake.dy === 0) {
this.snake.dy = -this.grid this.snake.dy = -this.grid
this.snake.dx = 0 this.snake.dx = 0
} } else if (move === 'ArrowRight' && this.snake.dx === 0) {
// right arrow key
else if (move === 'ArrowRight' && this.snake.dx === 0) {
this.snake.dx = this.grid this.snake.dx = this.grid
this.snake.dy = 0 this.snake.dy = 0
} } else if (move === 'ArrowDown' && this.snake.dy === 0) {
// down arrow key
else if (move === 'ArrowDown' && this.snake.dy === 0) {
this.snake.dy = this.grid this.snake.dy = this.grid
this.snake.dx = 0 this.snake.dx = 0
} }
} }
// edge death // Determine new head position
if ( const prev = this.snake.cells[0]
this.snake.x < 0 || const newHead: Point = prev
this.snake.y < 0 || ? { x: prev.x + this.snake.dx, y: prev.y + this.snake.dy }
this.snake.x >= this.canvas.width || : {
this.snake.y >= this.canvas.height x: this.getStartX() + this.snake.dx,
) { y: this.getStartY() + this.snake.dy,
this.death() }
}
// keep track of where snake has been. front of the array is always the head this.snake.cells.unshift(newHead)
this.snake.cells.unshift({ x: this.snake.x, y: this.snake.y })
// remove cells as we move away from them // Trim tail
if (this.snake.cells.length > this.snake.maxCells) { while (this.snake.cells.length > this.snake.maxCells) {
this.snake.cells.pop() this.snake.cells.pop()
} }
// draw bitcoin const canvas = this.canvasRef()?.nativeElement
this.context.fillStyle = '#ff4961' if (!canvas) return
this.context.drawImage(
this.image,
this.bitcoin.x - 1,
this.bitcoin.y - 1,
this.grid + 2,
this.grid + 2,
)
// draw snake one cell at a time // Wall collision
this.context.fillStyle = '#2fdf75' if (
newHead.x < 0 ||
newHead.y < 0 ||
newHead.x >= canvas.width ||
newHead.y >= canvas.height
) {
this.onDeath()
return
}
const firstCell = this.snake.cells[0] // Self collision
for (let i = 1; i < this.snake.cells.length; i++) {
for (let index = 0; index < this.snake.cells.length; index++) { const cell = this.snake.cells[i]
const cell = this.snake.cells[index] if (cell && newHead.x === cell.x && newHead.y === cell.y) {
this.onDeath()
// drawing 1 px smaller than the grid creates a grid effect in the snake body so you can see how long it is return
this.context.fillRect(cell.x, cell.y, this.grid - 1, this.grid - 1)
// snake ate bitcoin
if (cell.x === this.bitcoin.x && cell.y === this.bitcoin.y) {
this.score++
this.highScore = Math.max(this.score, this.highScore)
this.snake.maxCells++
this.bitcoin.x = this.getRandomInt(0, this.width) * this.grid
this.bitcoin.y = this.getRandomInt(0, this.height) * this.grid
} }
}
if (index > 0) { // Eat bitcoin
// check collision with all cells after this one (modified bubble sort) if (newHead.x === this.bitcoin.x && newHead.y === this.bitcoin.y) {
// snake occupies same space as a body part. reset game this.score++
if ( this.highScore = Math.max(this.score, this.highScore)
firstCell.x === this.snake.cells[index].x && this.snake.maxCells++
firstCell.y === this.snake.cells[index].y this.spawnBitcoin()
) {
this.death()
}
}
} }
} }
death() { private onDeath() {
this.snake.x = this.dead = true
this.grid * (Math.floor(this.width / 2) - this.startingLength) this.state.set('dead')
this.snake.y = this.grid * Math.floor(this.height / 2) // Brief delay before accepting restart input
this.snake.cells = [] setTimeout(() => {
this.snake.maxCells = this.startingLength this.dead = false
this.snake.dx = this.grid }, 300)
this.snake.dy = 0
this.bitcoin.x = this.getRandomInt(0, 25) * this.grid
this.bitcoin.y = this.getRandomInt(0, 25) * this.grid
this.score = 0
} }
getRandomInt(min: number, max: number) { private drawFrame() {
const canvas = this.canvasRef()?.nativeElement
if (!canvas || !this.ctx) return
this.ctx.clearRect(0, 0, canvas.width, canvas.height)
this.drawBitcoin()
this.drawSnake()
}
private drawBitcoin() {
if (!this.imageLoaded) return
this.ctx.drawImage(
this.image,
this.bitcoin.x,
this.bitcoin.y,
this.grid,
this.grid,
)
}
private drawSnake() {
const { cells } = this.snake
if (cells.length === 0) {
// Draw initial position in bottom-left corner (out of overlay text)
const x = STARTING_LENGTH * this.grid
const canvas = this.canvasRef()?.nativeElement
const y = canvas ? canvas.height - this.grid * 2 : this.getStartY()
for (let i = 0; i < STARTING_LENGTH; i++) {
const t = STARTING_LENGTH > 1 ? i / (STARTING_LENGTH - 1) : 0
this.ctx.fillStyle = lerpColor(HEAD_COLOR, TAIL_COLOR, t)
const r = i === 0 ? this.grid * 0.35 : this.grid * 0.2
const size = this.grid - 1
this.ctx.beginPath()
this.ctx.roundRect(x - i * this.grid + 0.5, y + 0.5, size, size, r)
this.ctx.fill()
}
return
}
// Draw tail-first so head renders on top
for (let i = cells.length - 1; i >= 0; i--) {
const cell = cells[i]
if (!cell) continue
const t = cells.length > 1 ? i / (cells.length - 1) : 0
this.ctx.fillStyle = lerpColor(HEAD_COLOR, TAIL_COLOR, t)
const r = i === 0 ? this.grid * 0.35 : this.grid * 0.2
const size = this.grid - 1
this.ctx.beginPath()
this.ctx.roundRect(cell.x + 0.5, cell.y + 0.5, size, size, r)
this.ctx.fill()
}
}
private randomInt(min: number, max: number): number {
return Math.floor(Math.random() * (max - min)) + min return Math.floor(Math.random() * (max - min)) + min
} }
} }

View File

@@ -27,6 +27,7 @@ export class SnekDirective {
this.dialog this.dialog
.openComponent<number>(new PolymorpheusComponent(SnekComponent), { .openComponent<number>(new PolymorpheusComponent(SnekComponent), {
label: 'Snake!' as i18nKey, label: 'Snake!' as i18nKey,
size: 'l',
closeable: false, closeable: false,
dismissible: false, dismissible: false,
data: this.snek, data: this.snek,