create-okona-game 0.5.0 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,11 +1,18 @@
1
1
  # create-okona-game
2
2
 
3
3
  ```
4
- npm create okona-game my-party-game
4
+ npm create okona-game my-game
5
5
  ```
6
6
 
7
- Scaffolds a phone-controlled HTML5 game for [Okona](https://okonaonline.com): a lobby built to the [Okona game standards](https://okonaonline.com/developers/docs.html#standards) (a controller joins by pressing A, any joined player starts, the game freezes while the Okona menu is open, no join code: Okona shows it), six seats with names and colours pushed to each phone, the frame loop, and a `game.js` with `start` / `update` / `draw` hooks holding a small sample (a coin-grab race) to replace with your game.
7
+ Scaffolds a phone-controlled HTML5 game for [Okona](https://okonaonline.com), for one player or up to six, on [Phaser](https://phaser.io) 3.90 (bundled). It includes:
8
8
 
9
- Open `index.html` in a browser and the keyboard is P1 — no build step, no `package.json`. Ship it with the [`okona` CLI](https://www.npmjs.com/package/okona-cli) (`npx okona-cli`, Node 18.17+): push to your sandbox first to try it with real phones, then submit it to the Okona catalog; once it's approved, players join in the Okona app with a phone or a gamepad.
9
+ - A lobby built to the [Okona game standards](https://okonaonline.com/developers/docs.html#standards). A controller joins by pressing A, any joined player starts, and the game freezes while the Okona menu is open. There's no join code in the game: Okona shows it.
10
+ - Six seats, with names and colours pushed to each phone.
11
+ - The Phaser setup Okona recommends for a TV box ([Phaser on Okona](https://okonaonline.com/developers/docs.html#phaser)): a 960x540 canvas scaled to fit, and the effects pipelines off.
12
+ - A `game.js` holding a small slot-car race to replace with your game: hold A for gas, left and right to change lanes, lift before the corners. The best lap is kept with `Okona.save`.
10
13
 
11
- Zero dependencies. Docs: https://okonaonline.com/developers/docs.html#html5 · SDK reference: https://okonaonline.com/developers/docs.html#js-sdk
14
+ Open `index.html` in a browser and the keyboard is P1. There's no build step and no `package.json`. Ship it with the [`okona` CLI](https://www.npmjs.com/package/okona-cli) (`npx okona-cli`, Node 18.17+). Push to your sandbox first to try it with real phones, then submit it to the Okona catalog; once it's approved, players join in the Okona app with a phone or a gamepad.
15
+
16
+ Zero dependencies to install. Docs: https://okonaonline.com/developers/docs.html#html5 · SDK reference: https://okonaonline.com/developers/docs.html#js-sdk
17
+
18
+ Phaser is © Richard Davey, Phaser Studio Inc., MIT licensed (`PHASER-LICENSE.md` in the scaffolded folder).
package/index.js CHANGED
@@ -3,9 +3,10 @@
3
3
  //
4
4
  // npm create okona-game my-game (or: npx create-okona-game my-game)
5
5
  //
6
- // Copies the template (lobby + join code + seats in index.html, your game in
7
- // game.js, an icon, an okona.json catalog record, a README) into ./my-game
8
- // with the name filled in. Zero
6
+ // Copies the template (the lobby, seats and Phaser setup in index.html, your
7
+ // game in game.js: a slot-car race to replace; Phaser 3.90 bundled as
8
+ // phaser.min.js; an icon, an okona.json catalog record, a README) into
9
+ // ./my-game with the name filled in. Zero
9
10
  // dependencies, Node ≥ 18. `--force` writes into a non-empty directory.
10
11
  import { mkdir, readdir, readFile, writeFile, copyFile, stat } from "node:fs/promises";
11
12
  import { join, resolve, dirname, basename } from "node:path";
@@ -36,7 +37,7 @@ export async function scaffold(target, { name, force = false } = {}) {
36
37
  const src = join(TEMPLATE, f);
37
38
  if ((await stat(src)).isDirectory()) continue;
38
39
  const dest = join(dir, f === "gitignore" ? ".gitignore" : f);
39
- if (/\.(html|js|md|json)$/.test(f) || f === "gitignore") {
40
+ if ((/\.(html|js|md|json)$/.test(f) && !/\.min\.js$/.test(f)) || f === "gitignore") { // phaser.min.js is copied as is
40
41
  const text = (await readFile(src, "utf8")).split("{{NAME}}").join(title);
41
42
  await writeFile(dest, text);
42
43
  } else {
@@ -58,7 +59,7 @@ if (isMain) {
58
59
  scaffold(target, { name, force }).then((r) => {
59
60
  console.log(`\n Created ${r.title} in ${r.dir}\n`);
60
61
  console.log(" Next:");
61
- console.log(" open index.html in a browser — the keyboard is P1 (WASD + Space)");
62
+ console.log(" open index.html in a browser — the keyboard is P1 (arrows + Space; Shift = B)");
62
63
  console.log(" edit game.js — start / update / draw are yours");
63
64
  console.log(" edit okona.json — title, description, keywords (the icon is icon.png; add \"screenshots\": \"shots\" for a folder of them)");
64
65
  console.log(" npx okona-cli login && npx okona-cli create # reads okona.json, writes the new game id back into it");
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "create-okona-game",
3
- "version": "0.5.0",
4
- "description": "Scaffold a phone-controlled HTML5 game for Okona: lobby, join code, six seats, and a game.js to make yours.",
3
+ "version": "0.6.0",
4
+ "description": "Scaffold a phone-controlled HTML5 game for Okona on Phaser 3.90: lobby, six seats, the TV-safe Phaser setup, and a slot-car race in game.js to make yours.",
5
5
  "license": "MIT",
6
6
  "homepage": "https://okonaonline.com/developers/docs.html#html5",
7
7
  "bugs": {
8
8
  "email": "support@okonaonline.com"
9
9
  },
10
- "keywords": ["okona", "create", "scaffold", "game", "html5", "phone-controller", "multiplayer", "party-game"],
10
+ "keywords": ["okona", "create", "scaffold", "game", "html5", "phaser", "phone-controller", "multiplayer", "tv"],
11
11
  "type": "module",
12
12
  "bin": {
13
13
  "create-okona-game": "index.js"
@@ -0,0 +1,20 @@
1
+ The MIT License (MIT)
2
+
3
+ Copyright (c) 2024 Richard Davey, Phaser Studio Inc.
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of
6
+ this software and associated documentation files (the "Software"), to deal in
7
+ the Software without restriction, including without limitation the rights to
8
+ use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
9
+ the Software, and to permit persons to whom the Software is furnished to do so,
10
+ subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
17
+ FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
18
+ COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
19
+ IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
20
+ CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -1,15 +1,20 @@
1
1
  # {{NAME}}
2
2
 
3
- A multiplayer game for [Okona](https://okonaonline.com). Up to six people play with their phones or gamepads; each joins by pressing A.
3
+ A game for [Okona](https://okonaonline.com), for one player or up to six. Each person plays with a phone or a gamepad and joins by pressing A. Built on [Phaser](https://phaser.io) 3.90, the HTML5 engine Okona recommends.
4
4
 
5
- - `index.html` — the lobby, seats and frame loop, built to the [Okona game standards](https://okonaonline.com/developers/docs.html#standards): A joins, any joined player starts, the game freezes while the Okona menu is open, and it draws no join code: Okona shows it (rarely needs changes)
6
- - `game.js` — **your game**: `start`, `update`, `draw`, and the join/leave/park/resume hooks
7
- - `icon.png` — the icon shown in the Okona portal (replace it)
8
- - `okona.json` — the catalog record: title, description, genre, keywords, the icon, screenshots. `okona push` syncs it
5
+ - `index.html`: the lobby, seats and loop, built to the [Okona game standards](https://okonaonline.com/developers/docs.html#standards) (A joins, any joined player starts, the game freezes while the Okona menu is open, and it draws no join code: Okona shows it), plus the Phaser config Okona recommends for a TV box. It rarely needs changes.
6
+ - `game.js`: **your game**. The sample is a slot-car race (hold A for gas, left and right to change lanes, lift before the corners) to replace with your own: `create`, `start`, `update`, `results`, and the join/leave hooks.
7
+ - `phaser.min.js` + `PHASER-LICENSE.md`: Phaser 3.90.0 (MIT), bundled so no network can block it.
8
+ - `icon.png`: the icon shown in the Okona portal (replace it).
9
+ - `okona.json`: the catalog record: title, description, genre, keywords, the icon, screenshots. `okona push` syncs it.
9
10
 
10
11
  ## Play it locally
11
12
 
12
- Open `index.html` in a browser (or `npx serve .`). The keyboard is P1: WASD/arrows move, Space is A, Shift is B. Phones need the real thing, below.
13
+ Open `index.html` in a browser (or `npx serve .`). The keyboard is P1: arrows or WASD, Space is A, Shift is B. Phones need the real thing, below.
14
+
15
+ ## Keep it fast on a TV
16
+
17
+ Okona plays on TV boxes with 2 GB shared by the CPU and the GPU. The setup in `index.html` is the one in [Phaser on Okona](https://okonaonline.com/developers/docs.html#phaser): a 960x540 canvas scaled to fit, and Phaser's effects pipelines off (`disablePreFX` / `disablePostFX`, or Phaser reserves GPU memory you never use). Keep both. Paint textures once (`textures.addImage`), reuse sprites, and change text only when its words change. Then play your sandbox on a real TV box for ten minutes before you submit.
13
18
 
14
19
  ## Submit to Okona
15
20
 
@@ -22,6 +27,6 @@ npx okona-cli push . --sandbox # catalog + icon + build in one go; try it w
22
27
  npx okona-cli push . --publish # submit for review: Okona approves it into the catalog or sends it back
23
28
  ```
24
29
 
25
- Screenshots: put up to six images in a `shots/` folder and add `"screenshots": "shots"` to `okona.json` — the next push uploads them in name order (1.png, 2.png, …). `npx okona-cli update --title "…"` changes a field without a build upload; `npx okona-cli game <id>` shows what the portal has.
30
+ Screenshots: put up to six images in a `shots/` folder and add `"screenshots": "shots"` to `okona.json`: the next push uploads them in name order (1.png, 2.png, …). `npx okona-cli update --title "…"` changes a field without a build upload; `npx okona-cli game <id>` shows what the portal has.
26
31
 
27
- Docs: https://okonaonline.com/developers/docs.html#html5 · SDK reference: https://okonaonline.com/developers/docs.html#js-sdk · The contract in one line: `Okona.pads()`, `Okona.on('join'|'leave'|'park'|'resume'|'menu')`, `Okona.menuOpen`, `Okona.players.setIdentity()`, `Okona.rumble()`, `Okona.track()`.
32
+ Docs: https://okonaonline.com/developers/docs.html#html5 · Phaser on Okona: https://okonaonline.com/developers/docs.html#phaser · SDK reference: https://okonaonline.com/developers/docs.html#js-sdk · The contract in one line: `Okona.pads()` once per frame, `Okona.on('join'|'leave'|'park'|'resume'|'menu')`, `Okona.menuOpen`, `Okona.players.setIdentity()`, `Okona.save`, `Okona.rumble()`, `Okona.track()`.
package/template/game.js CHANGED
@@ -1,98 +1,212 @@
1
- // {{NAME}} — the game. index.html runs the lobby (A joins), the seats
2
- // and the frame loop; it calls the hooks below. Replace the sample (a
3
- // coin-grab race) with your game. Everything is plain canvas — bring any
4
- // library you like, but bundle it (a venue's network may block CDNs).
1
+ // {{NAME}}: the game. index.html runs the lobby (A joins), the seats and the
2
+ // loop, and calls the hooks below. The sample is a slot-car race: every car
3
+ // runs in a lane of an oval. Hold A for gas, B brakes, left and right change
4
+ // lanes (left = outward, right = inward: the race runs clockwise). Each lane
5
+ // has a safe corner speed: the inside lane is shorter but tighter. Go into a
6
+ // corner too fast and you spin. Five laps; new players join the next race.
7
+ // Replace it with your game.
5
8
  //
6
- // Coordinates: a 1920×1080 logical screen; index.html scales it to fit.
7
- // Pads: pad.axes.lx/ly (−1..1, +Y down), pad.buttons.a/b/x/y/lb/rb/lt/rt/
8
- // select/l3/r3/up/down/left/right, pad.justPressed('a') (start + home are
9
- // Okona's: they never reach the game), player.joined (pressed A: a player),
10
- // pad.triggers.lt/rt (0..1), pad.parked (controller away — seat held).
11
- // Saves: Okona.save.get/set keep data for the player's account, on any screen
12
- // (the sample keeps its best round); read it after Okona.ready.
9
+ // Drawing: Phaser 3.90 (WebGL) on a 960x540 canvas that Phaser scales to fit.
10
+ // Textures are painted ONCE on a plain canvas and added with
11
+ // textures.addImage (never addCanvas: it reads every pixel back); sprites
12
+ // are made once and reused; text changes only when its words change.
13
+ // https://okonaonline.com/developers/docs.html#phaser
14
+ // Pads: pad.axes.lx/ly (-1..1, +Y down), pad.buttons.a/b/x/y/lb/rb/lt/rt/
15
+ // select/l3/r3/up/down/left/right, pad.justPressed('a'), pad.triggers.lt/rt
16
+ // (0..1), pad.parked (controller away: seat held). Start and Home are
17
+ // Okona's and never reach the game.
18
+ // Saves: Okona.save.get/set keep data for the player's account on any screen
19
+ // (the sample keeps the best lap). https://okonaonline.com/developers/docs.html#saves
13
20
  var Game = {
14
21
  title: '{{NAME}}',
15
- subtitle: 'Grab the coins. Most coins after 45 seconds wins.',
16
- minPlayers: 1,
17
- // Emoji (or a path to an image in this folder, e.g. 'icons/p1.png') shown
18
- // on each player's phone.
19
- icons: ['🟡', '🔵', '🟢', '🩷', '🟣', '🟠'],
20
- ROUND_SECONDS: 45,
22
+ subtitle: 'Hold A for gas, left and right to change lanes. Lift before the corners!',
23
+ // Emoji (or a path to an image in this folder) shown on each player's phone.
24
+ icons: ['🏎️', '🏎️', '🏎️', '🏎️', '🏎️', '🏎️'],
25
+ LAPS: 5,
26
+ TOP_SPEED: 330, // px/s
27
+ GRIP: 360, // safe corner speed = sqrt(GRIP * lane radius)
28
+ CX: 480, CY: 290, L: 400, R: 160, // the oval: straight length and corner radius
29
+ LANES: [-26, 0, 26], // lane offsets from the centre line (negative = inside)
21
30
 
22
- // ---- lifecycle ------------------------------------------------------------
23
- onJoin: function (player, pad) { // the controller pressed A: now a player
24
- player.x = 300 + player.index * 260; player.y = 540; player.vx = 0; player.vy = 0;
31
+ // ---- set up once: textures, the track, a car sprite per seat --------------
32
+ create: function (scene, W, H) {
33
+ this.scene = scene;
34
+ this.P = 2 * this.L + 2 * Math.PI * this.R; // centre-line length of one lap
35
+ var self = this;
36
+ this.paint('track', W, H, function (g) { self.paintTrack(g, W, H); });
37
+ this.paint('car', 30, 16, function (g) { // white: each car is tinted with its player's colour
38
+ g.fillStyle = '#FFFFFF'; g.fillRect(2, 1, 26, 14);
39
+ g.fillStyle = '#20262E'; g.fillRect(17, 3, 6, 10); g.fillRect(0, 0, 4, 4); g.fillRect(0, 12, 4, 4); g.fillRect(24, 0, 5, 3); g.fillRect(24, 13, 5, 3);
40
+ });
41
+ this.layer = scene.add.container(0, 0);
42
+ this.layer.add(scene.add.image(0, 0, 'track').setOrigin(0, 0));
43
+ this.cars = [];
44
+ for (var i = 0; i < 6; i++) {
45
+ var sprite = scene.add.image(0, 0, 'car').setVisible(false);
46
+ this.layer.add(sprite);
47
+ this.cars.push({ sprite: sprite });
48
+ }
49
+ // The infield: the countdown, a line per racer, the best lap. Text objects are made once.
50
+ var font = { fontFamily: 'system-ui, sans-serif', fontSize: '18px', fontStyle: 'bold', color: '#FFFFFF' };
51
+ this.banner = scene.add.text(this.CX, this.CY - 88, '', { fontFamily: 'system-ui, sans-serif', fontSize: '34px', fontStyle: 'bold', color: '#FFFFFF' }).setOrigin(0.5);
52
+ this.info = scene.add.text(this.CX, this.CY + 84, '', { fontFamily: 'system-ui, sans-serif', fontSize: '16px', color: '#A0B4C8', align: 'center' }).setOrigin(0.5);
53
+ this.layer.add([this.banner, this.info]);
54
+ this.board = [];
55
+ for (var k = 0; k < 6; k++) { this.board.push(scene.add.text(this.CX, this.CY - 52 + k * 22, '', font).setOrigin(0.5)); this.layer.add(this.board[k]); }
56
+ },
57
+ // A texture painted once with Canvas 2D, then handed to the GPU.
58
+ paint: function (key, w, h, fn) {
59
+ var c = document.createElement('canvas');
60
+ c.width = w; c.height = h;
61
+ fn(c.getContext('2d'));
62
+ this.scene.textures.addImage(key, c);
63
+ },
64
+ ovalPath: function (g, d) { // the oval at lane offset d
65
+ var r = this.R + d, x0 = this.CX - this.L / 2, x1 = this.CX + this.L / 2;
66
+ g.beginPath();
67
+ g.moveTo(x0, this.CY - r); g.lineTo(x1, this.CY - r);
68
+ g.arc(x1, this.CY, r, -Math.PI / 2, Math.PI / 2);
69
+ g.lineTo(x0, this.CY + r);
70
+ g.arc(x0, this.CY, r, Math.PI / 2, Math.PI * 1.5);
71
+ g.closePath();
72
+ },
73
+ paintTrack: function (g, W, H) {
74
+ g.fillStyle = '#2E5E3A'; g.fillRect(0, 0, W, H);
75
+ g.lineJoin = 'round';
76
+ g.strokeStyle = '#D9D9D9'; g.lineWidth = 104; this.ovalPath(g, 0); g.stroke(); // kerbs
77
+ g.strokeStyle = '#3A3F47'; g.lineWidth = 94; this.ovalPath(g, 0); g.stroke(); // tarmac
78
+ g.strokeStyle = '#59606B'; g.lineWidth = 2; g.setLineDash([10, 12]);
79
+ this.ovalPath(g, -13); g.stroke(); this.ovalPath(g, 13); g.stroke(); // lane lines
80
+ g.setLineDash([]);
81
+ for (var k = 0; k < 10; k++) { // the start line
82
+ g.fillStyle = k % 2 ? '#FFFFFF' : '#111111';
83
+ g.fillRect(this.CX - 4, this.CY - this.R - 47 + k * 9.4, 8, 9.4);
84
+ }
25
85
  },
26
- onLeave: function (player, pad) {}, // gone for good (seat given away, or still away when the round ended)
27
- onPark: function (player, pad) {}, // controller went away; seat + score held until the round ends — the sample freezes them
28
- onResume: function (player, pad) {},
29
86
 
87
+ // ---- players ---------------------------------------------------------------
88
+ onJoin: function (player, pad, racing) { player.waiting = racing; }, // mid-race: joins the next race
89
+ onLeave: function (player, pad) { this.cars[player.index].sprite.setVisible(false); },
90
+
91
+ // ---- a race ------------------------------------------------------------------
30
92
  start: function (players, pads) {
31
- this.best = Okona.save.get('best', 0); // the best round on this account, kept by Okona
32
- this.coins = [];
33
- for (var i = 0; i < 10; i++) this.spawnCoin();
34
- players.forEach(function (p) { p.x = 300 + p.index * 260; p.y = 540; p.vx = 0; p.vy = 0; });
93
+ this.best = Okona.save.get('bestLap', 0); // the best lap on this account, kept by Okona
94
+ this.finished = 0; this.winnerAt = 0;
95
+ var grid = 0;
96
+ for (var i = 0; i < 6; i++) {
97
+ var car = this.cars[i], p = players[i];
98
+ p.waiting = false;
99
+ car.racing = p.joined;
100
+ car.sprite.setVisible(car.racing).setTint(p.tint).setAlpha(1);
101
+ if (!car.racing) continue;
102
+ car.lane = grid % 3; car.d = this.LANES[car.lane];
103
+ car.s = this.P - 40 - Math.floor(grid / 3) * 44; // the grid, behind the line: rows of three
104
+ car.lap = 0; car.v = 0; car.spin = 0; car.turn = 0; car.held = 0;
105
+ car.lapStart = 2; car.bestLap = 0; car.finishTime = 0;
106
+ grid++;
107
+ this.place(car);
108
+ }
109
+ for (var b = 0; b < 6; b++) this.board[b].setText('');
35
110
  },
36
-
37
- spawnCoin: function () {
38
- this.coins.push({ x: 120 + Math.random() * 1680, y: 120 + Math.random() * 840, r: 22 });
111
+ // Where a car is: the centre line runs clockwise from the start line (top middle).
112
+ place: function (car) {
113
+ var s = ((car.s % this.P) + this.P) % this.P, L = this.L, R = this.R, r = R + car.d, x, y, a;
114
+ var half = L / 2, arc = Math.PI * R;
115
+ if (s < half) { x = this.CX + s; y = this.CY - r; a = 0; }
116
+ else if (s < half + arc) { a = (s - half) / R; x = this.CX + half + r * Math.sin(a); y = this.CY - r * Math.cos(a); }
117
+ else if (s < half + arc + L) { x = this.CX + half - (s - half - arc); y = this.CY + r; a = Math.PI; }
118
+ else if (s < half + 2 * arc + L) { a = (s - half - arc - L) / R; x = this.CX - half - r * Math.sin(a); y = this.CY + r * Math.cos(a); a += Math.PI; }
119
+ else { x = this.CX - half + (s - half - 2 * arc - L); y = this.CY - r; a = 0; }
120
+ car.inCorner = (s >= half && s < half + arc) || (s >= half + arc + L && s < half + 2 * arc + L);
121
+ car.sprite.setPosition(x, y).setRotation(a + car.turn);
39
122
  },
40
123
 
41
- // Return true when the round is over.
124
+ // Return true when the race is over.
42
125
  update: function (dt, players, pads, elapsed) {
126
+ var GO = 2, racers = 0, home = 0, time = elapsed - GO;
43
127
  for (var i = 0; i < 6; i++) {
44
- var pad = pads[i], p = players[i];
45
- if (!p.joined || !pad.connected || pad.parked) continue; // only players who are here
46
- var speed = pad.buttons.a ? 900 : 600;
47
- p.vx += pad.axes.lx * speed * dt * 6; p.vy += pad.axes.ly * speed * dt * 6;
48
- p.vx *= 0.88; p.vy *= 0.88;
49
- p.x = Math.max(60, Math.min(1860, p.x + p.vx * dt));
50
- p.y = Math.max(60, Math.min(1020, p.y + p.vy * dt));
51
- for (var c = this.coins.length - 1; c >= 0; c--) {
52
- var coin = this.coins[c];
53
- if (Math.hypot(coin.x - p.x, coin.y - p.y) < 50) {
54
- this.coins.splice(c, 1);
55
- p.score++;
56
- Okona.rumble(pad, 0.3, 0.6);
57
- this.spawnCoin();
58
- }
128
+ var car = this.cars[i], pad = pads[i], p = players[i];
129
+ if (!car.racing || !p.joined) continue;
130
+ var here = pad.connected && !pad.parked;
131
+ car.sprite.setAlpha(here ? 1 : 0.35); // away: a ghost that waits; it never holds up the race
132
+ if (here) racers++;
133
+ if (car.finishTime) { home += here ? 1 : 0; continue; }
134
+ if (!here || time < 0) continue;
135
+ // lanes: hold left/right to keep moving over, one lane every 0.25 s
136
+ var dir = pad.buttons.right || pad.axes.lx > 0.5 ? -1 : pad.buttons.left || pad.axes.lx < -0.5 ? 1 : 0;
137
+ car.held = dir ? car.held - dt : 0;
138
+ if (dir && car.held <= 0 && car.spin <= 0) { car.lane = Math.max(0, Math.min(2, car.lane + dir)); car.held = 0.25; }
139
+ var want = this.LANES[car.lane];
140
+ car.d += Math.max(-dt * 140, Math.min(dt * 140, want - car.d));
141
+ // speed: gas, brake, or the motor drags it down
142
+ var gas = Math.max(pad.buttons.a ? 1 : 0, pad.triggers.rt);
143
+ if (car.spin > 0) { car.spin -= dt; car.turn += dt * 14; car.v *= Math.pow(0.2, dt); if (car.spin <= 0) car.turn = 0; }
144
+ else if (pad.buttons.b) car.v = Math.max(0, car.v - 600 * dt);
145
+ else if (gas) car.v = Math.min(this.TOP_SPEED, car.v + 260 * gas * dt);
146
+ else car.v = Math.max(0, car.v - 220 * dt);
147
+ // too fast for this lane's corner: spin
148
+ if (car.inCorner && car.spin <= 0 && car.v > Math.sqrt(this.GRIP * (this.R + car.d)) * 1.08) {
149
+ car.spin = 0.9; car.v *= 0.4;
150
+ Okona.rumble(pad, 0.8, 0.4);
59
151
  }
152
+ // move along the centre line: an inside lane covers ground faster in the corners
153
+ var before = car.s;
154
+ car.s += car.v * dt * (car.inCorner ? this.R / (this.R + car.d) : 1);
155
+ if (Math.floor(car.s / this.P) > Math.floor(before / this.P)) this.lapDone(car, p, time);
156
+ this.place(car);
60
157
  }
61
- var over = elapsed >= this.ROUND_SECONDS;
62
- if (over) this.keepBest(players);
63
- return over;
158
+ this.drawBoard(players, pads);
159
+ this.banner.setText(time < 0 ? String(Math.ceil(-time)) : time < 1 ? 'GO!' : '');
160
+ this.info.setText(time < 0 ? this.LAPS + ' laps' : 'Best lap ' + (this.best ? this.best.toFixed(2) + ' s' : '-') + this.waitingLine(players));
161
+ // over when every racer who is here is home, or 15 s after the winner
162
+ return (racers > 0 && home === racers) || (this.winnerAt && time - this.winnerAt > 15) || racers === 0 && time > 0;
64
163
  },
65
-
66
- // A new best round goes in the save (it plays on fine if the save is unavailable).
67
- keepBest: function (players) {
68
- var top = 0;
69
- players.forEach(function (p) { if (p.joined && p.score > top) top = p.score; });
70
- if (top > this.best) { this.best = top; Okona.save.set('best', top); Okona.save.flush(); }
71
- },
72
-
73
- draw: function (ctx, players, pads, W, H) {
74
- ctx.strokeStyle = '#2A4A6F'; ctx.lineWidth = 6; ctx.strokeRect(30, 30, W - 60, H - 60);
75
- // coins
76
- for (var c = 0; c < this.coins.length; c++) {
77
- var coin = this.coins[c];
78
- ctx.fillStyle = '#F5C04A';
79
- ctx.beginPath(); ctx.arc(coin.x, coin.y, coin.r, 0, Math.PI * 2); ctx.fill();
80
- }
81
- // players
164
+ // One line per racer, in seat order; setText only redraws a line whose words changed.
165
+ drawBoard: function (players, pads) {
166
+ var row = 0;
82
167
  for (var i = 0; i < 6; i++) {
83
- var pad = pads[i], p = players[i];
84
- if (!p.joined) continue;
85
- ctx.globalAlpha = pad.parked ? 0.3 : 1;
86
- ctx.fillStyle = p.color;
87
- ctx.beginPath(); ctx.arc(p.x, p.y, 40, 0, Math.PI * 2); ctx.fill();
88
- ctx.globalAlpha = 1;
89
- ctx.fillStyle = '#FFFFFF'; ctx.textAlign = 'center'; ctx.font = 'bold 26px system-ui, sans-serif';
90
- ctx.fillText(p.name + ' ' + p.score + (pad.parked ? ' (away)' : ''), p.x, p.y - 56);
168
+ var car = this.cars[i], p = players[i];
169
+ if (!car.racing || !p.joined) continue;
170
+ var here = pads[i].connected && !pads[i].parked;
171
+ this.board[row].setText(p.name + ' ' + (car.finishTime ? 'home ' + car.finishTime.toFixed(2) + ' s'
172
+ : !here ? 'away' : 'lap ' + Math.max(1, Math.min(car.lap, this.LAPS)) + ' / ' + this.LAPS));
173
+ if (this.board[row].style.color !== p.color) this.board[row].setColor(p.color); // setColor redraws: only on a change
174
+ row++;
175
+ }
176
+ },
177
+ waitingLine: function (players) {
178
+ var names = players.filter(function (p) { return p.joined && p.waiting; }).map(function (p) { return p.name; });
179
+ return names.length ? '\n' + names.join(', ') + ' joining the next race' : '';
180
+ },
181
+ lapDone: function (car, player, time) {
182
+ car.lap++;
183
+ if (car.lap === 1) { car.lapStart = time; return; } // crossing the line from the grid starts lap 1
184
+ var lapTime = time - car.lapStart;
185
+ car.lapStart = time;
186
+ if (!car.bestLap || lapTime < car.bestLap) car.bestLap = lapTime;
187
+ if (!this.best || lapTime < this.best) { this.best = lapTime; Okona.save.set('bestLap', Math.round(lapTime * 100) / 100); }
188
+ if (car.lap > this.LAPS) {
189
+ car.finishTime = time; car.v = 0;
190
+ if (!this.winnerAt) this.winnerAt = time;
191
+ Okona.save.flush();
91
192
  }
92
- // HUD: big type — it is read from across the room
93
- ctx.fillStyle = '#E8A525'; ctx.textAlign = 'left'; ctx.font = 'bold 40px system-ui, sans-serif';
94
- ctx.fillText(this.title, 70, 100);
95
- ctx.fillStyle = '#A0B4C8'; ctx.textAlign = 'right';
96
- ctx.fillText('Best ' + this.best, W - 70, 100);
97
- }
193
+ },
194
+
195
+ // ---- results: one line per racer, best first ---------------------------------
196
+ results: function (players) {
197
+ var cars = this.cars, rows = [];
198
+ for (var i = 0; i < 6; i++) if (cars[i].racing && players[i].joined) rows.push(i);
199
+ rows.sort(function (a, b) {
200
+ var A = cars[a], B = cars[b];
201
+ if (A.finishTime && B.finishTime) return A.finishTime - B.finishTime;
202
+ if (A.finishTime || B.finishTime) return A.finishTime ? -1 : 1;
203
+ return B.s - A.s; // unfinished: placed by distance
204
+ });
205
+ return rows.map(function (i, place) {
206
+ var c = cars[i];
207
+ return { color: players[i].color, text: (place + 1) + '. ' + players[i].name + ' ' +
208
+ (c.finishTime ? c.finishTime.toFixed(2) + ' s' : 'did not finish') + (c.bestLap ? ' best lap ' + c.bestLap.toFixed(2) + ' s' : '') };
209
+ });
210
+ },
211
+ show: function (on) { this.layer.setVisible(on); }
98
212
  };
package/template/icon.png CHANGED
Binary file