From a4465cb532d7d6691dcc1d39cd60b96bc41ff7c4 Mon Sep 17 00:00:00 2001 From: Richard de Boer Date: Sun, 14 Mar 2021 22:28:44 +0100 Subject: [PATCH 1/2] new app: gbmusic: Gadgetbridge Music Controls --- apps.json | 21 ++ apps/gbmusic/ChangeLog | 1 + apps/gbmusic/README.md | 38 ++ apps/gbmusic/app.js | 691 ++++++++++++++++++++++++++++++++++ apps/gbmusic/icon.js | 1 + apps/gbmusic/icon.png | Bin 0 -> 725 bytes apps/gbmusic/screenshot.png | Bin 0 -> 6368 bytes apps/gbmusic/screenshot_2.png | Bin 0 -> 6475 bytes apps/gbmusic/settings.js | 38 ++ apps/gbmusic/widget.js | 38 ++ 10 files changed, 828 insertions(+) create mode 100644 apps/gbmusic/ChangeLog create mode 100644 apps/gbmusic/README.md create mode 100644 apps/gbmusic/app.js create mode 100644 apps/gbmusic/icon.js create mode 100644 apps/gbmusic/icon.png create mode 100644 apps/gbmusic/screenshot.png create mode 100644 apps/gbmusic/screenshot_2.png create mode 100644 apps/gbmusic/settings.js create mode 100644 apps/gbmusic/widget.js diff --git a/apps.json b/apps.json index a6f83749d..65d30658c 100644 --- a/apps.json +++ b/apps.json @@ -2971,5 +2971,26 @@ {"name":"stepo.app.js","url":"app.js"}, {"name":"stepo.img","url":"icon.js","evaluate":true} ] +}, +{ "id": "gbmusic", + "name": "Gadgetbridge Music Controls", + "shortName":"Music Controls", + "icon": "icon.png", + "version":"0.01", + "description": "Control the music on your Gadgetbridge-connected phone", + "tags": "tools,bluetooth,gadgetbridge,music", + "type":"app", + "allow_emulator": false, + "readme": "README.md", + "storage": [ + {"name":"gbmusic.app.js","url":"app.js"}, + {"name":"gbmusic.settings.js","url":"settings.js"}, + {"name":"gbmusic.wid.js","url":"widget.js"}, + {"name":"gbmusic.img","url":"icon.js","evaluate":true} + ], + "data": [ + {"name":"gbmusic.json"}, + {"name":"gbmusic.load.json"} + ] } ] diff --git a/apps/gbmusic/ChangeLog b/apps/gbmusic/ChangeLog new file mode 100644 index 000000000..ec66c5568 --- /dev/null +++ b/apps/gbmusic/ChangeLog @@ -0,0 +1 @@ +0.01: Initial version diff --git a/apps/gbmusic/README.md b/apps/gbmusic/README.md new file mode 100644 index 000000000..acb5f5dfe --- /dev/null +++ b/apps/gbmusic/README.md @@ -0,0 +1,38 @@ +# Gadgetbridge Music Controls + +If you have an Android phone with Gadgetbridge, this app allows you to view +and control music playback. + +![Screenshot: playing](screenshot.png) ![Screenshot: paused](screenshot_2.png) + +Download the [latest Gadgetbridge for Android here](https://f-droid.org/packages/nodomain.freeyourgadget.gadgetbridge/). + +## Features + +* Dynamic colors based on Track/Artist/Album name +* Scrolling display for long titles +* Automatic start when music plays +* Time and date display + +## Settings + +The app can automatically load when you play music and close when the music stops. +You can change this under `Settings`->`App/Widget Settings`->`Music Controls`. +(If the app opened automatically, it closes after music has been paused for 5 minutes.) + +## Controls + +### Buttons +* Button 1: Volume up (hold to repeat) +* Button 2: Toggle play/pause, long-press for menu +* Button 3: Volume down (hold to repeat, but remember that holding for too long resets your watch) + +### Touch +* Left: pause/previous song +* Right: next song/resume +* Center: toggle play/pause +* Swipe: next/previous song + +## Creator + +Richard de Boer diff --git a/apps/gbmusic/app.js b/apps/gbmusic/app.js new file mode 100644 index 000000000..ab26c22ee --- /dev/null +++ b/apps/gbmusic/app.js @@ -0,0 +1,691 @@ +/* jshint esversion: 6 */ +/** + * Control the music on your Gadgetbridge-connected phone + **/ +{ + let autoClose = false // only if opened automatically + let state = "" + let info = { + artist: "", + album: "", + track: "", + n: 0, + c: 0, + } + + const screen = { + width: g.getWidth(), + height: g.getHeight(), + center: g.getWidth()/2, + middle: g.getHeight()/2, + } + + const TIMEOUT = 5*1000*60 // auto close timeout: 5 minutes + // drawText defaults + const defaults = { + time: { // top center + color: -1, + font: "Vector", + size: 24, + left: 10, + top: 30, + }, + date: { // bottom center + color: -1, + font: "Vector", + size: 16, + bottom: 26, + center: screen.width/2, + }, + num: { // top right + font: "Vector", + size: 30, + top: 30, + right: 15, + }, + track: { // center above middle + font: "Vector", + size: 40, // maximum size + min_size: 25, // scroll (at maximum size) if this doesn't fit + bottom: (screen.height/2)+10, + center: screen.width/2, + // Smaller interval+step might be smoother, but flickers :-( + interval: 200, // scroll interval in ms + step: 10, // scroll speed per interval + }, + artist: { // center below middle + font: "Vector", + size: 30, // maximum size + middle: (screen.height/2)+17, + center: screen.width/2, + }, + album: { // center below middle + font: "Vector", + size: 20, // maximum size + middle: (screen.height/2)+18, // moved down if artist is present + center: screen.width/2, + }, + // these work a bit different, as they apply to all controls + controls: { + color: "#008800", + highlight: 200, // highlight pressed controls for this long, ms + activeColor: "#ff0000", + size: 20, // icons + left: 10, // for right-side + right: 20, // for left-side (more space because of +- buttons) + top: 30, + bottom: 30, + font: "6x8", // volume buttons + volSize: 2, // volume buttons + }, + } + + class Ticker { + constructor(interval) { + this.i = null + this.interval = interval + this.active = false + } + clear() { + if (this.i) { + clearInterval(this.i) + } + this.i = null + } + start() { + this.active = true + this.resume() + } + stop() { + this.active = false + this.clear() + } + pause() { + this.clear() + } + resume() { + this.clear() + if (this.active && Bangle.isLCDOn()) { + this.tick() + this.i = setInterval(() => {this.tick()}, this.interval) + } + } + } + + /** + * Draw time and date + */ + class Clock extends Ticker { + constructor() { + super(1000) + } + tick() { + g.reset() + const now = new Date + drawText("time", this.text(now)) + drawText("date", require("locale").date(now, true)) + } + text(time) { + const l = require("locale") + const is12hour = (require("Storage").readJSON("setting.json", 1) || {})["12hour"] + if (!is12hour) { + return l.time(time, true) + } + const date12 = new Date(time.getTime()) + const hours = date12.getHours() + if (hours===0) { + date12.setHours(12) + } else if (hours>12) { + date12.setHours(hours-12) + } + return l.time(date12, true)+l.meridian(time) + } + } + + /** + * Update all info every second while fading out + */ + class Fader extends Ticker { + constructor() { + super(defaults.track.interval) // redraw at same speed as scroller + } + tick() { + drawMusic() + } + start() { + this.since = Date.now() + super.start() + } + stop() { + super.stop() + this.since = Date.now() // force redraw at 100% brightness + drawMusic() + this.since = null + } + brightness() { + if (fadeOut.since) { + return Math.max(0, 1-((Date.now()-fadeOut.since)/TIMEOUT)) + } + return 1 + } + } + + /** + * Scroll long track names + */ + class Scroller extends Ticker { + constructor() { + super(defaults.track.interval) + } + tick() { + this.offset += defaults.track.step + this.draw() + } + draw() { + const s = defaults.track + const sep = " " + g.setFont(s.font, s.size) + g.setColor(infoColor("track")) + const text = sep+info.track, + text2 = text.repeat(2), + w1 = g.stringWidth(text), + bottom = screen.height-s.bottom + this.offset = this.offset%w1 + g.setFontAlign(-1, 1) + g.clearRect(0, bottom-s.size, screen.width, bottom) + .drawString(text2, -this.offset, screen.height-s.bottom) + } + start() { + this.offset = 0 + super.start() + } + stop() { + super.stop() + const s = defaults.track, + bottom = screen.height-s.bottom + g.clearRect(0, bottom-s.size, screen.width, bottom) + } + } + + function drawInfo(name, options) { + drawText(name, info[name], Object.assign({ + color: infoColor(name), + size: infoSize(name), + force: fadeOut.active, + }, options)) + } + let oldText = {} + function drawText(name, text, options) { + if (name in oldText && oldText[name].text===text && !(options || {}).force) { + return // nothing to do + } + const s = Object.assign( + // deep clone defaults to prevent them being overwritten with options + JSON.parse(JSON.stringify(defaults[name])), + options || {}, + ) + g.setColor(s.color) + g.setFont(s.font, s.size) + const ax = "left" in s ? -1 : ("right" in s ? 1 : 0), + ay = "top" in s ? -1 : ("bottom" in s ? 1 : 0) + g.setFontAlign(ax, ay) + // drawString coordinates + const x = "left" in s ? s.left : ("right" in s ? screen.width-s.right : s.center), + y = "top" in s ? s.top : ("bottom" in s ? screen.height-s.bottom : s.middle) + // bounding rectangle + const w = g.stringWidth(text), h = g.getFontHeight(), + left = "left" in s ? x : ("right" in s ? x-w : x-w/2), + top = "top" in s ? y : ("bottom" in s ? y-h : y-h/2) + if (name in oldText) { + const old = oldText[name] + // only clear if text/area has changed + if (old.text!==text + || old.left!==left || old.top!==top + || old.w!==w || old.h!==h) { + g.clearRect(old.left, old.top, old.left+old.w, old.top+old.h) + } + } + if (text.length) { + g.drawString(text, x, y) + // remember which rectangle to clear before next draw + oldText[name] = { + text: text, + left: left, top: top, + w: w, h: h, + } + } else { + delete oldText[name] + } + } + + /** + * + * @param text + * @return {number} Maximum font size to make text fit on screen + */ + function fitText(text) { + if (!text.length) { + return Infinity + } + // Vector: make a guess, then shrink/grow until it fits + const getWidth = (size) => g.setFont("Vector", size).stringWidth(text) + , sw = screen.width + let guess = Math.round(sw/(text.length*0.6)) + if (getWidth(guess)===sw) { // good guess! + return guess + } + if (getWidth(guess) target + do { + guess-- + } while(getWidth(guess)>sw) + return guess + } + + /** + * @param name + * @return {number} Font size to use for given info + */ + function infoSize(name) { + if (name==="num") { // fixed size + return defaults[name].size + } + return Math.min( + defaults[name].size, + fitText(info[name]), + ) + } + /** + * @param name + * @return {string} Semi-random color to use for given info + */ + let infoColors = {} + function infoColor(name) { + let h, s, v + if (name==="num") { + // always white + h = 0 + s = 0 + } else { + // complicated scheme to make color depend deterministically on info + // s=1 and hue depends on the text, so we always get a bright color + let text = "" + switch(name) { + case "track": + text = info.track + // fallthrough: also use album+artist + case "album": + text += info.album + // fallthrough: also use artist + case "artist": + text += info.artist + break + default: + text = info[name] + } + if (name in infoColors && infoColors[name].text===text && !fadeOut.active) { + return infoColors[name].color + } + let code = 0 // just the sum of all ascii values of text + text.split("").forEach(c => code += c.charCodeAt(0)) + // dark magic + h = code%360 + s = 1 + } + v = fadeOut.brightness() + const hsv2rgb = (h, s, v) => { + const f = (n) => { + const k = (n+h/60)%6 + return v-v*s*Math.max(Math.min(k, 4-k, 1), 0) + } + return {r: f(5), g: f(3), b: f(1)} + } + const rgb = hsv2rgb(h, s, v) + const f2hex = (f) => ("00"+(Math.round(f*255)).toString(16)).substr(-2) + const color = "#"+f2hex(rgb.r)+f2hex(rgb.g)+f2hex(rgb.b) + infoColors[name] = color + return color + } + + let lastTrack + function drawTrack() { + // we try if we can squeeze this in with a slightly smaller font, but if + // the title is too long we start up the scroller instead + const trackInfo = ([info.artist, info.album, info.n, info.track]).join("-") + if (trackInfo===lastTrack) { + return // already visible + } + if (infoSize("track")0) { + info.num = "#"+info.n + if ("c" in info && info.c>0) { // I've seen { c:-1 } + info.num += "/"+info.c + } + } + } + function drawMusic() { + g.reset() + setNumInfo() + drawInfo("num") + drawTrack() + drawArtistAlbum() + drawControls() + } + let tQuit + function updateMusic() { + // if paused for five minutes, load the clock + // (but timeout resets if we get new info, even while paused) + if (tQuit) { + clearTimeout(tQuit) + } + tQuit = null + if (state!=="play" && autoClose) { + if (state==="stop") { // never actually happens with my phone :-( + load() + } else { // also quit when paused for a long time + tQuit = setTimeout(load, TIMEOUT) + fadeOut.start() + } + } else { + fadeOut.stop() + } + drawMusic() + } + + // create tickers + const clock = new Clock() + const fadeOut = new Fader() + const scroller = new Scroller() + + //////////////////// + // Events + //////////////////// + + // pause timers while screen is off + Bangle.on("lcdPower", on => { + if (on) { + clock.resume() + scroller.resume() + fadeOut.resume() + } else { + clock.pause() + scroller.pause() + fadeOut.pause() + } + }) + + let tLauncher + // we put starting of watches inside a function, so we can defer it until we + // asked the user about autoStart + function startLauncherWatch() { + // long-press: launcher + // short-press: toggle play/pause + setWatch(function() { + if (tLauncher) { + clearTimeout(tLauncher) + } + tLauncher = setTimeout(Bangle.showLauncher, 1000) + }, BTN2, {repeat: true, edge: "rising"}) + setWatch(function() { + if (tLauncher) { + clearTimeout(tLauncher) + tLauncher = null + } + togglePlay() + }, BTN2, {repeat: true, edge: "falling"}) + } + + let tCommand = {} + /** + * Send command and highlight corresponding control + * @param command "play/pause/next/previous/volumeup/volumedown" + */ + function sendCommand(command) { + Bluetooth.println(JSON.stringify({t: "music", n: command})) + // for controlColor + if (command in tCommand) { + clearTimeout(tCommand[command]) + } + tCommand[command] = setTimeout(function() { + delete tCommand[command] + drawControls() + }, defaults.controls.highlight) + drawControls() + } + + // BTN1/3: volume control (with repeat after long-press) + let tVol, volCmd + function volUp() { + volStart("up") + } + function volDown() { + volStart("down") + } + function volStart(dir) { + const command = "volume"+dir + stopVol() + sendCommand(command) + volCmd = command + tVol = setTimeout(repeatVol, 500) + } + function repeatVol() { + sendCommand(volCmd) + tVol = setTimeout(repeatVol, 100) + } + function stopVol() { + if (tVol) { + clearTimeout(tVol) + tVol = null + } + volCmd = null + drawControls() + } + function startVolWatches() { + setWatch(volUp, BTN1, {repeat: true, edge: "rising"}) + setWatch(stopVol, BTN1, {repeat: true, edge: "falling"}) + setWatch(volDown, BTN3, {repeat: true, edge: "rising"}) + setWatch(stopVol, BTN3, {repeat: true, edge: "falling"}) + } + + // touch/swipe: navigation + function togglePlay() { + sendCommand(state==="play" ? "pause" : "play") + } + function startTouchWatches() { + Bangle.on("touch", function(side) { + switch(side) { + case 1: + sendCommand(state==="play" ? "pause" : "previous") + break + case 2: + sendCommand(state==="play" ? "next" : "play") + break + case 3: + togglePlay() + } + }) + Bangle.on("swipe", function(dir) { + sendCommand(dir===1 ? "previous" : "next") + }) + } + ///////////////////// + // Startup + ///////////////////// + // check for saved music state (by widget) to load + g.clear() + global.gbmusic_active = true // we don't need our widget + Bangle.loadWidgets() + Bangle.drawWidgets() + delete (global.gbmusic_active) + + function startEmulator() { + if (typeof Bluetooth==="undefined") { // emulator! + Bluetooth = { + println: (line) => {console.log("Bluetooth:", line)}, + } + // some example info + GB({"t": "musicinfo", "artist": "Some Artist Name", "album": "The Album Name", "track": "The Track Title Goes Here", "dur": 241, "c": 2, "n": 2}) + GB({"t": "musicstate", "state": "play", "position": 0, "shuffle": 1, "repeat": 1}) + } + } + function startWatches() { + startVolWatches() + startLauncherWatch() + startTouchWatches() + } + function start() { + // start listening for music updates + const _GB = global.GB + global.GB = (event) => { + // we eat music events! + switch(event.t) { + case "musicinfo": + info = event + delete (info.t) + break + case "musicstate": + state = event.state + break + default: + // pass on other events + if (_GB) { + setTimeout(_GB, 0, event) + } + return // no drawMusic + } + updateMusic() + } + startWatches() + drawMusic() + clock.start() + startEmulator() + } + + let saved = require("Storage").readJSON("gbmusic.load.json", true) + require("Storage").erase("gbmusic.load.json") + if (saved) { + // autoloaded: load state was saved by widget + info = saved.info + state = saved.state + delete (saved) + autoClose = true + start() + } else { + const s = require("Storage").readJSON("gbmusic.json", 1) || {} + if (!("autoStart" in s)) { + // user opened the app, but has not picked a setting yet + // ask them about autoloading now + E.showPrompt( + "Automatically load\n"+ + "when playing music?\n", + ).then(function(autoStart) { + s.autoStart = autoStart + require("Storage").writeJSON("gbmusic.json", s) + setTimeout(start, 0) + }) + } else { + start() + } + } +} diff --git a/apps/gbmusic/icon.js b/apps/gbmusic/icon.js new file mode 100644 index 000000000..5a83430a9 --- /dev/null +++ b/apps/gbmusic/icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwhC/AH4AihvQCynd7oXThoWBC6YVCC6QVEC6BCDC6QVHC5wWJC/4VHC6oJCC6QSDC6QJFC54JHC5oNIC/4X/BpkNA4IXTCwL0GC5z1EC8JVHIwgXJKpAXOBpAXlBpQJELxgXdBQaONBwyxCaZQ9LdZYXWKpgYNCygA/AGYA==")) diff --git a/apps/gbmusic/icon.png b/apps/gbmusic/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..43d24afa24ee13398e33e5dc1dd2fc1be2b5a3ce GIT binary patch literal 725 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 a3+1QBa$V}sa_XN8z|9-x*DJ0$Km9L_s?FR0 literal 0 HcmV?d00001 diff --git a/apps/gbmusic/screenshot_2.png b/apps/gbmusic/screenshot_2.png new file mode 100644 index 0000000000000000000000000000000000000000..f19f8f42832ebe1ccfe754c023cb72b33df0fb9a GIT binary patch literal 6475 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 VR({+!hjmE;Fu4K0UU>}}`9FUF6_x-1 literal 0 HcmV?d00001 diff --git a/apps/gbmusic/settings.js b/apps/gbmusic/settings.js new file mode 100644 index 000000000..ae8fc5991 --- /dev/null +++ b/apps/gbmusic/settings.js @@ -0,0 +1,38 @@ +/** + * @param {function} back Use back() to return to settings menu + */ +(function(back) { + const SETTINGS_FILE = "gbmusic.json", + storage = require("Storage"), + translate = require("locale").translate + + // initialize with default settings... + let s = { + autoStart: true, + } + // ...and overwrite them with any saved values + // This way saved values are preserved if a new version adds more settings + const saved = storage.readJSON(SETTINGS_FILE, 1) || {} + for(const key in saved) { + s[key] = saved[key] + } + + // creates a function to safe a specific setting, e.g. save('autoStart')(true) + function save(key) { + return function(value) { + s[key] = value + storage.write(SETTINGS_FILE, s) + } + } + + const menu = { + "": {"title": "Music Control"}, + "< Back": back, + "Auto start": { + value: s.autoStart, + format: v => translate(v ? "Yes" : "No"), + onchange: save("autoStart"), + } + } + E.showMenu(menu) +}) diff --git a/apps/gbmusic/widget.js b/apps/gbmusic/widget.js new file mode 100644 index 000000000..1a55490b5 --- /dev/null +++ b/apps/gbmusic/widget.js @@ -0,0 +1,38 @@ +(() => { + if (global.gbmusic_active || !(require("Storage").readJSON("gbmusic.json", 1) || {}).autoStart) { + return + } + + let state, info + function checkMusic() { + if (state!=="play" || !info) { + return + } + // playing music: launch music app + require("Storage").writeJSON("gbmusic.load.json", { + state: state, + info: info, + }) + load("gbmusic.app.js") + } + + const _GB = global.GB + global.GB = (event) => { + // we eat music events! + switch(event.t) { + case "musicinfo": + info = event + delete(info.t) + checkMusic() + break + case "musicstate": + state = event.state + checkMusic() + break + default: + if (_GB) { + setTimeout(_GB, 0, event) + } + } + } +})() From c73a77ce6cac8625e826cac3114f8d51c227944e Mon Sep 17 00:00:00 2001 From: capybara1 <39344016+capybara1@users.noreply.github.com> Date: Wed, 1 Jul 2020 00:03:29 +0200 Subject: [PATCH 2/2] Adding first version of app battleship --- apps.json | 21 ++ apps/battleship/ChangeLog | 1 + apps/battleship/README.md | 18 ++ apps/battleship/battleship-icon.js | 1 + apps/battleship/battleship-icon.png | Bin 0 -> 2024 bytes apps/battleship/battleship-icon.svg | 130 +++++++++++ apps/battleship/battleship.js | 321 ++++++++++++++++++++++++++++ 7 files changed, 492 insertions(+) create mode 100644 apps/battleship/ChangeLog create mode 100644 apps/battleship/README.md create mode 100644 apps/battleship/battleship-icon.js create mode 100644 apps/battleship/battleship-icon.png create mode 100644 apps/battleship/battleship-icon.svg create mode 100644 apps/battleship/battleship.js diff --git a/apps.json b/apps.json index 65d30658c..f3db4bd05 100644 --- a/apps.json +++ b/apps.json @@ -2992,5 +2992,26 @@ {"name":"gbmusic.json"}, {"name":"gbmusic.load.json"} ] +}, +{ + "id": "battleship", + "name":"Battleship", + "icon":"battleship-icon.png", + "version": "0.01", + "readme": "README.md", + "description": "The classic game of battleship", + "tags": "game", + "allow_emulator": true, + "storage": [ + { + "name": "battleship.app.js", + "url": "battleship.js" + }, + { + "name": "battleship.img", + "url": "battleship-icon.js", + "evaluate": true + } + ] } ] diff --git a/apps/battleship/ChangeLog b/apps/battleship/ChangeLog new file mode 100644 index 000000000..5560f00bc --- /dev/null +++ b/apps/battleship/ChangeLog @@ -0,0 +1 @@ +0.01: New App! diff --git a/apps/battleship/README.md b/apps/battleship/README.md new file mode 100644 index 000000000..765692d77 --- /dev/null +++ b/apps/battleship/README.md @@ -0,0 +1,18 @@ +# Battleship + +The classic game of battleship. + +## Usage + +In the beginning, each player is required to place +all ships in his fleet on the field. +Navigation of the cursor is performed using BTN1 and +BTN3 as well as left and right on the touch screen. +To place a ship use BTN2 to initialize a placement +and BTN2 again to complete it. + +In the next phase the players take alternating turns +in trying to hit an opposing ship. + +After a player succeeds in sinking the entire opposing +fleet the game ends. diff --git a/apps/battleship/battleship-icon.js b/apps/battleship/battleship-icon.js new file mode 100644 index 000000000..0878a4b28 --- /dev/null +++ b/apps/battleship/battleship-icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwyBC/AH4A/AH4A/AH4A/AH4AEgeGAIVmAogBHBoRV/LZQBaLf4BK9EMlMMpQBClIJBMf5dM08Utcdh0luABNCIMUpYZBMO5bK1hZPAJYdBMecDswxFhkqktQLrYBEqAlBMI1mXdq5dYpxhqFYsc5pdnAIM16VeuQ1FLs67pAIM9+WP7GHrFN2JhjEYsMlRdtv9Yu34v8YlnNHolmL8GnktQLtkXt3XuwBB/ADBhfIYLq9FimsLtd2+9m21u25hFouQIIq9eLtVu+1eu1myxhIYLi9GtZdps3Wq11u83w94t3WMIZfDmsOL78dhxdo2tOmhZBy/5AIILCYYhfBr0TIopfUswZDLsc+iRRBr2Vp0UL4X2L4teL4JhEAIMD05fYPIfoLstWuk9+dGiZhDu83w+Ys3Wr11MI8UlJfbhkqLsdOqk16U96ZhHAINWqoBBMI8kxZfcpRddmvRLoNGmct2M12RhLqxhKlmsL/ZhDkuRloBBL4JhRL4JhCkhfdlJffAIhhajmLL7cD9BfkuBfCMJlGMJEUhJfcwxflMLFUgenL7sdhxhpnvxp0RnvSMIXzMI89yJFFL7MUpZfnmuxq0xAIbDEMI0k1hdXMJGnL9HRL5BhD+RhBovzHoJfcszBE1hhnovxp0RYoMtyJhG+ck9qhEL7DBIqBhnAIlxMInSNIK9dL5GGhkqLMstyEt2BhJilKXr5hJimsLsdGiABBnvwMIsc5hdjMJXNL7812BfDopfEFoJdnL4VmYc2QXYJdBMoJdC1gxFL8phJhkqktQYr4hBEoJdtMJcD07FdXIWnLuJjGG4xjCpcc9xZPCIMMpZb5YpwBF9EMlMMpQBClIJBC5hd2YpwBWLfZldKf4A/AH4A/AH4A/AH4A/AAo")) diff --git a/apps/battleship/battleship-icon.png b/apps/battleship/battleship-icon.png new file mode 100644 index 0000000000000000000000000000000000000000..514492d2f997c4c9590bb612bb548fcd4489a690 GIT binary patch literal 2024 zcmVP)pF8FWQhbW?9;ba!ELWdL_~cP?peYja~^aAhuUa%Y?FJQ@H12X#qA zK~!jg?V0OuTt^wke{;^>y!P6TlZ|V;*h!1hCTY_owsYs!Nb~}&NNFk|@ej}nMWKR7 zP@xrHN`**`kn#^8p^{dJBFd)*B2`gB61%BLaZOq$w&P2DS$lV#y`3{Nd^mf&tG(H4 zFMJ`-ht=%Nyze~Ed(Oh3Xu!Tf<94BdfA5CBOM!sG-(9aKwkjPGcYu~(V^EbAcDct{_lYJ32<hDMBbD;Z*n+(%~HyFYb2x5AC07yHFol0S);wWjC^Y{B4+IbgcgrZ+&Sy_?2x7yhO;gedP;YfUB*<^J3xiP>?al`d?yK}2Z1{$?%n zWbFb@A8tTr-U0DqA@R0ty9l-SAPlpbbqTbRBxddq9UCqd@+VqD-y?D{UCFi(YZGw# za04p)0fKM1RiXB6Z29t27^b%-;>A$5#rzjHNZcFCS8D#wrqJ_i5wUvMA{eCG-$n2& zfDnT4j$Ycf?XEfM}Eh!A>$AS*}cfRL!os0B5!N9bf>2cl7>O$cI~+IQf-c zS^}Lk_?o#jdNbD|zQ&QRzi@eKrjo5u(Q5?5St=coq4sSwKeWBB!~w!BAznVVpTweM zIbpHyiNoyKb--=*AoV$_%+<;Q7W+=P>%8gnv*ppJ>d6~$$ba|PUgl>r#9|q2Wn)Rj zH@|v<#)gp7NpOf$eJ@n5>G>Hgj1(gSCc>ZvRFt*s$mI=Ywfi3AHvDQsKj$SZ7RV=KuMI}g$r z%smAyj&Q2)v9$}(`lMS7wRhKLxhy1q;>cdU7>hEylq4Ju@}-AEc)hHMkV=9O?AozE zZz%LDYZrii+ATGPyXsE(7Zc??51uh5DJ#nx~q-dsWmqMt}9 z-%6Ow4LQ=jK*kJhX=^H^;a;x3m)BiP<<4GclIpX+~}@Af+r2VIvW2 zY7M(?f-LqvTAX8J3H-GVLK|%lo`9KfCzrCzem&KM zc(NcPmNpm_VrB@CrPY~irhuDD6t|apGps2$$FtJ+YhzmlwvxHPmSLHTxE7^PArX%j zaP!#`C7SV~g%+uhDFDH=y9MLfRX{!McBT8V&i`+x$j z11M>eNkmJ!Yy?(rr)~91!VMBb*v_&l6}GmKxtz$7lKHv2*tX@GUZsrs3Olj*n_Id+ zH(6H!03jbd)M(J+hmOWPVJTH^k8tYlj^D}~X)cuWRY8FIgIh|>jiGg257RIT-V_1( zs4Hg!vy{ZLvP_PDmhWhq=gaykJHo)wFq$g>GMgbWeY<|_frXT0HffW{D(=MWeAJ~} zNvX&0d`dQxbWJXC_UgaaE&xFAmRpRD4Wq1V-2`Y2<1w3&g)B>H8_Tu|UL8dylVogU zu+ZeqO1>%!7#MsX=fn(YrI{c7q+a|1@Ov~(K7|kNTpIn^sSD zu1rsJxI=;$+!KZ(Z|z#rPQUjEn$8ax_9z+&1YV+&$apEGxB(3v0(-eX|!^lH9V z7jO~HbC11GDti>cc7SAb8auU2LrWN8)}FCyEl6YxVri39+G2KOkfpmL`3^!{K*nC? zYIN0PZ0$KhhLvX0LY2|{$AH?)JXGzksu+#U+rV?mbCL4uI80t07eq8ffy^%Ci zaH{VG40Q%Aj;zziCA9t}zZv>~|LGM|F<$R|9LY)N=tJ~F?sl>iN#H7)3z+8lO6fr@ zsQ)!92wvaUjTRkf(>WCoav7ng`Sr)+tTWON@Bj}`9{&NngfDB@zYJCY0000B literal 0 HcmV?d00001 diff --git a/apps/battleship/battleship-icon.svg b/apps/battleship/battleship-icon.svg new file mode 100644 index 000000000..bd23abf25 --- /dev/null +++ b/apps/battleship/battleship-icon.svg @@ -0,0 +1,130 @@ + + + + + + + + + + + + + + image/svg+xml + + + + + + + + + + + + + + + + diff --git a/apps/battleship/battleship.js b/apps/battleship/battleship.js new file mode 100644 index 000000000..3661ef494 --- /dev/null +++ b/apps/battleship/battleship.js @@ -0,0 +1,321 @@ +const FIELD_WIDTH = [11, 11, 15]; // for each phase +const FIELD_HEIGHT = FIELD_WIDTH; +const FIELD_LINE_WIDTH = 2; +const FIELD_MARGIN = 2; +const FIELD_COUNT_X = 10; +const FIELD_COUNT_Y = FIELD_COUNT_X; +const MARGIN_LEFT = 16; +const MARGIN_TOP = 42; +const HEADING_COLOR = ['#FF7070', '#7070FF']; // for each player +const FIELD_LINE_COLOR = '#FFFFFF'; +const FIELD_BG_COLOR_REGULAR = '#808080'; +const FIELD_BG_COLOR_SELECTED = '#FFFFFF'; +const SHIP_COLOR_PLACED = '#507090'; +const SHIP_COLOR_AVAIL = '#204070'; +const STATE_HIT_COLOR = ['#B00000', '#0000B0']; // for each player +const STATE_MISS_COLOR = '#404040'; +const SHIP_CAPS = [ + 1, // Carrier (type 0, size 5) + 2, // Battleship (type 1, size 4) + 3, // Destroyer (type 2, size 3) + 4 // Patrol Boat (type 3, size 2) +]; +const FULL_HITS = SHIP_CAPS.reduce((a, c, i) => a + c*(5 -i), 0); +const INDICATOR_LAYOUT = [ + [0, 1, 1, 3], + [2, 2, 2, 3, 3, 3] +]; +const INDICATORS = INDICATOR_LAYOUT.reduce((a, c, i) => { + let y = FIELD_COUNT_Y + 1 + i; + let x1 = 0; + c.forEach(type => { + let size = 5 - type; + let x2 = x1 + size - 1; + a.push({ "type": type, "position": [x1, y, x2, y] }); + x1 += size; + }); + return a; +}, []).sort((l, r) => (l.type - r.type)*FIELD_COUNT_X*FIELD_COUNT_Y + + (l.position[0] + l.position[1]*FIELD_COUNT_X + - (r.position[0] + r.position[1]*FIELD_COUNT_X))); + +let phase = 0; +let player = 0; +let selected = [-10, -10]; +let to_add = null; +let to_rem = null; +let placements = [[],[]]; +let field_states = [new Array(100).fill(0), new Array(FIELD_COUNT_X*FIELD_COUNT_Y).fill(0)]; +let current = [[0, 0],[0, 0]]; +let behaviours = []; // depending on phase + +function getLeftOffset(x) { + return MARGIN_LEFT + x*(FIELD_WIDTH[phase] + FIELD_MARGIN + 1); +} + +function getTopOffset(y) { + return MARGIN_TOP + y*(FIELD_HEIGHT[phase] + FIELD_MARGIN + 1); +} + +function getFieldState(x, y) { + return field_states[player][x + FIELD_COUNT_X*y]; +} + +function setFieldState(x, y, value) { + field_states[player][x + FIELD_COUNT_X*y] = value; +} + +function updateFieldStates() { + placements.forEach((ps, i) => { + ps.forEach(p => { + let pos = p.position; + for (let x = pos[0]; x <= pos[2]; x++) + for (let y = pos[1]; y <= pos[3]; y++) { + field_states[i][x + FIELD_COUNT_X*y] = 1; + } + }); + }); +} + +function getHitCount() { + return field_states[player].reduce( + (v, state) => state == 3 ? v + 1 : v, + 0); +} + +function drawField(x, y, selected) { + let x1 = getLeftOffset(x); + let y1 = getTopOffset(y); + let x2 = x1 + FIELD_WIDTH[phase]; + let y2 = y1 + FIELD_HEIGHT[phase]; + let field_state = getFieldState(x, y); + g.setColor(selected ? FIELD_BG_COLOR_SELECTED : FIELD_BG_COLOR_REGULAR); + g.fillRect(x1, y1, x2, y2); + g.setColor(FIELD_LINE_COLOR); + g.drawRect(x1, y1, x2, y2); + switch (field_state) { + case 2: + g.setColor(STATE_MISS_COLOR); + g.fillCircle(x1 + FIELD_WIDTH[phase]/2 + 1, y1 + FIELD_HEIGHT[phase]/2 + 1, FIELD_WIDTH[phase]/2 - 3); + break; + case 3: + g.setColor(STATE_HIT_COLOR[player]); + g.fillCircle(x1 + FIELD_WIDTH[phase]/2 + 1, y1 + FIELD_HEIGHT[phase]/2 + 1, FIELD_WIDTH[phase]/2 - 1); + break; + default: + break; + } +} + +function drawFields(x1, y1, x2, y2) { + let l = getLeftOffset(x1); + let t = getTopOffset(y1); + let r = getLeftOffset(x2) + FIELD_WIDTH[phase] + FIELD_MARGIN; + let b = getTopOffset(y2) + FIELD_HEIGHT[phase] + FIELD_MARGIN; + g.clearRect(l, t, r, b); + for (let x = x1; x <= x2; x++) + for (let y = y1; y <= y2; y++) { + drawField(x, y, x == current[player][0] && y == current[player][1]); + } +} + +function drawShip(x1, y1, x2, y2, color) { + g.setColor(color); + let diam = Math.min(FIELD_HEIGHT[phase], FIELD_WIDTH[phase]) - 3; + let rad = diam/2; + let cx1 = getLeftOffset(x1) + FIELD_WIDTH[phase]/2 + 1; + let cy1 = getTopOffset(y1) + FIELD_HEIGHT[phase]/2 + 1; + let cx2 = getLeftOffset(x2) + FIELD_WIDTH[phase]/2 + 1; + let cy2 = getTopOffset(y2) + FIELD_HEIGHT[phase]/2 + 1; + if (x1 == x2) { + g.fillRect(cx1 - rad, cy1, cx1 + rad, cy2); + } else { + g.fillRect(cx1, cy1 - rad, cx2, cy1 + rad); + } + g.fillCircle(cx1, cy1, rad); + g.fillCircle(cx2, cy2, rad); +} + +function hasCollision(pos) { + return placements[player].some( + p => pos[0] <= p.position[2] + && pos[2] >= p.position[0] + && pos[1] <= p.position[3] + && pos[3] >= p.position[1]); +} + +function isAvailable(type) { + let count = placements[player].reduce( + (v, p) => p.type == type ? v + 1 : v, + 0); + return count < SHIP_CAPS[type]; +} + +function determineChanges() { + to_rem = to_add; + to_add = null; + if (selected[0] == current[player][0] && selected[1] == current[player][1]) return; + if (selected[0] == current[player][0]) { + let size = Math.abs(selected[1] - current[player][1]) + 1; + if (size < 2 || size > 5 ) return; + let y1 = Math.min(selected[1], current[player][1]); + let y2 = Math.max(selected[1], current[player][1]); + let pos = [current[player][0], y1, current[player][0], y2]; + let type = 5 - size; + if (!hasCollision(pos) && isAvailable(type)) { + to_add = { "type": type, "position": pos }; + } + } + if (selected[1] == current[player][1]) { + let size = Math.abs(selected[0] - current[player][0]) + 1; + if (size < 2 || size > 5 ) return; + let x1 = Math.min(selected[0], current[player][0]); + let x2 = Math.max(selected[0], current[player][0]); + let pos = [x1, current[player][1], x2, current[player][1]]; + let type = 5 - size; + if (!hasCollision(pos) && isAvailable(type)) { + to_add = { "type": type, "position": pos }; + } + } +} + +function addPlacement(descriptor) { + placements[player].push(descriptor); + placements[player].sort((l, r) => l.type - r.type); +} + +function drawShipPlacements() { + if (to_rem) { + drawFields.apply(null, to_rem.position); + } + placements[player].forEach( + p => drawShip.apply(null, p.position.concat([SHIP_COLOR_PLACED]))); + if (to_add) { + drawShip.apply(null, to_add.position.concat([SHIP_COLOR_PLACED])); + } +} + +function drawShipIndicator() { + let p = to_add + ? placements[player].concat(to_add).sort((l, r) => l.type - r.type) + : placements[player]; + let pi = 0; + INDICATORS.forEach(indicator => { + let color = SHIP_COLOR_AVAIL; + if (pi < p.length && p[pi].type == indicator.type) { + pi += 1; + color = SHIP_COLOR_PLACED; + } + drawShip.apply(null, indicator.position.concat(color)); + }); +} + +function drawHeading(text) { + g.clearRect(0, 20, 100, 32); + g.setColor(HEADING_COLOR[player]); + g.setFont('4x6', 2.8); + g.drawString(text, MARGIN_LEFT, 20); +} + +function reset() { + g.clear(); + drawHeading('Player ' + (player + 1)); + drawFields(0, 0, 9, 9); +} + +function showResults() { + let text1 = 'Player ' + (player + 1) + ' won!'; + let text2 = 'Congratulations!'; + g.clear(); + g.clearRect(0, 20, 100, 32); + g.setColor(HEADING_COLOR[player]); + g.setFont('Vector', 20); + g.drawString(text1, MARGIN_LEFT, 80); + g.drawString(text2, MARGIN_LEFT, 120); +} + +function moveSelection(dx, dy) { + let x = current[player][0]; + let y = current[player][1]; + drawField(x, y, false); + current[player][0] = x = (x + dx + FIELD_COUNT_X)%FIELD_COUNT_X; + current[player][1] = y = (y + dy + FIELD_COUNT_Y)%FIELD_COUNT_Y; + drawField(x, y, true); +} + +behaviours.push({ + "move": (dx, dy) => { + moveSelection(dx, dy); + determineChanges(); + drawShipPlacements(); + drawShipIndicator(); + }, + "action": _ => { + if (to_add) { + addPlacement(to_add); + to_add = null; + selected = [-10, -10]; + if (placements[player].length == 10) { + behaviours[phase].transition(); + } + } else { + selected = [current[player][0], current[player][1]]; + } + }, + "transition": _ => { + current[0] = [0, 0]; + player = 1; + phase = 1; + reset(); + drawShipIndicator(); + } +}); + +behaviours.push({ + "move": behaviours[0].move, + "action": behaviours[0].action, + "transition": _ => { + current[1] = [0, 0]; + player = 0; + phase = 2; + updateFieldStates(); + reset(); + } +}); + +behaviours.push({ + "move": (dx, dy) => moveSelection(dx, dy), + "action": _ => { + let x = current[player][0]; + let y = current[player][1]; + let field_state = getFieldState(x, y); + if (field_state > 1) return; + setFieldState(x, y, field_state + 2); + drawField(x, y, true); + Bangle.buzz(200 + field_state*800, 0.5 + field_state*0.5); + if (getHitCount() < FULL_HITS) { + player = (player + 1)%2; + setTimeout(reset, 1000); + } else { + setTimeout(behaviours[phase].transition, 1000); + } + }, + "transition": _ => { + phase = 3; + showResults(); + } +}); + +behaviours.push({ + "move": _ => {}, + "action": _ => {} +}); + +reset(); +drawShipIndicator(); + +setWatch(_ => behaviours[phase].move(0, -1), BTN1, {repeat: true, debounce: 100}); +setWatch(_ => behaviours[phase].move(0, 1), BTN3, {repeat: true, debounce: 100}); +setWatch(_ => behaviours[phase].move(-1, 0), BTN4, {repeat: true, debounce: 100}); +setWatch(_ => behaviours[phase].move(1, 0), BTN5, {repeat: true, debounce: 100}); +setWatch(_ => behaviours[phase].action(), BTN2, {repeat: true, debounce: 100});