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',
767: 'Servername',
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

View File

@@ -696,4 +696,9 @@ export const ENGLISH: Record<string, number> = {
'Your server is now reachable at': 766,
'Server Name': 767,
'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',
767: 'Nombre del servidor',
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

View File

@@ -696,4 +696,9 @@ export default {
766: 'Votre serveur est maintenant accessible à',
767: 'Nom du serveur',
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

View File

@@ -696,4 +696,9 @@ export default {
766: 'Twój serwer jest teraz dostępny pod adresem',
767: 'Nazwa serwera',
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

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 { DataModel } from 'src/app/services/patch-db/data-model'
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 { UPDATE } from './update.component'
import { KeyboardSelectComponent } from './keyboard-select.component'
@@ -62,18 +63,29 @@ import { ServerNameDialog } from './server-name.dialog'
{{ 'General Settings' | i18n }}
</ng-container>
@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">
<tui-icon icon="@tui.zap" (click)="count = count + 1" />
<span tuiTitle>
<strong>{{ 'Software Update' | i18n }}</strong>
<span tuiSubtitle [style.flex-wrap]="'wrap'">
{{ server.version }}
<strong>
{{ 'Software Update' | i18n }}
@if (os.showUpdate$ | async) {
<tui-badge-notification>
{{ 'Update available' | i18n }}
</tui-badge-notification>
}
</span>
</strong>
</span>
<button
tuiButton
@@ -277,6 +289,10 @@ export default class SystemGeneralComponent {
count = 0
about() {
this.dialog.openComponent(ABOUT, { label: 'About this server' }).subscribe()
}
readonly server = toSignal(this.patch.watch$('serverInfo'))
readonly score = toSignal(this.patch.watch$('ui', 'snakeHighScore'))
readonly os = inject(OSService)

View File

@@ -1,21 +1,69 @@
import {
AfterViewInit,
afterNextRender,
ChangeDetectionStrategy,
Component,
DestroyRef,
ElementRef,
HostListener,
inject,
OnDestroy,
DOCUMENT,
signal,
viewChild,
} from '@angular/core'
import { i18nPipe, pauseFor } from '@start9labs/shared'
import { i18nPipe } from '@start9labs/shared'
import { TuiButton, TuiDialogContext } from '@taiga-ui/core'
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({
template: `
<div class="canvas-center">
<canvas id="game"></canvas>
<div class="game-container">
<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>
<footer class="footer">
<footer>
<strong>{{ 'Score' | i18n }}: {{ score }}</strong>
<span>{{ 'High score' | i18n }}: {{ highScore }}</span>
<button tuiButton (click)="dismiss()">
@@ -24,272 +72,380 @@ import { injectContext } from '@taiga-ui/polymorpheus'
</footer>
`,
styles: `
.canvas-center {
min-height: 50vh;
padding-top: 20px;
:host {
display: flex;
flex-direction: column;
gap: 1rem;
}
.game-container {
position: relative;
background: #111;
border-radius: 0.5rem;
display: flex;
align-items: 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;
align-items: center;
justify-content: space-between;
padding-top: 32px;
}
`,
changeDetection: ChangeDetectionStrategy.OnPush,
imports: [TuiButton, i18nPipe],
})
export class SnekComponent implements AfterViewInit, OnDestroy {
private readonly document = inject(DOCUMENT)
export class SnekComponent {
private readonly destroyRef = inject(DestroyRef)
private readonly dialog = injectContext<TuiDialogContext<number, number>>()
private readonly canvasRef = viewChild<ElementRef<HTMLCanvasElement>>('game')
readonly state = signal<GameState>('ready')
highScore: number = this.dialog.data
score = 0
private readonly speed = 45
private readonly width = 40
private readonly height = 26
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
private yDown?: number
private canvas!: HTMLCanvasElement
private image!: HTMLImageElement
private context!: CanvasRenderingContext2D
constructor() {
this.image.onload = () => {
this.imageLoaded = true
}
this.image.src = 'assets/img/icons/bitcoin.svg'
private snake: any
private bitcoin: { x: number; y: number } = { x: NaN, y: NaN }
afterNextRender(() => {
this.initCanvas()
this.snake = this.createSnake()
this.spawnBitcoin()
this.drawFrame()
this.animationId = requestAnimationFrame(t => this.loop(t))
})
private moveQueue: String[] = []
private destroyed = false
this.destroyRef.onDestroy(() => {
cancelAnimationFrame(this.animationId)
})
}
dismiss() {
this.dialog.completeWith(this.highScore)
}
@HostListener('document:keydown', ['$event'])
keyEvent(e: KeyboardEvent) {
this.moveQueue.push(e.key)
}
@HostListener('touchstart', ['$event'])
touchStart(e: TouchEvent) {
this.handleTouchStart(e)
}
@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,
onKeydown(e: KeyboardEvent) {
if (
e.key === 'ArrowUp' ||
e.key === 'ArrowDown' ||
e.key === 'ArrowLeft' ||
e.key === 'ArrowRight'
) {
e.preventDefault()
}
this.canvas.width = this.grid * this.width
this.canvas.height = this.grid * this.height
this.context.imageSmoothingEnabled = false
}
const current = this.state()
getTouches(evt: TouchEvent) {
return evt.touches
}
handleTouchStart(evt: TouchEvent) {
const firstTouch = this.getTouches(evt)[0]
this.xDown = firstTouch?.clientX
this.yDown = firstTouch?.clientY
}
handleTouchMove(evt: TouchEvent) {
if (!this.xDown || !this.yDown) {
if (current === 'ready') {
this.state.set('playing')
this.lastTime = 0
// Queue directional input so first keypress sets direction
if (e.key.startsWith('Arrow')) {
this.moveQueue.push(e.key)
}
return
}
var xUp = evt.touches[0]?.clientX || 0
var yUp = evt.touches[0]?.clientY || 0
var xDiff = this.xDown - xUp
var yDiff = this.yDown - yUp
if (Math.abs(xDiff) > Math.abs(yDiff)) {
/*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')
}
if (current === 'dead' && !this.dead) {
this.restart()
return
}
if (current === 'playing') {
this.moveQueue.push(e.key)
}
/* reset values */
this.xDown = undefined
this.yDown = undefined
}
// game loop
async loop() {
if (this.destroyed) return
@HostListener('touchstart', ['$event'])
onTouchStart(e: TouchEvent) {
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
this.snake.x += this.snake.dx
this.snake.y += this.snake.dy
@HostListener('touchmove', ['$event'])
onTouchMove(e: TouchEvent) {
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) {
const move = this.moveQueue.shift()
// left arrow key
const move = this.moveQueue.shift()!
if (move === 'ArrowLeft' && this.snake.dx === 0) {
this.snake.dx = -this.grid
this.snake.dy = 0
}
// up arrow key
else if (move === 'ArrowUp' && this.snake.dy === 0) {
} else if (move === 'ArrowUp' && this.snake.dy === 0) {
this.snake.dy = -this.grid
this.snake.dx = 0
}
// right arrow key
else if (move === 'ArrowRight' && this.snake.dx === 0) {
} else if (move === 'ArrowRight' && this.snake.dx === 0) {
this.snake.dx = this.grid
this.snake.dy = 0
}
// down arrow key
else if (move === 'ArrowDown' && this.snake.dy === 0) {
} else if (move === 'ArrowDown' && this.snake.dy === 0) {
this.snake.dy = this.grid
this.snake.dx = 0
}
}
// edge death
if (
this.snake.x < 0 ||
this.snake.y < 0 ||
this.snake.x >= this.canvas.width ||
this.snake.y >= this.canvas.height
) {
this.death()
}
// Determine new head position
const prev = this.snake.cells[0]
const newHead: Point = prev
? { x: prev.x + this.snake.dx, y: prev.y + this.snake.dy }
: {
x: this.getStartX() + this.snake.dx,
y: this.getStartY() + this.snake.dy,
}
// keep track of where snake has been. front of the array is always the head
this.snake.cells.unshift({ x: this.snake.x, y: this.snake.y })
this.snake.cells.unshift(newHead)
// remove cells as we move away from them
if (this.snake.cells.length > this.snake.maxCells) {
// Trim tail
while (this.snake.cells.length > this.snake.maxCells) {
this.snake.cells.pop()
}
// draw bitcoin
this.context.fillStyle = '#ff4961'
this.context.drawImage(
this.image,
this.bitcoin.x - 1,
this.bitcoin.y - 1,
this.grid + 2,
this.grid + 2,
)
const canvas = this.canvasRef()?.nativeElement
if (!canvas) return
// draw snake one cell at a time
this.context.fillStyle = '#2fdf75'
// Wall collision
if (
newHead.x < 0 ||
newHead.y < 0 ||
newHead.x >= canvas.width ||
newHead.y >= canvas.height
) {
this.onDeath()
return
}
const firstCell = this.snake.cells[0]
for (let index = 0; index < this.snake.cells.length; index++) {
const cell = this.snake.cells[index]
// drawing 1 px smaller than the grid creates a grid effect in the snake body so you can see how long it is
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
// Self collision
for (let i = 1; i < this.snake.cells.length; i++) {
const cell = this.snake.cells[i]
if (cell && newHead.x === cell.x && newHead.y === cell.y) {
this.onDeath()
return
}
}
if (index > 0) {
// check collision with all cells after this one (modified bubble sort)
// snake occupies same space as a body part. reset game
if (
firstCell.x === this.snake.cells[index].x &&
firstCell.y === this.snake.cells[index].y
) {
this.death()
}
}
// Eat bitcoin
if (newHead.x === this.bitcoin.x && newHead.y === this.bitcoin.y) {
this.score++
this.highScore = Math.max(this.score, this.highScore)
this.snake.maxCells++
this.spawnBitcoin()
}
}
death() {
this.snake.x =
this.grid * (Math.floor(this.width / 2) - this.startingLength)
this.snake.y = this.grid * Math.floor(this.height / 2)
this.snake.cells = []
this.snake.maxCells = this.startingLength
this.snake.dx = this.grid
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
private onDeath() {
this.dead = true
this.state.set('dead')
// Brief delay before accepting restart input
setTimeout(() => {
this.dead = false
}, 300)
}
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
}
}

View File

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