Compare commits
11 Commits
5d7941429c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 20a3ee40a1 | |||
| 926f876a6e | |||
| 33d3f4f08e | |||
| ffb8149c45 | |||
| ae101d1b88 | |||
| 8dab8183d7 | |||
| 48f7d5c7dc | |||
| 693bcf9a58 | |||
| faf9eafe45 | |||
| 1d9d878c3d | |||
| 4ae32743d7 |
63
docs/superpowers/NEXT-STEPS.md
Normal file
63
docs/superpowers/NEXT-STEPS.md
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
# Next steps
|
||||||
|
|
||||||
|
Scratch continuity note for resuming work (e.g. on another machine). The
|
||||||
|
authoritative design lives in `specs/2026-07-02-padel-scoring-design.md` and
|
||||||
|
`plans/2026-07-02-padel-scoring.md`.
|
||||||
|
|
||||||
|
## Current status (2026-07-05)
|
||||||
|
|
||||||
|
- Scoring app is **implemented and working**: UI-free `PadelMatch` engine
|
||||||
|
(17 unit tests pass in the sim), `CourtRenderer`, `HeartRateProvider`, and
|
||||||
|
the View/Delegate/App. Built and verified in the Connect IQ simulator and
|
||||||
|
sideloaded to a real Venu 4 (venu445mm).
|
||||||
|
- UI has been refined beyond the plan: top **HR arc gauge** in Garmin zone
|
||||||
|
colors (grey/blue/green/orange/red) with a marker on the current zone and
|
||||||
|
the bpm number below; **proper padel court** (net centered, service lines at
|
||||||
|
the 3m-from-back-wall proportion, center line only between service lines),
|
||||||
|
wider with a configurable blue surface (`bgColor` on `CourtRenderer`); serve
|
||||||
|
ball marks the back zone; games column white, sets accent green, "Pts"; HR +
|
||||||
|
time on a matched `FONT_MEDIUM`.
|
||||||
|
- All committed on `main` and pushed to https://scm.vilanet.fr/kriss/garmin-padel.
|
||||||
|
|
||||||
|
## Build / run reminder
|
||||||
|
|
||||||
|
- `developer_key` is git-ignored (private signing key) — provide it out of band.
|
||||||
|
- SDK: connectiq-sdk-mac-9.2.0. Device: `venu445mm`.
|
||||||
|
- Build: `"$SDK/bin/monkeyc" -f monkey.jungle -o bin/garmin-padel.prg -y ./developer_key -d venu445mm`
|
||||||
|
- Run: `"$SDK/bin/connectiq" &` then `"$SDK/bin/monkeydo" bin/garmin-padel.prg venu445mm`
|
||||||
|
- Unit tests: build with `--unit-test` then `monkeydo bin/test.prg venu445mm -t`
|
||||||
|
(monkeydo's exit code 1 is a known simulator artifact; trust the in-app
|
||||||
|
`passed/failed` summary).
|
||||||
|
|
||||||
|
## Input gotcha (important)
|
||||||
|
|
||||||
|
The **simulator merges the back-swipe and the physical back button** into one
|
||||||
|
`onBack`, so swipe-left appears to exit the app there. On **real hardware** they
|
||||||
|
are separate: swipe-left reaches `onSwipe(SWIPE_LEFT)` (→ undo, consumed) and
|
||||||
|
the physical button triggers the default back (→ exit). The delegate relies on
|
||||||
|
this — test back/undo behavior **on the watch, not the sim**.
|
||||||
|
|
||||||
|
## Next feature: record a padel activity
|
||||||
|
|
||||||
|
The app currently only *displays* the score + a live HR readout; it does **not**
|
||||||
|
record a Garmin activity (spec §8 deferred). Add session recording via
|
||||||
|
`Toybox.ActivityRecording`:
|
||||||
|
|
||||||
|
- create/start a session (find the best sport type — native padel/racquet if
|
||||||
|
the Venu 4 exposes one, else a generic training/sport type),
|
||||||
|
- pause/resume, and save-or-discard at match end (appears in Garmin Connect
|
||||||
|
with duration, HR zones, calories),
|
||||||
|
- likely show an elapsed activity timer on screen.
|
||||||
|
|
||||||
|
**Open design questions to settle first (do a brief brainstorm/spec pass):**
|
||||||
|
- Auto-start the session on launch, on the first point, or via an explicit start?
|
||||||
|
- Which sport type is available on venu445mm?
|
||||||
|
- Save prompt at match end (save / discard / resume)?
|
||||||
|
- Where to surface the elapsed activity timer in the layout?
|
||||||
|
|
||||||
|
## Other deferred items (spec §8)
|
||||||
|
|
||||||
|
- Settings screen driving `RulesConfig` (golden point, sets, tiebreak) and the
|
||||||
|
top/bottom info slots (incl. the court color already wired as `bgColor`).
|
||||||
|
- Match history.
|
||||||
|
- Resume-after-close persistence.
|
||||||
@@ -20,7 +20,10 @@
|
|||||||
Use "Monkey C: Edit Permissions" from the Visual Studio Code command
|
Use "Monkey C: Edit Permissions" from the Visual Studio Code command
|
||||||
palette to update permissions.
|
palette to update permissions.
|
||||||
-->
|
-->
|
||||||
<iq:permissions/>
|
<iq:permissions>
|
||||||
|
<iq:uses-permission id="Sensor"/>
|
||||||
|
<iq:uses-permission id="UserProfile"/>
|
||||||
|
</iq:permissions>
|
||||||
<!--
|
<!--
|
||||||
Use "Monkey C: Edit Languages" from the Visual Studio Code command
|
Use "Monkey C: Edit Languages" from the Visual Studio Code command
|
||||||
palette to edit your compatible language list.
|
palette to edit your compatible language list.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 16 KiB |
@@ -1,4 +0,0 @@
|
|||||||
<layout id="MainLayout" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
|
|
||||||
<label x="center" y="5" text="@Strings.prompt" color="Graphics.COLOR_WHITE" justification="Graphics.TEXT_JUSTIFY_CENTER" />
|
|
||||||
<bitmap id="id_monkey" x="center" y="30" filename="../drawables/monkey.png" />
|
|
||||||
</layout>
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
<menu id="MainMenu" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
|
|
||||||
<menu-item id="item_1" label="@Strings.menu_label_1" />
|
|
||||||
<menu-item id="item_2" label="@Strings.menu_label_2" />
|
|
||||||
</menu>
|
|
||||||
@@ -1,8 +1,3 @@
|
|||||||
<strings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
|
<strings xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="https://developer.garmin.com/downloads/connect-iq/resources.xsd">
|
||||||
<string id="AppName">garmin-padel</string>
|
<string id="AppName">Padel</string>
|
||||||
|
|
||||||
<string id="prompt">Click the menu button</string>
|
|
||||||
|
|
||||||
<string id="menu_label_1">Item 1</string>
|
|
||||||
<string id="menu_label_2">Item 2</string>
|
|
||||||
</strings>
|
</strings>
|
||||||
|
|||||||
52
source/CourtRenderer.mc
Normal file
52
source/CourtRenderer.mc
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Graphics;
|
||||||
|
|
||||||
|
// Draws a top-down padel court centered at (cx, cy) and a yellow serve ball
|
||||||
|
// in the given quadrant. Half-width HW and half-height HH keep it inside the
|
||||||
|
// central band of the round screen.
|
||||||
|
class CourtRenderer {
|
||||||
|
private const HW = 92; // half court width (px)
|
||||||
|
private const HH = 95; // half court height (px)
|
||||||
|
private const BALL_R = 7;
|
||||||
|
|
||||||
|
// bgColor fills the court surface (blue for now; a future settings screen
|
||||||
|
// will drive it via RulesConfig).
|
||||||
|
public function draw(dc as Graphics.Dc, cx as Number, cy as Number,
|
||||||
|
quadrant as Number, showBall as Boolean,
|
||||||
|
bgColor as Number) as Void {
|
||||||
|
var left = cx - HW;
|
||||||
|
var top = cy - HH;
|
||||||
|
|
||||||
|
// Service line sits 3m from the back wall (7m from the net) on a 10m
|
||||||
|
// half, i.e. 0.7 of the way from the net to the back boundary.
|
||||||
|
var sl = (7 * HH) / 10;
|
||||||
|
|
||||||
|
dc.setColor(bgColor, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.fillRectangle(left, top, 2 * HW, 2 * HH); // court surface
|
||||||
|
|
||||||
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.setPenWidth(2);
|
||||||
|
dc.drawRectangle(left, top, 2 * HW, 2 * HH); // outer boundary
|
||||||
|
dc.drawLine(left, cy - sl, left + 2 * HW, cy - sl); // upper service line
|
||||||
|
dc.drawLine(left, cy + sl, left + 2 * HW, cy + sl); // lower service line
|
||||||
|
dc.drawLine(cx, cy - sl, cx, cy + sl); // center line (between service lines only)
|
||||||
|
|
||||||
|
dc.setPenWidth(4);
|
||||||
|
dc.drawLine(left, cy, left + 2 * HW, cy); // net (boldest)
|
||||||
|
|
||||||
|
if (showBall) {
|
||||||
|
var pos = _ballPos(quadrant, cx, cy);
|
||||||
|
dc.setColor(Graphics.COLOR_YELLOW, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.fillCircle(pos[0], pos[1], BALL_R);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _ballPos(quadrant as Number, cx as Number, cy as Number) as Array<Number> {
|
||||||
|
var dx = HW / 2;
|
||||||
|
var dy = (17 * HH) / 20; // centered in the back zone, farthest from the net
|
||||||
|
if (quadrant == QUAD_BOTTOM_RIGHT) { return [cx + dx, cy + dy]; }
|
||||||
|
if (quadrant == QUAD_BOTTOM_LEFT) { return [cx - dx, cy + dy]; }
|
||||||
|
if (quadrant == QUAD_UPPER_LEFT) { return [cx - dx, cy - dy]; }
|
||||||
|
return [cx + dx, cy - dy]; // QUAD_UPPER_RIGHT
|
||||||
|
}
|
||||||
|
}
|
||||||
46
source/HeartRateProvider.mc
Normal file
46
source/HeartRateProvider.mc
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
import Toybox.Sensor;
|
||||||
|
import Toybox.UserProfile;
|
||||||
|
|
||||||
|
// Wraps the heart-rate sensor and maps the current bpm to a zone (1..5).
|
||||||
|
// Returns null bpm when no reading is available (e.g. simulator without data
|
||||||
|
// simulation), so the view can show a placeholder.
|
||||||
|
class HeartRateProvider {
|
||||||
|
private var _hr as Number?;
|
||||||
|
private var _zones as Array<Number>?;
|
||||||
|
|
||||||
|
public function initialize() {
|
||||||
|
_hr = null;
|
||||||
|
_zones = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onStart() as Void {
|
||||||
|
Sensor.setEnabledSensors([Sensor.SENSOR_HEARTRATE]);
|
||||||
|
Sensor.enableSensorEvents(method(:onSensor));
|
||||||
|
_zones = UserProfile.getHeartRateZones(UserProfile.HR_ZONE_SPORT_GENERIC);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onStop() as Void {
|
||||||
|
Sensor.enableSensorEvents(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function onSensor(info as Sensor.Info) as Void {
|
||||||
|
_hr = info.heartRate;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getHeartRate() as Number? {
|
||||||
|
return _hr;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns 1..5, or 0 if HR/zones unavailable.
|
||||||
|
public function getZone() as Number {
|
||||||
|
if (_hr == null || _zones == null) { return 0; }
|
||||||
|
var z = _zones as Array<Number>;
|
||||||
|
var hr = _hr as Number;
|
||||||
|
// _zones holds 6 boundaries: [z1lo, z2lo, z3lo, z4lo, z5lo, max]
|
||||||
|
for (var i = z.size() - 2; i >= 0; i -= 1) {
|
||||||
|
if (hr >= z[i]) { return i + 1 > 5 ? 5 : i + 1; }
|
||||||
|
}
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
200
source/PadelMatch.mc
Normal file
200
source/PadelMatch.mc
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
|
|
||||||
|
// Teams
|
||||||
|
const US = 0;
|
||||||
|
const THEM = 1;
|
||||||
|
|
||||||
|
// Court quadrants (your team = bottom half)
|
||||||
|
enum {
|
||||||
|
QUAD_BOTTOM_RIGHT, // US, deuce (right)
|
||||||
|
QUAD_BOTTOM_LEFT, // US, ad (left)
|
||||||
|
QUAD_UPPER_LEFT, // THEM, deuce (their right -> screen left)
|
||||||
|
QUAD_UPPER_RIGHT // THEM, ad (their left -> screen right)
|
||||||
|
}
|
||||||
|
|
||||||
|
class PadelMatch {
|
||||||
|
private var _rules as RulesConfig;
|
||||||
|
|
||||||
|
private var _points as Array<Number>; // raw point count per team in the current game
|
||||||
|
private var _games as Array<Number>; // games won in the current set
|
||||||
|
private var _sets as Array<Number>; // sets won in the match
|
||||||
|
private var _server as Number; // team serving the current game
|
||||||
|
private var _inTiebreak as Boolean;
|
||||||
|
private var _tbPoints as Array<Number>;
|
||||||
|
private var _tbFirstServer as Number; // team that served the tiebreak's first point
|
||||||
|
private var _matchOver as Boolean;
|
||||||
|
private var _winner as Number; // US / THEM / -1
|
||||||
|
private var _history as Array<Dictionary>;
|
||||||
|
|
||||||
|
private const POINT_LABELS = ["0", "15", "30", "40"];
|
||||||
|
|
||||||
|
public function initialize(rules as RulesConfig) {
|
||||||
|
_rules = rules;
|
||||||
|
_points = [0, 0];
|
||||||
|
_games = [0, 0];
|
||||||
|
_sets = [0, 0];
|
||||||
|
_server = US;
|
||||||
|
_inTiebreak = false;
|
||||||
|
_tbPoints = [0, 0];
|
||||||
|
_tbFirstServer = US;
|
||||||
|
_matchOver = false;
|
||||||
|
_winner = -1;
|
||||||
|
_history = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- public API ----
|
||||||
|
|
||||||
|
public function pointTo(team as Number) as Void {
|
||||||
|
if (_matchOver) { return; }
|
||||||
|
_pushHistory();
|
||||||
|
if (_inTiebreak) {
|
||||||
|
_tbPoints[team] += 1;
|
||||||
|
_resolveTiebreak(team);
|
||||||
|
} else {
|
||||||
|
_points[team] += 1;
|
||||||
|
_resolveGame(team);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public function undo() as Void {
|
||||||
|
var n = _history.size();
|
||||||
|
if (n == 0) { return; }
|
||||||
|
var s = _history[n - 1];
|
||||||
|
_history = _history.slice(0, n - 1);
|
||||||
|
_points = s["points"] as Array<Number>;
|
||||||
|
_games = s["games"] as Array<Number>;
|
||||||
|
_sets = s["sets"] as Array<Number>;
|
||||||
|
_server = s["server"] as Number;
|
||||||
|
_inTiebreak = s["inTiebreak"] as Boolean;
|
||||||
|
_tbPoints = s["tbPoints"] as Array<Number>;
|
||||||
|
_tbFirstServer = s["tbFirstServer"] as Number;
|
||||||
|
_matchOver = s["matchOver"] as Boolean;
|
||||||
|
_winner = s["winner"] as Number;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function canSetServer() as Boolean {
|
||||||
|
return _history.size() == 0 && !_matchOver;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function toggleStartingServer() as Void {
|
||||||
|
if (canSetServer()) { _server = 1 - _server; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public function servingQuadrant() as Number {
|
||||||
|
var team;
|
||||||
|
var pointsPlayed;
|
||||||
|
if (_inTiebreak) {
|
||||||
|
pointsPlayed = _tbPoints[0] + _tbPoints[1];
|
||||||
|
team = _tiebreakServer(pointsPlayed);
|
||||||
|
} else {
|
||||||
|
pointsPlayed = _points[0] + _points[1];
|
||||||
|
team = _server;
|
||||||
|
}
|
||||||
|
var isDeuce = (pointsPlayed % 2) == 0;
|
||||||
|
return _quadrantFor(team, isDeuce);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function pointLabel(team as Number) as String {
|
||||||
|
if (_inTiebreak) { return _tbPoints[team].toString(); }
|
||||||
|
var a = _points[team];
|
||||||
|
var b = _points[1 - team];
|
||||||
|
if (!_rules.goldenPoint && a == 4 && b == 3) { return "AD"; }
|
||||||
|
if (a >= 4) { return "40"; } // safety; game normally ends before display
|
||||||
|
return POINT_LABELS[a];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function games(team as Number) as Number { return _games[team]; }
|
||||||
|
public function sets(team as Number) as Number { return _sets[team]; }
|
||||||
|
public function server() as Number { return _server; }
|
||||||
|
public function isInTiebreak() as Boolean { return _inTiebreak; }
|
||||||
|
public function isMatchOver() as Boolean { return _matchOver; }
|
||||||
|
public function winner() as Number { return _winner; }
|
||||||
|
public function tiebreakPoints(team as Number) as Number { return _tbPoints[team]; }
|
||||||
|
|
||||||
|
// ---- internals ----
|
||||||
|
|
||||||
|
private function _resolveGame(team as Number) as Void {
|
||||||
|
var other = 1 - team;
|
||||||
|
var a = _points[team];
|
||||||
|
var b = _points[other];
|
||||||
|
if (_rules.goldenPoint) {
|
||||||
|
if (a >= 4) { _winGame(team); }
|
||||||
|
} else {
|
||||||
|
if (a >= 4 && (a - b) >= 2) { _winGame(team); }
|
||||||
|
else if (a >= 4 && b >= 4) { _points[team] = 3; _points[other] = 3; } // both past 40 -> deuce
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _winGame(team as Number) as Void {
|
||||||
|
_games[team] += 1;
|
||||||
|
_points = [0, 0];
|
||||||
|
_server = 1 - _server;
|
||||||
|
_resolveSet(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _resolveSet(team as Number) as Void {
|
||||||
|
var other = 1 - team;
|
||||||
|
var a = _games[team];
|
||||||
|
var b = _games[other];
|
||||||
|
if (a >= _rules.gamesPerSet && (a - b) >= 2) {
|
||||||
|
_winSet(team);
|
||||||
|
} else if (_rules.tiebreakEnabled && a == _rules.gamesPerSet && b == _rules.gamesPerSet) {
|
||||||
|
_inTiebreak = true;
|
||||||
|
_tbPoints = [0, 0];
|
||||||
|
_tbFirstServer = _server; // team due to serve after the 12th game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _resolveTiebreak(team as Number) as Void {
|
||||||
|
var other = 1 - team;
|
||||||
|
var a = _tbPoints[team];
|
||||||
|
var b = _tbPoints[other];
|
||||||
|
if (a >= _rules.tiebreakTarget && (a - b) >= 2) {
|
||||||
|
_games[team] += 1; // e.g. 7-6
|
||||||
|
_winSet(team);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _winSet(team as Number) as Void {
|
||||||
|
_sets[team] += 1;
|
||||||
|
_games = [0, 0];
|
||||||
|
if (_inTiebreak) {
|
||||||
|
_inTiebreak = false;
|
||||||
|
_server = 1 - _tbFirstServer; // tiebreak receiver serves first next set
|
||||||
|
}
|
||||||
|
_resolveMatch(team);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _resolveMatch(team as Number) as Void {
|
||||||
|
if (_sets[team] >= _rules.setsToWin) {
|
||||||
|
_matchOver = true;
|
||||||
|
_winner = team;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _tiebreakServer(pointsPlayed as Number) as Number {
|
||||||
|
var offset = ((pointsPlayed + 1) / 2) % 2; // integer division
|
||||||
|
return (offset == 0) ? _tbFirstServer : (1 - _tbFirstServer);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _quadrantFor(team as Number, isDeuce as Boolean) as Number {
|
||||||
|
if (team == US) {
|
||||||
|
return isDeuce ? QUAD_BOTTOM_RIGHT : QUAD_BOTTOM_LEFT;
|
||||||
|
}
|
||||||
|
return isDeuce ? QUAD_UPPER_LEFT : QUAD_UPPER_RIGHT;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _pushHistory() as Void {
|
||||||
|
_history.add({
|
||||||
|
"points" => [_points[0], _points[1]],
|
||||||
|
"games" => [_games[0], _games[1]],
|
||||||
|
"sets" => [_sets[0], _sets[1]],
|
||||||
|
"server" => _server,
|
||||||
|
"inTiebreak" => _inTiebreak,
|
||||||
|
"tbPoints" => [_tbPoints[0], _tbPoints[1]],
|
||||||
|
"tbFirstServer" => _tbFirstServer,
|
||||||
|
"matchOver" => _matchOver,
|
||||||
|
"winner" => _winner
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -139,6 +139,46 @@ function winGameFor(m as PadelMatch, team as Number) as Void {
|
|||||||
m.pointTo(US); // match started; toggle now locked
|
m.pointTo(US); // match started; toggle now locked
|
||||||
m.toggleStartingServer();
|
m.toggleStartingServer();
|
||||||
Test.assertEqualMessage(m.canSetServer(), false, "server locked after first point");
|
Test.assertEqualMessage(m.canSetServer(), false, "server locked after first point");
|
||||||
|
Test.assertEqualMessage(m.servingQuadrant(), QUAD_UPPER_RIGHT, "server unchanged after locked toggle");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
(:test) function testGoldenPointSuddenDeath(logger as Logger) as Boolean {
|
||||||
|
var m = newMatch(); // golden point default
|
||||||
|
for (var i = 0; i < 3; i += 1) { m.pointTo(US); m.pointTo(THEM); }
|
||||||
|
Test.assertEqualMessage(m.pointLabel(US), "40", "US 40 at deuce");
|
||||||
|
Test.assertEqualMessage(m.pointLabel(THEM), "40", "THEM 40 at deuce");
|
||||||
|
m.pointTo(US); // sudden death: next point wins
|
||||||
|
Test.assertEqualMessage(m.games(US), 1, "US wins game on golden point");
|
||||||
|
Test.assertEqualMessage(m.pointLabel(US), "0", "points reset after game");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
(:test) function testSetWinBySevenFive(logger as Logger) as Boolean {
|
||||||
|
var m = newMatch();
|
||||||
|
for (var g = 0; g < 5; g += 1) { winGameFor(m, US); winGameFor(m, THEM); }
|
||||||
|
Test.assertEqualMessage(m.games(US), 5, "5-5 games");
|
||||||
|
winGameFor(m, US); // 6-5: not a set yet (win by 2)
|
||||||
|
Test.assertEqualMessage(m.sets(US), 0, "no set at 6-5");
|
||||||
|
Test.assertEqualMessage(m.isInTiebreak(), false, "no tiebreak at 6-5");
|
||||||
|
Test.assertEqualMessage(m.games(US), 6, "US leads 6-5");
|
||||||
|
winGameFor(m, US); // 7-5: set won
|
||||||
|
Test.assertEqualMessage(m.sets(US), 1, "US wins set 7-5");
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
(:test) function testTiebreakWinByTwo(logger as Logger) as Boolean {
|
||||||
|
var m = newMatch();
|
||||||
|
for (var g = 0; g < 6; g += 1) { winGameFor(m, US); winGameFor(m, THEM); }
|
||||||
|
Test.assertEqualMessage(m.isInTiebreak(), true, "tiebreak at 6-6");
|
||||||
|
for (var p = 0; p < 6; p += 1) { m.pointTo(US); m.pointTo(THEM); } // 6-6 in tiebreak
|
||||||
|
Test.assertEqualMessage(m.isInTiebreak(), true, "still in tiebreak at 6-6");
|
||||||
|
m.pointTo(US); // 7-6: not won (win by 2)
|
||||||
|
Test.assertEqualMessage(m.isInTiebreak(), true, "7-6 not enough");
|
||||||
|
Test.assertEqualMessage(m.sets(US), 0, "no set at 7-6");
|
||||||
|
m.pointTo(US); // 8-6: set won
|
||||||
|
Test.assertEqualMessage(m.isInTiebreak(), false, "tiebreak done at 8-6");
|
||||||
|
Test.assertEqualMessage(m.sets(US), 1, "US wins set via 8-6 tiebreak");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -3,24 +3,35 @@ import Toybox.Lang;
|
|||||||
import Toybox.WatchUi;
|
import Toybox.WatchUi;
|
||||||
|
|
||||||
class garmin_padelApp extends Application.AppBase {
|
class garmin_padelApp extends Application.AppBase {
|
||||||
|
private var _rules as RulesConfig;
|
||||||
|
private var _match as PadelMatch;
|
||||||
|
private var _hr as HeartRateProvider;
|
||||||
|
|
||||||
function initialize() {
|
public function initialize() {
|
||||||
AppBase.initialize();
|
AppBase.initialize();
|
||||||
|
_rules = new RulesConfig();
|
||||||
|
_match = new PadelMatch(_rules);
|
||||||
|
_hr = new HeartRateProvider();
|
||||||
}
|
}
|
||||||
|
|
||||||
// onStart() is called on application start up
|
public function onStart(state as Dictionary?) as Void {
|
||||||
function onStart(state as Dictionary?) as Void {
|
_hr.onStart();
|
||||||
}
|
}
|
||||||
|
|
||||||
// onStop() is called when your application is exiting
|
public function onStop(state as Dictionary?) as Void {
|
||||||
function onStop(state as Dictionary?) as Void {
|
_hr.onStop();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return the initial view of your application here
|
public function getMatch() as PadelMatch { return _match; }
|
||||||
function getInitialView() as [Views] or [Views, InputDelegates] {
|
public function getHeartRate() as HeartRateProvider { return _hr; }
|
||||||
|
|
||||||
|
public function resetMatch() as Void {
|
||||||
|
_match = new PadelMatch(_rules);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getInitialView() as [Views] or [Views, InputDelegates] {
|
||||||
return [ new garmin_padelView(), new garmin_padelDelegate() ];
|
return [ new garmin_padelView(), new garmin_padelDelegate() ];
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function getApp() as garmin_padelApp {
|
function getApp() as garmin_padelApp {
|
||||||
|
|||||||
@@ -3,13 +3,36 @@ import Toybox.WatchUi;
|
|||||||
|
|
||||||
class garmin_padelDelegate extends WatchUi.BehaviorDelegate {
|
class garmin_padelDelegate extends WatchUi.BehaviorDelegate {
|
||||||
|
|
||||||
function initialize() {
|
public function initialize() {
|
||||||
BehaviorDelegate.initialize();
|
BehaviorDelegate.initialize();
|
||||||
}
|
}
|
||||||
|
|
||||||
function onMenu() as Boolean {
|
public function onSwipe(swipeEvent as WatchUi.SwipeEvent) as Boolean {
|
||||||
WatchUi.pushView(new Rez.Menus.MainMenu(), new garmin_padelMenuDelegate(), WatchUi.SLIDE_UP);
|
var m = getApp().getMatch();
|
||||||
|
var dir = swipeEvent.getDirection();
|
||||||
|
if (dir == WatchUi.SWIPE_UP) {
|
||||||
|
m.pointTo(US);
|
||||||
|
} else if (dir == WatchUi.SWIPE_DOWN) {
|
||||||
|
m.pointTo(THEM);
|
||||||
|
} else if (dir == WatchUi.SWIPE_LEFT) {
|
||||||
|
m.undo();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
WatchUi.requestUpdate();
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function onTap(clickEvent as WatchUi.ClickEvent) as Boolean {
|
||||||
|
var m = getApp().getMatch();
|
||||||
|
if (m.isMatchOver()) {
|
||||||
|
getApp().resetMatch();
|
||||||
|
} else if (m.canSetServer()) {
|
||||||
|
m.toggleStartingServer();
|
||||||
|
} else {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
WatchUi.requestUpdate();
|
||||||
|
return true;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import Toybox.Lang;
|
|
||||||
import Toybox.System;
|
|
||||||
import Toybox.WatchUi;
|
|
||||||
|
|
||||||
class garmin_padelMenuDelegate extends WatchUi.MenuInputDelegate {
|
|
||||||
|
|
||||||
function initialize() {
|
|
||||||
MenuInputDelegate.initialize();
|
|
||||||
}
|
|
||||||
|
|
||||||
function onMenuItem(item as Symbol) as Void {
|
|
||||||
if (item == :item_1) {
|
|
||||||
System.println("item 1");
|
|
||||||
} else if (item == :item_2) {
|
|
||||||
System.println("item 2");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
@@ -1,33 +1,152 @@
|
|||||||
|
import Toybox.Lang;
|
||||||
import Toybox.Graphics;
|
import Toybox.Graphics;
|
||||||
import Toybox.WatchUi;
|
import Toybox.WatchUi;
|
||||||
|
import Toybox.System;
|
||||||
|
import Toybox.Timer;
|
||||||
|
import Toybox.Math;
|
||||||
|
|
||||||
class garmin_padelView extends WatchUi.View {
|
class garmin_padelView extends WatchUi.View {
|
||||||
|
private const ACCENT = 0x00E0A0; // padel green (sets/games)
|
||||||
|
private const LABEL_COLOR = 0x808080; // muted grey (S/G/pts labels)
|
||||||
|
private const COURT_BLUE = 0x1560BD; // court surface fill (configurable later)
|
||||||
|
|
||||||
function initialize() {
|
private var _court as CourtRenderer;
|
||||||
|
private var _timer as Timer.Timer;
|
||||||
|
|
||||||
|
public function initialize() {
|
||||||
View.initialize();
|
View.initialize();
|
||||||
|
_court = new CourtRenderer();
|
||||||
|
_timer = new Timer.Timer();
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load your resources here
|
public function onShow() as Void {
|
||||||
function onLayout(dc as Dc) as Void {
|
_timer.start(method(:onTick), 1000, true); // refresh clock + HR every second
|
||||||
setLayout(Rez.Layouts.MainLayout(dc));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when this View is brought to the foreground. Restore
|
public function onHide() as Void {
|
||||||
// the state of this View and prepare it to be shown. This includes
|
_timer.stop();
|
||||||
// loading resources into memory.
|
|
||||||
function onShow() as Void {
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update the view
|
public function onTick() as Void {
|
||||||
function onUpdate(dc as Dc) as Void {
|
WatchUi.requestUpdate();
|
||||||
// Call the parent onUpdate function to redraw the layout
|
|
||||||
View.onUpdate(dc);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Called when this View is removed from the screen. Save the
|
public function onUpdate(dc as Graphics.Dc) as Void {
|
||||||
// state of this View here. This includes freeing resources from
|
var m = getApp().getMatch();
|
||||||
// memory.
|
var w = dc.getWidth();
|
||||||
function onHide() as Void {
|
var h = dc.getHeight();
|
||||||
|
var cx = w / 2;
|
||||||
|
var cy = h / 2;
|
||||||
|
|
||||||
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_BLACK);
|
||||||
|
dc.clear();
|
||||||
|
|
||||||
|
_court.draw(dc, cx, cy, m.servingQuadrant(), !m.isMatchOver(), COURT_BLUE);
|
||||||
|
_drawLeftColumn(dc, m);
|
||||||
|
_drawRightColumn(dc, m, w);
|
||||||
|
_drawHeartRate(dc, cx, cy);
|
||||||
|
_drawTime(dc, cx, h - 78);
|
||||||
|
|
||||||
|
if (m.isMatchOver()) {
|
||||||
|
_drawWinner(dc, cx, cy, m);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Left: S and G mini-columns, your team on top, opponents below.
|
||||||
|
private function _drawLeftColumn(dc as Graphics.Dc, m as PadelMatch) as Void {
|
||||||
|
var sX = 30;
|
||||||
|
var gX = 76;
|
||||||
|
var labelY = 148;
|
||||||
|
var usY = 184;
|
||||||
|
var themY = 240;
|
||||||
|
|
||||||
|
dc.setColor(LABEL_COLOR, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(sX, labelY, Graphics.FONT_XTINY, "S", Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
dc.drawText(gX, labelY, Graphics.FONT_XTINY, "G", Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
|
||||||
|
dc.setColor(ACCENT, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(sX, usY, Graphics.FONT_TINY, m.sets(US).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
dc.drawText(sX, themY, Graphics.FONT_TINY, m.sets(THEM).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
|
||||||
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(gX, usY, Graphics.FONT_TINY, m.games(US).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
dc.drawText(gX, themY, Graphics.FONT_TINY, m.games(THEM).toString(), Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Right: Pts column, your team on top, opponents below.
|
||||||
|
private function _drawRightColumn(dc as Graphics.Dc, m as PadelMatch, w as Number) as Void {
|
||||||
|
var pX = w - 46;
|
||||||
|
var labelY = 148;
|
||||||
|
var usY = 184;
|
||||||
|
var themY = 240;
|
||||||
|
|
||||||
|
dc.setColor(LABEL_COLOR, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(pX, labelY, Graphics.FONT_XTINY, "Pts", Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
|
||||||
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(pX, usY, Graphics.FONT_TINY, m.pointLabel(US), Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
dc.drawText(pX, themY, Graphics.FONT_TINY, m.pointLabel(THEM), Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Heart rate as a top arc gauge: 5 zone-colored segments (grey/blue/green/
|
||||||
|
// orange/red), a white marker on the current zone, and the bpm number just
|
||||||
|
// below — mirroring the standard Garmin run-activity HR view.
|
||||||
|
private function _drawHeartRate(dc as Graphics.Dc, cx as Number, cy as Number) as Void {
|
||||||
|
var hr = getApp().getHeartRate();
|
||||||
|
var bpm = hr.getHeartRate();
|
||||||
|
var zone = hr.getZone();
|
||||||
|
var r = cx - 9; // arc radius, hugging the top bezel
|
||||||
|
|
||||||
|
// 5 zone segments across the top (150deg .. 30deg, clockwise)
|
||||||
|
dc.setPenWidth(14);
|
||||||
|
for (var k = 0; k < 5; k += 1) {
|
||||||
|
var segStart = 150 - k * 24;
|
||||||
|
dc.setColor(_zoneColor(k + 1), Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawArc(cx, cy, r, Graphics.ARC_CLOCKWISE, segStart - 2, segStart - 22);
|
||||||
|
}
|
||||||
|
dc.setPenWidth(1);
|
||||||
|
|
||||||
|
// marker on the current zone
|
||||||
|
if (zone >= 1 && zone <= 5) {
|
||||||
|
var mid = 150 - (zone - 1) * 24 - 12;
|
||||||
|
var rad = Math.toRadians(mid);
|
||||||
|
var mx = (cx + r * Math.cos(rad)).toNumber();
|
||||||
|
var my = (cy - r * Math.sin(rad)).toNumber();
|
||||||
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.fillCircle(mx, my, 8);
|
||||||
|
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawCircle(mx, my, 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// exact rate, tucked just under the arc (dim placeholder when no reading,
|
||||||
|
// so it never reads as a court line)
|
||||||
|
var text = (bpm == null) ? "--" : bpm.toString();
|
||||||
|
var col = (bpm == null) ? LABEL_COLOR : Graphics.COLOR_WHITE;
|
||||||
|
dc.setColor(col, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(cx, cy - 178, Graphics.FONT_MEDIUM, text, Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Garmin HR zone colors: 1 grey, 2 blue, 3 green, 4 orange, 5 red.
|
||||||
|
private function _zoneColor(z as Number) as Number {
|
||||||
|
if (z == 1) { return Graphics.COLOR_LT_GRAY; }
|
||||||
|
if (z == 2) { return Graphics.COLOR_BLUE; }
|
||||||
|
if (z == 3) { return Graphics.COLOR_GREEN; }
|
||||||
|
if (z == 4) { return Graphics.COLOR_ORANGE; }
|
||||||
|
return Graphics.COLOR_RED;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _drawTime(dc as Graphics.Dc, cx as Number, y as Number) as Void {
|
||||||
|
var now = System.getClockTime();
|
||||||
|
var text = now.hour.format("%02d") + ":" + now.min.format("%02d");
|
||||||
|
dc.setColor(Graphics.COLOR_WHITE, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(cx, y, Graphics.FONT_MEDIUM, text, Graphics.TEXT_JUSTIFY_CENTER);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function _drawWinner(dc as Graphics.Dc, cx as Number, cy as Number, m as PadelMatch) as Void {
|
||||||
|
var text = (m.winner() == US) ? "You win!" : "They win";
|
||||||
|
dc.setColor(ACCENT, Graphics.COLOR_BLACK);
|
||||||
|
dc.fillRectangle(cx - 70, cy - 18, 140, 36);
|
||||||
|
dc.setColor(Graphics.COLOR_BLACK, Graphics.COLOR_TRANSPARENT);
|
||||||
|
dc.drawText(cx, cy, Graphics.FONT_SMALL, text, Graphics.TEXT_JUSTIFY_CENTER | Graphics.TEXT_JUSTIFY_VCENTER);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user