mirror of
https://github.com/Start9Labs/start-os.git
synced 2026-03-30 12:11:56 +00:00
update snake and add about this server to system general
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user