diff --git a/app/client/arcade/GameEngine.js b/app/client/arcade/GameEngine.js new file mode 100644 index 0000000..46ad990 --- /dev/null +++ b/app/client/arcade/GameEngine.js @@ -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) +} diff --git a/app/client/arcade/RowingGames.js b/app/client/arcade/RowingGames.js new file mode 100644 index 0000000..3dd44a0 --- /dev/null +++ b/app/client/arcade/RowingGames.js @@ -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') +} diff --git a/app/client/arcade/SpaceBackground.js b/app/client/arcade/SpaceBackground.js new file mode 100644 index 0000000..3bb8018 --- /dev/null +++ b/app/client/arcade/SpaceBackground.js @@ -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) + } + }) +} diff --git a/app/client/arcade/StrokeFighterBattleScene.js b/app/client/arcade/StrokeFighterBattleScene.js new file mode 100644 index 0000000..ee5f4b7 --- /dev/null +++ b/app/client/arcade/StrokeFighterBattleScene.js @@ -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() +} diff --git a/app/client/assets/sounds/explosionCrunch_000.ogg b/app/client/assets/sounds/explosionCrunch_000.ogg new file mode 100644 index 0000000..019e536 Binary files /dev/null and b/app/client/assets/sounds/explosionCrunch_000.ogg differ diff --git a/app/client/assets/sounds/laserSmall_001.ogg b/app/client/assets/sounds/laserSmall_001.ogg new file mode 100644 index 0000000..ffc89b9 Binary files /dev/null and b/app/client/assets/sounds/laserSmall_001.ogg differ diff --git a/app/client/assets/sprites/enemyBlack1.png b/app/client/assets/sprites/enemyBlack1.png new file mode 100644 index 0000000..bc2fa4c Binary files /dev/null and b/app/client/assets/sprites/enemyBlack1.png differ diff --git a/app/client/assets/sprites/enemyBlue2.png b/app/client/assets/sprites/enemyBlue2.png new file mode 100644 index 0000000..bf3bd0c Binary files /dev/null and b/app/client/assets/sprites/enemyBlue2.png differ diff --git a/app/client/assets/sprites/enemyGreen3.png b/app/client/assets/sprites/enemyGreen3.png new file mode 100644 index 0000000..74e2bca Binary files /dev/null and b/app/client/assets/sprites/enemyGreen3.png differ diff --git a/app/client/assets/sprites/enemyRed4.png b/app/client/assets/sprites/enemyRed4.png new file mode 100644 index 0000000..a3216d4 Binary files /dev/null and b/app/client/assets/sprites/enemyRed4.png differ diff --git a/app/client/assets/sprites/enemyRed5.png b/app/client/assets/sprites/enemyRed5.png new file mode 100644 index 0000000..645cdf3 Binary files /dev/null and b/app/client/assets/sprites/enemyRed5.png differ diff --git a/app/client/assets/sprites/laserRed01.png b/app/client/assets/sprites/laserRed01.png new file mode 100644 index 0000000..5e467b6 Binary files /dev/null and b/app/client/assets/sprites/laserRed01.png differ diff --git a/app/client/assets/sprites/laserRed09.png b/app/client/assets/sprites/laserRed09.png new file mode 100644 index 0000000..7dc31dc Binary files /dev/null and b/app/client/assets/sprites/laserRed09.png differ diff --git a/app/client/assets/sprites/playerShip2_orange.png b/app/client/assets/sprites/playerShip2_orange.png new file mode 100644 index 0000000..82ddc80 Binary files /dev/null and b/app/client/assets/sprites/playerShip2_orange.png differ diff --git a/app/client/assets/sprites/spaceShips_004.png b/app/client/assets/sprites/spaceShips_004.png new file mode 100644 index 0000000..9e9c73a Binary files /dev/null and b/app/client/assets/sprites/spaceShips_004.png differ diff --git a/app/client/assets/sprites/spaceShips_006.png b/app/client/assets/sprites/spaceShips_006.png new file mode 100644 index 0000000..2d62a81 Binary files /dev/null and b/app/client/assets/sprites/spaceShips_006.png differ diff --git a/app/client/assets/sprites/spaceShips_007.png b/app/client/assets/sprites/spaceShips_007.png new file mode 100644 index 0000000..51599ae Binary files /dev/null and b/app/client/assets/sprites/spaceShips_007.png differ diff --git a/app/client/assets/sprites/spaceShips_009.png b/app/client/assets/sprites/spaceShips_009.png new file mode 100644 index 0000000..3c425bd Binary files /dev/null and b/app/client/assets/sprites/spaceShips_009.png differ diff --git a/app/client/assets/sprites/star1.png b/app/client/assets/sprites/star1.png new file mode 100644 index 0000000..67551ae Binary files /dev/null and b/app/client/assets/sprites/star1.png differ diff --git a/app/client/assets/sprites/star2.png b/app/client/assets/sprites/star2.png new file mode 100644 index 0000000..a047ef6 Binary files /dev/null and b/app/client/assets/sprites/star2.png differ diff --git a/app/client/assets/sprites/ufoGreen.png b/app/client/assets/sprites/ufoGreen.png new file mode 100644 index 0000000..7d441b2 Binary files /dev/null and b/app/client/assets/sprites/ufoGreen.png differ diff --git a/app/client/components/BatteryIcon.js b/app/client/components/BatteryIcon.js index 2321a73..db8c34a 100644 --- a/app/client/components/BatteryIcon.js +++ b/app/client/components/BatteryIcon.js @@ -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`
${this.peripheralMode()}
${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' }) } diff --git a/app/client/components/GameComponent.js b/app/client/components/GameComponent.js new file mode 100644 index 0000000..177d89f --- /dev/null +++ b/app/client/components/GameComponent.js @@ -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`` + } + + firstUpdated () { + const canvas = this.renderRoot.querySelector('#arcade') + createRowingGames(canvas, canvas.clientWidth, canvas.clientHeight) + } +} diff --git a/app/client/index.html b/app/client/index.html index 22ef8e8..98f74a1 100644 --- a/app/client/index.html +++ b/app/client/index.html @@ -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) { diff --git a/app/client/index.js b/app/client/index.js index b26dfcd..153e18f 100644 --- a/app/client/index.js +++ b/app/client/index.js @@ -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` + ` + } + // there is no need to put this initialization component into a shadow root createRenderRoot () { return this diff --git a/app/client/lib/app.js b/app/client/lib/app.js index 90f5801..34e973f 100644 --- a/app/client/lib/app.js +++ b/app/client/lib/app.js @@ -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' })) diff --git a/app/client/lib/icons.js b/app/client/lib/icons.js index 23b9a75..463f10c 100644 --- a/app/client/lib/icons.js +++ b/app/client/lib/icons.js @@ -25,3 +25,4 @@ export const icon_expand = svg`` export const icon_bluetooth = svg`` export const icon_upload = svg`` +export const icon_gamepad = svg`` diff --git a/app/client/store/appState.js b/app/client/store/appState.js index 3a93f55..5541e6c 100644 --- a/app/client/store/appState.js +++ b/app/client/store/appState.js @@ -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: { diff --git a/package-lock.json b/package-lock.json index 33e4e08..90e7d3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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" diff --git a/package.json b/package.json index cd8c906..204d3d5 100644 --- a/package.json +++ b/package.json @@ -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",