Feature/snake (#1256)

* snake game

Co-authored-by: Drew Ansbacher <drew@start9labs.com>
This commit is contained in:
Drew Ansbacher
2022-02-24 16:20:52 -07:00
committed by GitHub
parent 63f0a3bca8
commit 8cb0186621
14 changed files with 487 additions and 2 deletions

View File

@@ -0,0 +1,95 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
version="1.1"
width="23px"
height="23px"
viewBox="0 0 1 1"
preserveAspectRatio="xMidYMid"
id="svg2"
inkscape:version="0.48.2 r9819"
sodipodi:docname="bitcoin-logo-noshadow.svg">
<metadata
id="metadata22">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title></dc:title>
</cc:Work>
</rdf:RDF>
</metadata>
<sodipodi:namedview
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1"
objecttolerance="10"
gridtolerance="10"
guidetolerance="10"
inkscape:pageopacity="0"
inkscape:pageshadow="2"
inkscape:window-width="1447"
inkscape:window-height="861"
id="namedview20"
showgrid="false"
inkscape:zoom="0.921875"
inkscape:cx="212.51437"
inkscape:cy="233.24617"
inkscape:window-x="0"
inkscape:window-y="0"
inkscape:window-maximized="0"
inkscape:current-layer="svg2" />
<!-- Android launcher icons: viewBox="-0.045 -0.045 1.09 1.09" -->
<defs
id="defs4">
<filter
id="_drop-shadow"
color-interpolation-filters="sRGB">
<feGaussianBlur
in="SourceAlpha"
result="blur-out"
stdDeviation="1"
id="feGaussianBlur7" />
<feBlend
in="SourceGraphic"
in2="blur-out"
mode="normal"
id="feBlend9" />
</filter>
<linearGradient
id="coin-gradient"
x1="0%"
y1="0%"
x2="0%"
y2="100%">
<stop
offset="0%"
style="stop-color:#f9aa4b"
id="stop12" />
<stop
offset="100%"
style="stop-color:#f7931a"
id="stop14" />
</linearGradient>
</defs>
<g
transform="scale(0.015625)"
id="g16">
<path
id="coin"
d="m 63.0359,39.741 c -4.274,17.143 -21.637,27.576 -38.782,23.301 -17.138,-4.274 -27.571,-21.638 -23.295,-38.78 4.272,-17.145 21.635,-27.579 38.775,-23.305 17.144,4.274 27.576,21.64 23.302,38.784 z"
style="fill:url(#coin-gradient)" />
<path
id="symbol"
d="m 46.1009,27.441 c 0.637,-4.258 -2.605,-6.547 -7.038,-8.074 l 1.438,-5.768 -3.511,-0.875 -1.4,5.616 c -0.923,-0.23 -1.871,-0.447 -2.813,-0.662 l 1.41,-5.653 -3.509,-0.875 -1.439,5.766 c -0.764,-0.174 -1.514,-0.346 -2.242,-0.527 l 0.004,-0.018 -4.842,-1.209 -0.934,3.75 c 0,0 2.605,0.597 2.55,0.634 1.422,0.355 1.679,1.296 1.636,2.042 l -1.638,6.571 c 0.098,0.025 0.225,0.061 0.365,0.117 -0.117,-0.029 -0.242,-0.061 -0.371,-0.092 l -2.296,9.205 c -0.174,0.432 -0.615,1.08 -1.609,0.834 0.035,0.051 -2.552,-0.637 -2.552,-0.637 l -1.743,4.019 4.569,1.139 c 0.85,0.213 1.683,0.436 2.503,0.646 l -1.453,5.834 3.507,0.875 1.439,-5.772 c 0.958,0.26 1.888,0.5 2.798,0.726 l -1.434,5.745 3.511,0.875 1.453,-5.823 c 5.987,1.133 10.489,0.676 12.384,-4.739 1.527,-4.36 -0.076,-6.875 -3.226,-8.515 2.294,-0.529 4.022,-2.038 4.483,-5.155 z m -8.022,11.249 c -1.085,4.36 -8.426,2.003 -10.806,1.412 l 1.928,-7.729 c 2.38,0.594 10.012,1.77 8.878,6.317 z m 1.086,-11.312 c -0.99,3.966 -7.1,1.951 -9.082,1.457 l 1.748,-7.01 c 1.982,0.494 8.365,1.416 7.334,5.553 z"
style="fill:#ffffff" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

@@ -62,6 +62,17 @@
</ion-item>
</ion-menu-toggle>
</ion-item-group>
<img
(click)="openSnek()"
style="
cursor: pointer;
position: absolute;
bottom: 90px;
right: 20px;
width: 25px;
"
src="assets/img/icons/snek.png"
/>
<div
style="
text-align: center;

View File

@@ -32,6 +32,7 @@ import { LocalStorageService } from './services/local-storage.service'
import { EOSService } from './services/eos.service'
import { MarketplaceService } from './pages/marketplace-routes/marketplace.service'
import { OSWelcomePage } from './modals/os-welcome/os-welcome.page'
import { SnakePage } from './modals/snake/snake.page'
@Component({
selector: 'app-root',
@@ -39,6 +40,14 @@ import { OSWelcomePage } from './modals/os-welcome/os-welcome.page'
styleUrls: ['app.component.scss'],
})
export class AppComponent {
code = {
s: false,
n: false,
e: false,
k: false,
unlocked: false,
}
@HostListener('document:keydown.enter', ['$event'])
@debounce()
handleKeyboardEvent() {
@@ -48,6 +57,29 @@ export class AppComponent {
if (elem) elem.click()
}
@HostListener('document:keypress', ['$event'])
async keyPress(e: KeyboardEvent) {
if (e.repeat || this.code.unlocked) return
if (this.code[e.key] === false) {
this.code[e.key] = true
}
if (
Object.entries(this.code)
.filter(([key, value]) => key.length === 1)
.map(([key, value]) => value)
.reduce((a, b) => a && b)
) {
await this.openSnek()
}
}
@HostListener('document:keyup', ['$event'])
keyUp(e: KeyboardEvent) {
if (this.code[e.key]) {
this.code[e.key] = false
}
}
ServerStatus = ServerStatus
showMenu = false
selectedIndex = 0
@@ -238,6 +270,42 @@ export class AppComponent {
}
}
async openSnek() {
this.code.unlocked = true
const modal = await this.modalCtrl.create({
component: SnakePage,
cssClass: 'snake-modal',
backdropDismiss: false,
})
modal.onDidDismiss().then(async ret => {
this.code.unlocked = false
if (
ret.data.highScore &&
(ret.data.highScore >
this.patch.data.ui.gaming?.snake?.['high-score'] ||
!this.patch.data.ui.gaming?.snake?.['high-score'])
) {
const loader = await this.loadingCtrl.create({
spinner: 'lines',
cssClass: 'loader',
message: 'Saving High Score...',
})
await loader.present()
try {
await this.embassyApi.setDbValue({
pointer: '/gaming',
value: { snake: { 'high-score': ret.data.highScore } },
})
} catch (e) {
this.errToast.present(e)
} finally {
this.loadingCtrl.dismiss()
}
}
})
modal.present()
}
// should wipe cache independant of actual BE logout
private async logout() {
this.embassyApi.logout({})

View File

@@ -0,0 +1,11 @@
import { NgModule } from '@angular/core'
import { CommonModule } from '@angular/common'
import { IonicModule } from '@ionic/angular'
import { SnakePage } from './snake.page'
@NgModule({
declarations: [SnakePage],
imports: [CommonModule, IonicModule],
exports: [SnakePage],
})
export class SnakePageModule {}

View File

@@ -0,0 +1,23 @@
<ion-header>
<ion-toolbar>
<ion-title>Play Snek!</ion-title>
<ion-title slot="end">Score: {{ score }}</ion-title>
</ion-toolbar>
</ion-header>
<ion-content class="ion-padding">
<div class="canvas-center" style="width: 100%">
<canvas id="game"> </canvas>
</div>
</ion-content>
<ion-footer>
<ion-toolbar>
<ion-title slot="start">High Score: {{ highScore }}</ion-title>
<ion-buttons slot="end" class="ion-padding-end">
<ion-button (click)="dismiss()" class="enter-click"
>Byeeeeeee!</ion-button
>
</ion-buttons>
</ion-toolbar>
</ion-footer>

View File

@@ -0,0 +1,6 @@
.canvas-center {
padding-top: 20px;
display: flex;
align-items: center;
justify-content: center;
}

View File

@@ -0,0 +1,260 @@
import { Component, HostListener } from '@angular/core'
import { ModalController } from '@ionic/angular'
import { pauseFor } from '@start9labs/shared'
import { PatchDbService } from 'src/app/services/patch-db/patch-db.service'
@Component({
selector: 'snake',
templateUrl: './snake.page.html',
styleUrls: ['./snake.page.scss'],
})
export class SnakePage {
speed = 40
width = 40
height = 26
grid
startingLength = 4
score = 0
highScore = 0
xDown: number
yDown: number
canvas: HTMLCanvasElement
image: HTMLImageElement
context
snake
bitcoin
moveQueue: String[] = []
constructor(
private readonly modalCtrl: ModalController,
private readonly patch: PatchDbService,
) {}
ngOnInit() {
if (this.patch.data.ui.gaming?.snake?.['high-score']) {
this.highScore = this.patch.data.ui.gaming?.snake?.['high-score']
}
}
async dismiss() {
return this.modalCtrl.dismiss({ highScore: 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', ['$event'])
sizeChange(event) {
this.init()
}
ionViewDidEnter() {
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 = document.getElementById('game') as HTMLCanvasElement
this.canvas.style.border = '1px solid #e0e0e0'
this.context = this.canvas.getContext('2d')
const container = document.getElementsByClassName('canvas-center')[0]
this.grid = Math.floor(container.clientWidth / this.width)
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
this.canvas.height = this.grid * this.height
this.context.imageSmoothingEnabled = false
}
getTouches(evt: TouchEvent) {
return evt.touches
}
handleTouchStart(evt) {
const firstTouch = this.getTouches(evt)[0]
this.xDown = firstTouch.clientX
this.yDown = firstTouch.clientY
}
handleTouchMove(evt) {
if (!this.xDown || !this.yDown) {
return
}
var xUp = evt.touches[0].clientX
var yUp = evt.touches[0].clientY
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')
}
}
/* reset values */
this.xDown = null
this.yDown = null
}
// game loop
async loop() {
await pauseFor(this.speed)
requestAnimationFrame(async () => await this.loop())
this.context.clearRect(0, 0, this.canvas.width, this.canvas.height)
// move snake by it's velocity
this.snake.x += this.snake.dx
this.snake.y += this.snake.dy
if (this.moveQueue.length) {
const move = this.moveQueue.shift()
// left arrow key
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) {
this.snake.dy = -this.grid
this.snake.dx = 0
}
// right arrow key
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) {
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()
}
// 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 })
// remove cells as we move away from them
if (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,
)
// draw snake one cell at a time
this.context.fillStyle = '#2fdf75'
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++
if (this.score > this.highScore) this.highScore = this.score
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) {
// 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()
}
}
}
}
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
}
getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min)) + min
}
}

View File

@@ -14,7 +14,7 @@
<ion-icon slot="start" name="add" size="large" color="dark"></ion-icon>
<ion-label>
<ion-text color="dark">
<b>Add alternative marketplace</b>
<b>Add alt marketplace</b>
</ion-text>
</ion-label>
</ion-item>

View File

@@ -291,7 +291,7 @@ function getMarketplaceValueSpec(): ValueSpecObject {
url: {
type: 'string',
name: 'URL',
description: 'The fully-qualified URL of the alternative marketplace.',
description: 'The fully-qualified URL of the alt marketplace.',
nullable: false,
masked: false,
copyable: false,

View File

@@ -6,6 +6,7 @@ import { ServerShowPage } from './server-show.page'
import { FormsModule } from '@angular/forms'
import { TextSpinnerComponentModule } from '@start9labs/shared'
import { BadgeMenuComponentModule } from 'src/app/components/badge-menu-button/badge-menu.component.module'
import { SnakePageModule } from 'src/app/modals/snake/snake.module'
const routes: Routes = [
{
@@ -22,6 +23,7 @@ const routes: Routes = [
RouterModule.forChild(routes),
TextSpinnerComponentModule,
BadgeMenuComponentModule,
SnakePageModule,
],
declarations: [ServerShowPage],
})

View File

@@ -17,6 +17,7 @@ import { wizardModal } from 'src/app/components/install-wizard/install-wizard.co
import { exists, isEmptyObject } from '@start9labs/shared'
import { EOSService } from 'src/app/services/eos.service'
import { ServerStatus } from 'src/app/services/patch-db/data-model'
import { SnakePage } from 'src/app/modals/snake/snake.page'
@Component({
selector: 'server-show',

View File

@@ -16,6 +16,7 @@ export const mockPatchData: DataModel = {
'ack-welcome': '1.0.0',
marketplace: undefined,
dev: undefined,
gaming: undefined,
},
'server-info': {
id: 'embassy-abcdefgh',

View File

@@ -16,6 +16,13 @@ export interface UIData {
'ack-welcome': string // EOS version
marketplace: UIMarketplaceData
dev: DevData
gaming:
| {
snake: {
'high-score': number
}
}
| undefined
}
export interface UIMarketplaceData {