adds first steps for the arcade game stroke fighter

This commit is contained in:
Lars Berning 2022-02-13 21:33:09 +01:00
parent 697d2cd53c
commit 700e569a42
No known key found for this signature in database
GPG Key ID: 028E73C9E1D8A0B3
31 changed files with 367 additions and 17 deletions

View File

@ -0,0 +1,12 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Kaboom based Game Engine for Rowing Games
*/
import kaboom from 'kaboom'
export function createGameEngine (options) {
return kaboom(options)
}

View File

@ -0,0 +1,44 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Initializer for the Rowing Games
*/
import { createGameEngine } from './GameEngine.js'
import StrokeFighterBattleScene from './StrokeFighterBattleScene.js'
/**
* creates and initializes the rowing games
* @param {Element} canvasElement
* @param {number} clientWidth
* @param {number} clientHeight
*/
export function createRowingGames (canvasElement, clientWidth, clientHeight) {
const k = createGameEngine({
debug: true,
global: false,
canvas: canvasElement,
crisp: true,
width: clientWidth,
height: clientHeight
})
// for now show debug infos all the time
k.debug.inspect = true
// todo: once there are multiple games, asset loadingshould be moved to the individual games
const assets = '/assets'
const sprites = ['enemyBlack1', 'enemyBlue2', 'enemyGreen3', 'enemyRed4', 'enemyRed5', 'playerShip2_orange',
'spaceShips_004', 'spaceShips_006', 'spaceShips_007', 'spaceShips_009', 'star1', 'star2', 'ufoGreen',
'laserRed01', 'laserRed09']
for (const sprite of sprites) {
k.loadSprite(sprite, `${assets}/sprites/${sprite}.png`)
}
k.loadSound('hit', `${assets}/sounds/explosionCrunch_000.ogg`)
k.loadSound('shoot', `${assets}/sounds/laserSmall_001.ogg`)
k.scene('strokeFighterBattle', () => { StrokeFighterBattleScene(k) })
k.go('strokeFighterBattle')
}

View File

@ -0,0 +1,51 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Creates a scrolling space background
*/
const STAR_SPEED = 20
const STAR_SPRITE_NAMES = ['star1', 'star2']
const STAR_NUM = 10
export default function addSpaceBackground (k) {
k.add([
k.rect(k.width() + 50, k.height() + 50),
k.pos(-25, -25),
k.color(0, 0, 0),
k.layer('background')
])
for (let i = 0; i < STAR_NUM; i++) {
addStar(false)
}
/**
* adds a star at a random position
* @param {boolean} respawn defines whether this is an initial star or a respawn
*/
function addStar (respawn) {
const spriteName = k.choose(STAR_SPRITE_NAMES)
const position = k.rand(k.vec2(0, respawn ? -50 : 0), k.vec2(k.width(), respawn ? 0 : k.height()))
const starColor = k.rand(120, 200)
k.add([
k.sprite(spriteName),
k.scale(k.rand(0.2, 0.7)),
k.color(starColor, starColor, starColor),
k.layer('background'),
k.pos(position),
'star',
{ speed: k.rand(STAR_SPEED * 0.5, STAR_SPEED * 1.5) }
])
}
k.onUpdate('star', (component) => {
component.move(0, component.speed)
if (component.pos.y - component.height > k.height()) {
k.destroy(component)
addStar(true)
}
})
}

View File

@ -0,0 +1,174 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Implements the Battle Actions of the Stroke Fighter Game
*/
import addSpaceBackground from './SpaceBackground.js'
const ENEMY_SPRITE_NAMES = ['enemyBlack1', 'enemyBlue2', 'enemyGreen3', 'enemyRed4', 'enemyRed5',
'spaceShips_004', 'spaceShips_006', 'spaceShips_007', 'spaceShips_009', 'ufoGreen']
export default function StrokeFighterBattleScene (k) {
const BULLET_SPEED = 1200
const ENEMY_SPEED = 60
const PLAYER_SPEED = 480
const ENEMY_HEALTH = 4
const SPRITE_WIDTH = 90
k.layers([
'background',
'game',
'ui'
], 'game')
addSpaceBackground(k)
function grow (rate) {
return {
update () {
const n = rate * k.dt()
this.scale.x += n
this.scale.y += n
}
}
}
const player = k.add([
k.sprite('playerShip2_orange'),
k.area(),
k.pos(k.width() / 2, k.height() - 64),
k.origin('center')
])
function moveLeft () {
player.move(-PLAYER_SPEED, 0)
if (player.pos.x < 0) {
player.pos.x = k.width()
}
}
function moveRight () {
player.move(PLAYER_SPEED, 0)
if (player.pos.x > k.width()) {
player.pos.x = 0
}
}
player.onCollide('enemy', (e) => {
k.destroy(e)
k.shake(4)
k.play('hit', {
detune: -1200,
volume: 0.3,
speed: k.rand(0.5, 2)
})
})
player.onUpdate(() => {
const tolerance = 10
const closestEnemy = k.get('enemy').reduce((prev, enemy) => { return enemy?.pos.y > prev?.pos.y ? enemy : prev }, { pos: { y: 0 } })
if (closestEnemy?.pos?.x) {
if (closestEnemy.pos.x > player.pos.x + tolerance) {
moveRight()
} else if (closestEnemy.pos.x < player.pos.x - tolerance) {
moveLeft()
}
}
})
function addLaserHit (p, n) {
for (let i = 0; i < n; i++) {
k.wait(k.rand(n * 0.1), () => {
for (let i = 0; i < 2; i++) {
k.add([
k.sprite('laserRed09'),
k.pos(p.sub(0, 10)),
k.scale(k.vec2(0.5)),
k.lifespan(0.1),
grow(k.rand(0.5, 2)),
k.origin('center')
])
}
})
}
}
function spawnBullet (p) {
k.add([
k.sprite('laserRed01'),
k.area(),
k.pos(p),
k.origin('center'),
k.move(k.UP, BULLET_SPEED),
k.cleanup(),
'bullet'
])
}
// todo: this should be triggered by a finished rowing drive phase
k.onKeyPress('space', () => {
spawnBullet(player.pos.sub(16, 15))
spawnBullet(player.pos.add(16, -15))
k.play('shoot', {
volume: 0.3,
detune: k.rand(-1200, 1200)
})
})
function spawnEnemy () {
const name = k.choose(ENEMY_SPRITE_NAMES)
k.add([
k.sprite(name),
k.area(),
k.pos(k.rand(0 + SPRITE_WIDTH / 2, k.width() - SPRITE_WIDTH / 2), 0),
k.health(ENEMY_HEALTH),
k.origin('bot'),
'enemy',
{ speed: k.rand(ENEMY_SPEED * 0.5, ENEMY_SPEED * 1.5) }
])
k.wait(3, spawnEnemy)
}
k.on('death', 'enemy', (e) => {
k.destroy(e)
k.shake(2)
})
k.on('hurt', 'enemy', (e) => {
k.shake(1)
k.play('hit', {
detune: k.rand(-1200, 1200),
volume: 0.1,
speed: k.rand(0.2, 2)
})
})
const timer = k.add([
k.text(0),
k.pos(12, 32),
k.fixed(),
k.layer('ui'),
{ time: 0 }
])
timer.onUpdate(() => {
timer.time += k.dt()
timer.text = timer.time.toFixed(2)
})
k.onCollide('bullet', 'enemy', (b, e) => {
k.destroy(b)
e.hurt(1)
addLaserHit(b.pos, 1)
})
k.onUpdate('enemy', (sprite) => {
sprite.move(0, sprite.speed)
if (sprite.pos.y - sprite.height > k.height()) {
k.destroy(sprite)
}
})
spawnEnemy()
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 735 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 752 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 312 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 KiB

View File

@ -25,10 +25,10 @@ export class DashboardMetric extends AppElement {
render () {
// 416 is the max width value of the battery bar in the SVG graphic
const batteryWidth = this.batteryLevel * 416 / 100
const batteryWidth = parseInt(this.batteryLevel) * 416 / 100
// if battery level is low, highlight the battery icon
const iconClass = this.batteryLevel > 25 ? 'icon' : 'icon low-battery'
const iconClass = parseInt(this.batteryLevel) > 25 ? 'icon' : 'icon low-battery'
return svg`
<svg aria-hidden="true" focusable="false" class="${iconClass}" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512">

View File

@ -7,7 +7,7 @@
import { AppElement, html, css } from './AppElement.js'
import { customElement, state } from 'lit/decorators.js'
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload } from '../lib/icons.js'
import { icon_undo, icon_expand, icon_compress, icon_poweroff, icon_bluetooth, icon_upload, icon_gamepad } from '../lib/icons.js'
import './AppDialog.js'
@customElement('dashboard-actions')
@ -65,6 +65,7 @@ export class DashboardActions extends AppElement {
return html`
<button @click=${this.reset}>${icon_undo}</button>
${this.renderOptionalButtons()}
<button @click=${this.openRowingGames}>${icon_gamepad}</button>
<button @click=${this.switchPeripheralMode}>${icon_bluetooth}</button>
<div class="peripheral-mode">${this.peripheralMode()}</div>
${this.dialog ? this.dialog : ''}
@ -129,6 +130,10 @@ export class DashboardActions extends AppElement {
this.sendEvent('triggerAction', { command: 'reset' })
}
openRowingGames () {
this.sendEvent('triggerAction', { command: 'openRowingGames' })
}
switchPeripheralMode () {
this.sendEvent('triggerAction', { command: 'switchPeripheralMode' })
}

View File

@ -0,0 +1,33 @@
'use strict'
/*
Open Rowing Monitor, https://github.com/laberning/openrowingmonitor
Wrapper for the Open Rowing Monitor rowing games
*/
import { AppElement, html, css } from './AppElement.js'
import { customElement } from 'lit/decorators.js'
import { createRowingGames } from '../arcade/RowingGames.js'
@customElement('game-component')
export class GameComponent extends AppElement {
static styles = css`
:host {
width: 100%;
height: 100%;
display: block;
}
#arcade {
width: 100%;
height: 100%;
}
`
render () {
return html`<canvas id="arcade"></canvas>`
}
firstUpdated () {
const canvas = this.renderRoot.querySelector('#arcade')
createRowingGames(canvas, canvas.clientWidth, canvas.clientHeight)
}
}

View File

@ -24,6 +24,7 @@
--theme-font-color: #f5f5f5;
--theme-warning-color: #ff0000;
--theme-border-radius: 3px;
height: 100%;
}
body {
@ -34,6 +35,7 @@
font-family: var(--theme-font-family);
user-select: none;
overscroll-behavior: contain;
height: 100%;
}
@media (orientation: portrait) {

View File

@ -10,6 +10,7 @@ import { customElement, state } from 'lit/decorators.js'
import { APP_STATE } from './store/appState.js'
import { createApp } from './lib/app.js'
import './components/PerformanceDashboard.js'
import './components/GameComponent.js'
@customElement('web-app')
export class App extends LitElement {
@ -55,9 +56,12 @@ export class App extends LitElement {
return JSON.parse(JSON.stringify(this.appState))
}
// once we have multiple views, then we would rather reference some kind of router here
// instead of embedding the performance-dashboard directly
// currently just toggle between games and dashboard, if this gets more complex we might use a router here...
render () {
return this.appState?.activeRoute === 'DASHBOARD' ? this.renderDashboard() : this.renderRowingGames()
}
renderDashboard () {
return html`
<performance-dashboard
.appState=${this.appState}
@ -66,6 +70,12 @@ export class App extends LitElement {
`
}
renderRowingGames () {
return html`
<game-component></game-component>
`
}
// there is no need to put this initialization component into a shadow root
createRenderRoot () {
return this

View File

@ -128,6 +128,10 @@ export function createApp (app) {
if (socket)socket.send(JSON.stringify({ command: 'switchPeripheralMode' }))
break
}
case 'openRowingGames': {
app.updateState({ ...app.getState(), activeRoute: 'ROWINGGAMES' })
break
}
case 'reset': {
resetFields()
if (socket)socket.send(JSON.stringify({ command: 'reset' }))

View File

@ -25,3 +25,4 @@ export const icon_expand = svg`<svg aria-hidden="true" focusable="false" class="
export const icon_compress = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M436 192H312c-13.3 0-24-10.7-24-24V44c0-6.6 5.4-12 12-12h40c6.6 0 12 5.4 12 12v84h84c6.6 0 12 5.4 12 12v40c0 6.6-5.4 12-12 12zm-276-24V44c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12v84H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h124c13.3 0 24-10.7 24-24zm0 300V344c0-13.3-10.7-24-24-24H12c-6.6 0-12 5.4-12 12v40c0 6.6 5.4 12 12 12h84v84c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12zm192 0v-84h84c6.6 0 12-5.4 12-12v-40c0-6.6-5.4-12-12-12H312c-13.3 0-24 10.7-24 24v124c0 6.6 5.4 12 12 12h40c6.6 0 12-5.4 12-12z"></path></svg>`
export const icon_bluetooth = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 448 512"><path fill="currentColor" d="M292.6 171.1L249.7 214l-.3-86 43.2 43.1m-43.2 219.8l43.1-43.1-42.9-42.9-.2 86zM416 259.4C416 465 344.1 512 230.9 512S32 465 32 259.4 115.4 0 228.6 0 416 53.9 416 259.4zm-158.5 0l79.4-88.6L211.8 36.5v176.9L138 139.6l-27 26.9 92.7 93-92.7 93 26.9 26.9 73.8-73.8 2.3 170 127.4-127.5-83.9-88.7z"></path></svg>`
export const icon_upload = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M537.6 226.6c4.1-10.7 6.4-22.4 6.4-34.6 0-53-43-96-96-96-19.7 0-38.1 6-53.3 16.2C367 64.2 315.3 32 256 32c-88.4 0-160 71.6-160 160 0 2.7.1 5.4.2 8.1C40.2 219.8 0 273.2 0 336c0 79.5 64.5 144 144 144h368c70.7 0 128-57.3 128-128 0-61.9-44-113.6-102.4-125.4zM393.4 288H328v112c0 8.8-7.2 16-16 16h-48c-8.8 0-16-7.2-16-16V288h-65.4c-14.3 0-21.4-17.2-11.3-27.3l105.4-105.4c6.2-6.2 16.4-6.2 22.6 0l105.4 105.4c10.1 10.1 2.9 27.3-11.3 27.3z"></path></svg>`
export const icon_gamepad = svg`<svg aria-hidden="true" focusable="false" class="icon" role="img" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 640 512"><path fill="currentColor" d="M448 64H192C85.96 64 0 149.1 0 256s85.96 192 192 192h256c106 0 192-85.96 192-192S554 64 448 64zM247.1 280h-32v32c0 13.2-10.78 24-23.98 24c-13.2 0-24.02-10.8-24.02-24v-32L136 279.1C122.8 279.1 111.1 269.2 111.1 256c0-13.2 10.85-24.01 24.05-24.01L167.1 232v-32c0-13.2 10.82-24 24.02-24c13.2 0 23.98 10.8 23.98 24v32h32c13.2 0 24.02 10.8 24.02 24C271.1 269.2 261.2 280 247.1 280zM431.1 344c-22.12 0-39.1-17.87-39.1-39.1s17.87-40 39.1-40s39.1 17.88 39.1 40S454.1 344 431.1 344zM495.1 248c-22.12 0-39.1-17.87-39.1-39.1s17.87-40 39.1-40c22.12 0 39.1 17.88 39.1 40S518.1 248 495.1 248z"/></svg>`

View File

@ -8,6 +8,8 @@
export const APP_STATE = {
// currently can be STANDALONE (Mobile Home Screen App), KIOSK (Raspberry Pi deployment) or '' (default)
appMode: '',
// currently can be DASHBOARD or 'ROWINGGAMES' (default)
activeRoute: 'DASHBOARD',
// contains all the rowing metrics that are delivered from the backend
metrics: {},
config: {

35
package-lock.json generated
View File

@ -14,6 +14,7 @@
"ant-plus": "0.1.24",
"finalhandler": "1.1.2",
"form-data": "4.0.0",
"kaboom": "2000.2.7",
"lit": "2.1.3",
"loglevel": "1.8.0",
"nosleep.js": "0.12.0",
@ -3372,9 +3373,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001311",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001311.tgz",
"integrity": "sha512-mleTFtFKfykEeW34EyfhGIFjGCqzhh38Y0LhdQ9aWF+HorZTtdgKV/1hEE0NlFkG2ubvisPV6l400tlbPys98A==",
"version": "1.0.30001312",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz",
"integrity": "sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ==",
"dev": true,
"funding": {
"type": "opencollective",
@ -6640,6 +6641,11 @@
"integrity": "sha512-TCa7ZdxCeq6q3Rgms2JCRHTCfWAETPZ8SzYUbkYF6KR3I03sN29DaOIC+xyWboIcMvjAsD5iG2u/RWzHD8XpgQ==",
"dev": true
},
"node_modules/kaboom": {
"version": "2000.2.7",
"resolved": "https://registry.npmjs.org/kaboom/-/kaboom-2000.2.7.tgz",
"integrity": "sha512-CiEsmVdmufedNNvppMrVwVGYmao+WRsjE7M/TU4lFGYwUNwBPBVxORmsKvUmuvchw0cR+Dwcj1vAL+K8P06qGA=="
},
"node_modules/keyv": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz",
@ -7051,9 +7057,9 @@
}
},
"node_modules/minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz",
"integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.1.tgz",
"integrity": "sha512-reLxBcKUPNBnc/sVtAbxgRVFSegoGeLaSjmphNhcwcolhYLRgtJscn5mRl6YRZNQv40Y7P6JM2YhSIsbL9OB5A==",
"devOptional": true,
"dependencies": {
"brace-expansion": "^1.1.7"
@ -13733,9 +13739,9 @@
"dev": true
},
"caniuse-lite": {
"version": "1.0.30001311",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001311.tgz",
"integrity": "sha512-mleTFtFKfykEeW34EyfhGIFjGCqzhh38Y0LhdQ9aWF+HorZTtdgKV/1hEE0NlFkG2ubvisPV6l400tlbPys98A==",
"version": "1.0.30001312",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001312.tgz",
"integrity": "sha512-Wiz1Psk2MEK0pX3rUzWaunLTZzqS2JYZFzNKqAiJGiuxIjRPLgV6+VDPOg6lQOUxmDwhTlh198JsTTi8Hzw6aQ==",
"dev": true
},
"caseless": {
@ -16197,6 +16203,11 @@
"integrity": "sha512-TCa7ZdxCeq6q3Rgms2JCRHTCfWAETPZ8SzYUbkYF6KR3I03sN29DaOIC+xyWboIcMvjAsD5iG2u/RWzHD8XpgQ==",
"dev": true
},
"kaboom": {
"version": "2000.2.7",
"resolved": "https://registry.npmjs.org/kaboom/-/kaboom-2000.2.7.tgz",
"integrity": "sha512-CiEsmVdmufedNNvppMrVwVGYmao+WRsjE7M/TU4lFGYwUNwBPBVxORmsKvUmuvchw0cR+Dwcj1vAL+K8P06qGA=="
},
"keyv": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/keyv/-/keyv-4.1.1.tgz",
@ -16518,9 +16529,9 @@
"dev": true
},
"minimatch": {
"version": "3.0.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.0.5.tgz",
"integrity": "sha512-tUpxzX0VAzJHjLu0xUfFv1gwVp9ba3IOuRAVH2EGuRW8a5emA2FlACLqiT/lDVtS1W+TGNwqz3sWaNyLgDJWuw==",
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.1.tgz",
"integrity": "sha512-reLxBcKUPNBnc/sVtAbxgRVFSegoGeLaSjmphNhcwcolhYLRgtJscn5mRl6YRZNQv40Y7P6JM2YhSIsbL9OB5A==",
"devOptional": true,
"requires": {
"brace-expansion": "^1.1.7"

View File

@ -36,6 +36,7 @@
"ant-plus": "0.1.24",
"finalhandler": "1.1.2",
"form-data": "4.0.0",
"kaboom": "2000.2.7",
"lit": "2.1.3",
"loglevel": "1.8.0",
"nosleep.js": "0.12.0",