diff --git a/apps.json b/apps.json index 6b0703601..611edb53a 100644 --- a/apps.json +++ b/apps.json @@ -3554,5 +3554,23 @@ {"name":"floralclk.app.js","url":"app.js"}, {"name":"floralclk.img","url":"app-icon.js","evaluate":true} ] +}, +{ "id": "score", + "name": "Score Tracker", + "icon": "score.app.png", + "version":"0.01", + "description": "Score Tracker for sports that use plain numbers (e.g. Badminton, Volleyball, Soccer, Table Tennis, ...). Also supports tennis scoring.", + "readme": "README.md", + "tags": "b2", + "type": "app", + "storage": [ + {"name":"score.app.js","url":"score.app.js"}, + {"name":"score.settings.js","url":"score.settings.js"}, + {"name":"score.presets.json","url":"score.presets.json"}, + {"name":"score.img","url":"score.app-icon.js","evaluate":true} + ], + "data": [ + {"name":"score.json"} + ] } ] diff --git a/apps/score/ChangeLog b/apps/score/ChangeLog new file mode 100644 index 000000000..5560f00bc --- /dev/null +++ b/apps/score/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/score/README.md b/apps/score/README.md new file mode 100644 index 000000000..1de1ccdb5 --- /dev/null +++ b/apps/score/README.md @@ -0,0 +1,47 @@ +This app will allow you to keep scores for most kinds of sports. + +# Keybinds +To correct a falsely awarded point simply open and close the menu within .5 seconds. This will put the app into correction mode (indicated by the `R`). +In this mode any score increments will be decrements. To move back a set, reduce both players scores to 0, then decrement one of the scores once again. + +## Bangle.js 1 +| Keybinding | Description | +|---------------------|------------------------------| +| `BTN1` | Increment left player score | +| `BTN3` | Increment right player score | +| `BTN2` | Menu | +| touch on left side | Scroll up | +| touch on right side | Scroll down | + +## Bangle.js 2 +| Keybinding | Description | +|-------------------------------------|------------------------------| +| `BTN1` | Menu | +| touch on left side of divider line | Increment left player score | +| touch on right side of divider line | Increment right player score | + +# Settings +| Setting | Description | +|------------------------------------|------------------------------------------------------------------------------------------------------------------------------| +| `Presets` | Enable a preset for one of the configured sports | +| `Sets to win` | How many sets a player has to win before the match is won (Maximum sets: this*2-1) | +| `Sets per page` | How many sets should be shown in the app. Further sets will be available by scrolling (ignored if higher than `Sets to win`) | +| `Score to win` | What score ends a given set | +| `2-point lead` | Does winning a set require a two-point lead | +| `Maximum score?` | Should there be a maximum score, at which point the two-point lead rule falls away | +| `Maximum score` | At which score should the two-point lead rule fall away (ignored if lower than Sets to win) | +| `Tennis scoring` | If enabled, each point in a set will require a full tennis game | +| `TB sets?` | Should sets that have reached `(maxScore-1):(maxScore-1)` be decided with a tiebreak | +| All other options starting with TB | Equivalent to option with same name but applied to tiebreaks | + +The settings can be changed both from within the app by simply pressing `BTN2` (`BTN1` on Bangle.js 2) or in the `App Settings` in the `Settings` app. + +If changes are made to the settings from within the app, a new match will automatically be initialized upon exiting the settings. + +By default the settings will reflect Badminton rules. + +## Tennis Scoring +While tennis scoring is available, correcting in this mode will reset to the beginning of the current game. +Resetting at the beginning of the current game will reset to the beginning of the previous game, leaving the user to fast-forward to the correct score once again. + +This might get changed at some point. diff --git a/apps/score/score.app-icon.js b/apps/score/score.app-icon.js new file mode 100644 index 000000000..b1d4631ba --- /dev/null +++ b/apps/score/score.app-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwxH+AH4A/AH4A/AE2IxAKSCigv/F/4vS44ABB4IECAAoKECgM7AAIJBAgQAFBQguJF6HHEhAvKGAwvy4wPB4wuGBQwdCmgJBmguGBQwvJ0ulF5AKFEgeCwQvIBQqPJ4wuHBQ4lEFw4KHF5IAQFJAALF+vNACYv/F/4v053P64vPxPXAAOJF6vP6wbCF52zCQQAB2YvTDIgvOLoWzMJQvOL6JeCss7spgIF5nPMQgvNCAQEBr4FEd6YvVAowv/F/4v4d9WzCANlndlAgOzF82JFQWJGgWJF8xgDAAReGF8RhDLo4vRABQiHABgv/F/4v/F4owTCgIuZAH4A/AH4A/ADgA==")) diff --git a/apps/score/score.app.js b/apps/score/score.app.js new file mode 100644 index 000000000..3a9b71505 --- /dev/null +++ b/apps/score/score.app.js @@ -0,0 +1,478 @@ +require('Font5x9Numeric7Seg').add(Graphics); +require('Font7x11Numeric7Seg').add(Graphics); +require('FontTeletext5x9Ascii').add(Graphics); + +let settingsMenu = eval(require('Storage').read('score.settings.js')); +let settings = settingsMenu(null, null, true); + +let tennisScores = ['00','15','30','40','DC','AD']; + +let scores = null; +let tScores = null; +let cSet = null; + +let firstShownSet = null; + +let settingsMenuOpened = null; +let correctionMode = false; + +let w = g.getWidth(); +let h = g.getHeight(); + +let isBangle1 = process.env.BOARD === 'BANGLEJS'; + +function getXCoord(func) { + let offset = 40; + return func(w-offset)+offset; +} + +function getSecondsTime() { + return Math.floor(getTime() * 1000); +} + +function setupDisplay() { + // make sure LCD on Bangle.js 1 stays on + if (isBangle1) { + if (settings.keepDisplayOn) { + Bangle.setLCDTimeout(0); + Bangle.setLCDPower(true); + } else { + Bangle.setLCDTimeout(10); + } + } +} + +function setupInputWatchers(init) { + Bangle.setUI('updown', v => { + if (v) { + if (isBangle1) { + let i = settings.mirrorScoreButtons ? v : v * -1; + handleInput(Math.floor((i+2)/2)); + } else { + handleInput(Math.floor((v+2)/2)+3); + } + } + }); + if (init) { + setWatch(() => handleInput(2), isBangle1 != null ? BTN2 : BTN, { repeat: true }); + Bangle.on('touch', (b, e) => { + if (isBangle1) { + if (b === 1) { + handleInput(3); + } else { + handleInput(4); + } + } else { + if (e.x < getXCoord(w => w/2)) { + handleInput(0); + } else { + handleInput(1); + } + } + }); + } +} + +function setupMatch() { + scores = []; + for (let s = 0; s < sets(); s++) { + scores.push([0,0,null,0,0]); + } + scores.push([0,0,null,0,0]); + + if (settings.enableTennisScoring) { + tScores = [0,0]; + } else { + tScores = null; + } + + scores[0][2] = getSecondsTime(); + + cSet = 0; + setFirstShownSet(); + + correctionMode = false; +} + +function showSettingsMenu() { + settingsMenuOpened = getSecondsTime(); + l = null; + settingsMenu(function (s, reset) { + E.showMenu(); + + settings = s; + + if (reset) { + setupMatch(); + } else if (getSecondsTime() - settingsMenuOpened < 500 || correctionMode) { + correctionMode = !correctionMode; + } + + settingsMenuOpened = null; + + draw(); + + setupDisplay(); + setupInputWatchers(); + }, function (msg) { + switch (msg) { + case 'end_set': + updateCurrentSet(1); + break; + } + }); +} + +function maxScore() { + return Math.max(settings.maxScore, settings.winScore); +} + +function tiebreakMaxScore() { + return Math.max(settings.maxScoreTiebreakMaxScore, settings.maxScoreTiebreakWinScore); +} + +function setsPerPage() { + return Math.min(settings.setsPerPage, sets()); +} + +function sets() { + return settings.winSets * 2 - 1; +} + +function currentSet() { + return matchEnded() ? cSet - 1 : cSet; +} + +function shouldTiebreak() { + return settings.enableMaxScoreTiebreak && + scores[cSet][0] + scores[cSet][1] === (maxScore() - 1) * 2; +} + +function formatNumber(num, length) { + return num.toString().padStart(length ? length : 2,"0"); +} + +function formatDuration(duration) { + let durS = Math.floor(duration / 1000); + let durM = Math.floor(durS / 60); + let durH = Math.floor(durM / 60); + durS = durS - durM * 60; + durM = durM - durH * 60; + + durS = formatNumber(durS); + durM = formatNumber(durM); + durH = formatNumber(durH); + + let dur = null; + if (durH > 0) { + dur = durH + ':' + durM; + } else { + dur = durM + ':' + durS; + } + + return dur; +} + +function tiebreakWon(set, player) { + let pScore = scores[set][3+player]; + let p2Score = scores[set][3+~~!player]; + + let winScoreReached = pScore >= settings.maxScoreTiebreakWinScore; + let isTwoAhead = !settings.maxScoreTiebreakEnableTwoAhead || pScore - p2Score >= 2; + let reachedMaxScore = settings.maxScoreTiebreakEnableMaxScore && pScore >= tiebreakMaxScore(); + + return reachedMaxScore || (winScoreReached && isTwoAhead); +} + +function setWon(set, player) { + let pScore = scores[set][player]; + let p2Score = scores[set][~~!player]; + + let winScoreReached = pScore >= settings.winScore; + let isTwoAhead = !settings.enableTwoAhead || pScore - p2Score >= 2; + let tiebreakW = tiebreakWon(set, player); + let reachedMaxScore = settings.enableMaxScore && pScore >= maxScore(); + let manuallyEndedWon = cSet > set ? pScore > p2Score : false; + + return ( + (settings.enableMaxScoreTiebreak ? tiebreakW : reachedMaxScore) || + (winScoreReached && isTwoAhead) || + manuallyEndedWon + ); +} + +function setEnded(set) { + return setWon(set, 0) || setWon(set, 1); +} + +function setsWon(player) { + return Array(sets()).fill(0).map((_, s) => ~~setWon(s, player)).reduce((a,v) => a+v, 0); +} + +function matchWon(player) { + return setsWon(player) >= settings.winSets; +} + +function matchEnded() { + return (matchWon(0) || matchWon(1)) && cSet > (setsWon(0) + setsWon(1) - 1); +} + +function matchScore(player) { + return scores.reduce((acc, val) => acc += val[player], 0); +} + +function setFirstShownSet() { + firstShownSet = Math.max(0, currentSet() - setsPerPage() + 1); +} + +function updateCurrentSet(val) { + if (val > 0) { + cSet++; + } else if (val < 0) { + cSet--; + } else { + return; + } + setFirstShownSet(); + + if (val > 0) { + scores[cSet][2] = getSecondsTime(); + + if (matchEnded()) { + firstShownSet = 0; + } + } +} + +function score(player) { + if (!matchEnded()) { + setFirstShownSet(); + } + + if (correctionMode) { + if ( + scores[cSet][0] === 0 && scores[cSet][1] === 0 && + scores[cSet][3] === 0 && scores[cSet][4] === 0 && + cSet > 0 + ) { + updateCurrentSet(-1); + } + + if (scores[cSet][3] > 0 || scores[cSet][4] > 0) { + if (scores[cSet][3+player] > 0) { + scores[cSet][3+player]--; + } + } else if (scores[cSet][player] > 0) { + if ( + !settings.enableTennisScoring || + (tScores[player] === 0 && tScores[~~!player] === 0) + ) { + scores[cSet][player]--; + } else { + tScores[player] = 0; + tScores[~~!player] = 0; + } + } + } else { + if (matchEnded()) return; + + if (shouldTiebreak()) { + scores[cSet][3+player]++; + } else if (settings.enableTennisScoring) { + if (tScores[player] === 4 && tScores[~~!player] === 5) { // DC : AD + tScores[~~!player]--; + } else if (tScores[player] === 2 && tScores[~~!player] === 3) { // 30 : 40 + tScores[0] = 4; + tScores[1] = 4; + } else if (tScores[player] === 3 || tScores[player] === 5) { // 40 / AD + tScores[0] = 0; + tScores[1] = 0; + scores[cSet][player]++; + } else { + tScores[player]++; + } + } else { + scores[cSet][player]++; + } + + if (setEnded(cSet) && cSet < sets()) { + if (shouldTiebreak()) { + scores[cSet][player]++; + } + updateCurrentSet(1); + } + } +} + +function handleInput(button) { + if (settingsMenuOpened) { + return; + } + + switch (button) { + case 0: + case 1: + score(button); + break; + case 2: + showSettingsMenu(); + return; + case 3: + case 4: + let hLimit = currentSet() - setsPerPage() + 1; + let lLimit = 0; + let val = (button * 2 - 7); + firstShownSet += val; + if (firstShownSet > hLimit) firstShownSet = hLimit; + if (firstShownSet < lLimit) firstShownSet = lLimit; + break; + } + + draw(); +} + +function draw() { + g.setFontAlign(0,0); + g.clear(); + + for (let p = 0; p < 2; p++) { + if (matchWon(p)) { + g.setFontAlign(0,0); + g.setFont('Teletext5x9Ascii',2); + g.drawString( + "WINNER", + getXCoord(w => p === 0 ? w/4 : w/4*3), + 15 + ); + } else if (matchEnded()) { + g.setFontAlign(1,0); + + g.setFont('Teletext5x9Ascii',1); + g.drawString( + (currentSet()+1) + ' set' + (currentSet() > 0 ? 's' : ''), + 40, + 8 + ); + + let dur1 = formatDuration(scores[cSet][2] - scores[0][2]); + g.setFont('5x9Numeric7Seg',1); + g.drawString( + dur1, + 40, + 18 + ); + } + + g.setFontAlign(p === 0 ? -1 : 1,1); + g.setFont('5x9Numeric7Seg',2); + g.drawString( + setsWon(p), + getXCoord(w => p === 0 ? 5 : w-3), + h-5 + ); + + if (!settings.enableTennisScoring) { + g.setFontAlign(p === 0 ? 1 : -1,1); + g.setFont('7x11Numeric7Seg',2); + g.drawString( + formatNumber(matchScore(p), 3), + getXCoord(w => p === 0 ? w/2 - 3 : w/2 + 6), + h-5 + ); + } + } + g.setFontAlign(0,0); + + if (correctionMode) { + g.setFont('Teletext5x9Ascii',2); + g.drawString( + "R", + getXCoord(w => w/2), + h-10 + ); + } + + let lastShownSet = Math.min( + sets(), + currentSet() + 1, + firstShownSet+setsPerPage() + ); + let setsOnCurrentPage = Math.min( + sets(), + setsPerPage() + ); + for (let set = firstShownSet; set < lastShownSet; set++) { + if (set < 0) continue; + + let y = (h-15)/(setsOnCurrentPage+1)*(set-firstShownSet+1)+5; + + g.setFontAlign(-1,0); + g.setFont('7x11Numeric7Seg',1); + g.drawString(set+1, 5, y-10); + if (scores[set+1][2] != null) { + let dur2 = formatDuration(scores[set+1][2] - scores[set][2]); + g.drawString(dur2, 5, y+10); + } + + for (let p = 0; p < 2; p++) { + if (!setWon(set, p === 0 ? 1 : 0) || matchEnded()) { + let bigNumX = getXCoord(w => p === 0 ? w/4-12 : w/4*3+15); + let smallNumX = getXCoord(w => p === 0 ? w/2-2 : w/2+3); + + if (settings.enableTennisScoring && set === cSet && !shouldTiebreak()) { + g.setFontAlign(0,0); + g.setFont('7x11Numeric7Seg',3); + g.drawString( + formatNumber(tennisScores[tScores[p]]), + bigNumX, + y + ); + } else if (shouldTiebreak() && set === cSet) { + g.setFontAlign(0,0); + g.setFont('7x11Numeric7Seg',3); + g.drawString( + formatNumber(scores[set][3+p], 3), + bigNumX, + y + ); + } else { + g.setFontAlign(0,0); + g.setFont('7x11Numeric7Seg',3); + g.drawString( + formatNumber(scores[set][p]), + bigNumX, + y + ); + } + + if ((shouldTiebreak() || settings.enableTennisScoring) && set === cSet) { + g.setFontAlign(p === 0 ? 1 : -1,0); + g.setFont('7x11Numeric7Seg',1); + g.drawString( + formatNumber(scores[set][p]), + smallNumX, + y + ); + } else if ((scores[set][3] !== 0 || scores[set][4] !== 0) && set !== cSet) { + g.setFontAlign(p === 0 ? 1 : -1,0); + g.setFont('7x11Numeric7Seg',1); + g.drawString( + formatNumber(scores[set][3+p], 3), + smallNumX, + y + ); + } + } + } + } + + // draw separator + g.drawLine(getXCoord(w => w/2), 20, getXCoord(w => w/2), h-25); + + g.flip(); +} + +setupDisplay(); +setupInputWatchers(true); +setupMatch(); +draw(); diff --git a/apps/score/score.app.png b/apps/score/score.app.png new file mode 100644 index 000000000..c1e7e2215 Binary files /dev/null and b/apps/score/score.app.png differ diff --git a/apps/score/score.presets.json b/apps/score/score.presets.json new file mode 100644 index 000000000..b57b52157 --- /dev/null +++ b/apps/score/score.presets.json @@ -0,0 +1,30 @@ +{ + "Badminton": { + "winScore": 21, + "enableTwoAhead": true, + "enableMaxScore": true, + "maxScore": 30 + }, + "Tennis": { + "winScore": 6, + "enableTwoAhead": true, + "enableMaxScore": true, + "maxScore": 7, + "enableMaxScoreTiebreak": true, + "maxScoreTiebreakWinScore": 7, + "maxScoreTiebreakEnableTwoAhead": true, + "maxScoreTiebreakEnableMaxScore": false, + "enableTennisScoring": true + }, + "Soccer": { + "winSets": 1, + "winScore": 9999, + "enableTwoAhead": false, + "enableMaxScore": false + }, + "Table Tennis": { + "winScore": 11, + "enableTwoAhead": true, + "enableMaxScore": false + } +} diff --git a/apps/score/score.settings.js b/apps/score/score.settings.js new file mode 100644 index 000000000..88e367821 --- /dev/null +++ b/apps/score/score.settings.js @@ -0,0 +1,219 @@ +(function () { + return (function (back, inApp, ret) { + const isBangle1 = process.env.BOARD === 'BANGLEJS' + + function fillSettingsWithDefaults(settings) { + if (isBangle1) { + if (settings.mirrorScoreButtons == null) { + settings.mirrorScoreButtons = false; + } + if (settings.keepDisplayOn == null) { + settings.keepDisplayOn = true; + } + } + if (settings.winSets == null) { + settings.winSets = 2; + } + if (settings.setsPerPage == null) { + settings.setsPerPage = 5; + } + if (settings.winScore == null) { + settings.winScore = 21; + } + if (settings.enableTwoAhead == null) { + settings.enableTwoAhead = true; + } + if (settings.enableMaxScore == null) { + settings.enableMaxScore = true; + } + if (settings.maxScore == null) { + settings.maxScore = 30; + } + if (settings.enableTennisScoring == null) { + settings.enableTennisScoring = false; + } + + if (settings.enableMaxScoreTiebreak == null) { + settings.enableMaxScoreTiebreak = false; + } + if (settings.maxScoreTiebreakWinScore == null) { + settings.maxScoreTiebreakWinScore = 6; + } + if (settings.maxScoreTiebreakEnableTwoAhead == null) { + settings.maxScoreTiebreakEnableTwoAhead = true; + } + if (settings.maxScoreTiebreakEnableMaxScore == null) { + settings.maxScoreTiebreakEnableMaxScore = false; + } + if (settings.maxScoreTiebreakMaxScore == null) { + settings.maxScoreTiebreakMaxScore = 15; + } + + return settings; + } + + const fileName = 'score.json'; + let settings = require('Storage').readJSON(fileName, 1) || {}; + const offon = ['No', 'Yes']; + + let presetsFileName = 'score.presets.json'; + let presets = require('Storage').readJSON(presetsFileName); + let presetNames = Object.keys(presets); + + let changed = false; + + function save(settings) { + require('Storage').writeJSON(fileName, settings); + } + + function setAndSave(key, value, notChanged) { + if (!notChanged) { + changed = true; + } + settings[key] = value; + if (key === 'winScore' && settings.maxScore < value) { + settings.maxScore = value; + } + save(settings); + } + + settings = fillSettingsWithDefaults(settings); + + if (ret) { + return settings; + } + + const presetMenu = function (appMenuBack) { + let ret = function (changed) { E.showMenu(appMenu(appMenuBack, changed ? 2 : null)); }; + let m = { + '': {'title': 'Score Presets'}, + '< Back': ret, + }; + for (let i = 0; i < presetNames.length; i++) { + m[presetNames[i]] = (function (i) { + return function() { + changed = true; + let mirrorScoreButtons = settings.mirrorScoreButtons; + let keepDisplayOn = settings.keepDisplayOn; + + settings = fillSettingsWithDefaults(presets[presetNames[i]]); + + settings.mirrorScoreButtons = mirrorScoreButtons; + settings.keepDisplayOn = keepDisplayOn; + save(settings); + ret(true); + }; + })(i); + } + + return m; + }; + + const appMenu = function (back, selected) { + let m = {}; + + m[''] = {'title': 'Score Settings'}; + if (selected != null) { + m[''].selected = selected; + } + m['< Back'] = function () { back(settings, changed); }; + m['Presets'] = function () { E.showMenu(presetMenu(back)); }; + if (isBangle1) { + m['Mirror Buttons'] = { + value: settings.mirrorScoreButtons, + format: m => offon[~~m], + onchange: m => setAndSave('mirrorScoreButtons', m, true), + }; + m['Keep display on'] = { + value: settings.keepDisplayOn, + format: m => offon[~~m], + onchange: m => setAndSave('keepDisplayOn', m, true), + } + } + m['Sets to win'] = { + value: settings.winSets, + min:1, + onchange: m => setAndSave('winSets', m), + }; + m['Sets per page'] = { + value: settings.setsPerPage, + min:1, + max:5, + onchange: m => setAndSave('setsPerPage', m), + }; + m['Score to win'] = { + value: settings.winScore, + min:1, + max: 999, + onchange: m => setAndSave('winScore', m), + }; + m['2-point lead'] = { + value: settings.enableTwoAhead, + format: m => offon[~~m], + onchange: m => setAndSave('enableTwoAhead', m), + }; + m['Maximum score?'] = { + value: settings.enableMaxScore, + format: m => offon[~~m], + onchange: m => setAndSave('enableMaxScore', m), + }; + m['Maximum score'] = { + value: settings.maxScore, + min: 1, + max: 999, + onchange: m => setAndSave('maxScore', m), + }; + m['Tennis scoring'] = { + value: settings.enableTennisScoring, + format: m => offon[~~m], + onchange: m => setAndSave('enableTennisScoring', m), + }; + m['TB sets?'] = { + value: settings.enableMaxScoreTiebreak, + format: m => offon[~~m], + onchange: m => setAndSave('enableMaxScoreTiebreak', m), + }; + m['TB Score to win'] = { + value: settings.maxScoreTiebreakWinScore, + onchange: m => setAndSave('maxScoreTiebreakWinScore', m), + }; + m['TB 2-point lead'] = { + value: settings.maxScoreTiebreakEnableTwoAhead, + format: m => offon[~~m], + onchange: m => setAndSave('maxScoreTiebreakEnableTwoAhead', m), + }; + m['TB max score?'] = { + value: settings.maxScoreTiebreakEnableMaxScore, + format: m => offon[~~m], + onchange: m => setAndSave('maxScoreTiebreakEnableMaxScore', m), + }; + m['TB max score'] = { + value: settings.maxScoreTiebreakMaxScore, + onchange: m => setAndSave('maxScoreTiebreakMaxScore', m), + }; + + return m; + }; + + const inAppMenu = function () { + let m = { + '': {'title': 'Score Menu'}, + '< Back': function () { back(settings, changed); }, + 'Reset match': function () { back(settings, true); }, + 'End current set': function () { inApp('end_set'); back(settings, changed); }, + 'Configuration': function () { E.showMenu(appMenu(function () { + E.showMenu(inAppMenu()); + })); }, + }; + + return m; + }; + + if (inApp != null) { + E.showMenu(inAppMenu()); + } else { + E.showMenu(appMenu(back)); + } + + }); +})();