From 66264ea61c300564f433a87515b6d982e2b9f79c Mon Sep 17 00:00:00 2001 From: Richard de Boer Date: Sun, 26 Apr 2020 03:29:45 +0200 Subject: [PATCH] ballmaze game: Navigate a ball through a maze using the accelerometer --- apps.json | 16 ++ apps/ballmaze/README.md | 15 + apps/ballmaze/app.js | 514 ++++++++++++++++++++++++++++++++++ apps/ballmaze/icon.js | 1 + apps/ballmaze/icon.png | Bin 0 -> 444 bytes apps/ballmaze/maze.png | Bin 0 -> 2850 bytes apps/ballmaze/size_select.png | Bin 0 -> 4409 bytes 7 files changed, 546 insertions(+) create mode 100644 apps/ballmaze/README.md create mode 100644 apps/ballmaze/app.js create mode 100644 apps/ballmaze/icon.js create mode 100644 apps/ballmaze/icon.png create mode 100644 apps/ballmaze/maze.png create mode 100644 apps/ballmaze/size_select.png diff --git a/apps.json b/apps.json index 6d67d8b40..9652b3e19 100644 --- a/apps.json +++ b/apps.json @@ -1482,5 +1482,21 @@ {"name":"pong.app.js","url":"app.js"}, {"name":"pong.img","url":"app-icon.js","evaluate":true} ] + }, + { "id": "ballmaze", + "name": "Ball Maze", + "icon": "icon.png", + "version": "0.01", + "description": "Navigate a ball through a maze by tilting your watch.", + "readme": "README.md", + "tags": "game", + "type": "app", + "storage": [ + {"name": "ballmaze.app.js","url":"app.js"}, + {"name": "ballmaze.img","url":"icon.js","evaluate": true} + ], + "data": [ + {"name": "ballmaze.json"} + ] } ] diff --git a/apps/ballmaze/README.md b/apps/ballmaze/README.md new file mode 100644 index 000000000..22a295686 --- /dev/null +++ b/apps/ballmaze/README.md @@ -0,0 +1,15 @@ +# Ball Maze + +Navigate a ball through a maze by tilting your watch. + +![Screenshot](size_select.png) +![Screenshot](maze.png) + +## Usage + +Select a maze size to begin the game. +Tilt your watch to steer the ball towards the target and advance to the next level. + +## Creator + +Richard de Boer diff --git a/apps/ballmaze/app.js b/apps/ballmaze/app.js new file mode 100644 index 000000000..b249d6494 --- /dev/null +++ b/apps/ballmaze/app.js @@ -0,0 +1,514 @@ +(() => { + let intervalID; + let settings = require("Storage").readJSON("ballmaze.json") || {}; + + // density, elasticity of bounces, "drag coefficient" + const rho = 100, e = 0.3, C = 0.01; + // screen width & height in pixels + const sW = 240, sH = 160; + // gravity constant (lowercase was already taken) + const G = 9.80665; + + // wall bit flags + const TOP = 1<<0, LEFT = 1<<1, BOTTOM = 1<<2, RIGHT = 1<<3, + LINKED = 1<<4; // used in maze generation + + // The play area is 240x160, sizes are the ball radius, so we can use common + // denominators of 120x80 to get square rooms + // Reverse the order to show the easiest on top of the menu + const sizes = [1, 2, 4, 5, 8, 10, 16, 20, 40].reverse(), + // even size 1 actually works, but larger mazes take forever to generate + minSize = 4, defaultSize = 10; + const sizeNames = { + 1: "Insane", 2: "Gigantic", 4: "Enormous", 5: "Huge", 8: "Large", + 10: "Medium", 16: "Small", 20: "Tiny", 40: "Trivial", + }; + + /** + * Draw something to all screen buffers + * @param draw {function} Callback which performs the drawing + */ + function drawAll(draw) { + draw(); + g.flip(); + draw(); + g.flip(); + } + + /** + * Clear all buffers + */ + function clearAll() { + drawAll(() => g.clear()); + } + + // use unbuffered graphics for UI stuff + function showMessage(message, title) { + Bangle.setLCDMode(); + return E.showMessage(message, title); + } + + function showPrompt(prompt, options) { + Bangle.setLCDMode(); + return E.showPrompt(prompt, options); + } + + function showMenu(menu) { + Bangle.setLCDMode(); + return E.showMenu(menu); + } + + const sign = (n) => n<0?-1:1; // we don't really care about zero + + /** + * Play the game, using a ball with radius size + * @param size {number} + */ + function playMaze(size) { + const r = size; + // ball mass, weight, "drag" + // Yes, larger maze = larger ball = heavier ball + // (atm our physics is so oversimplified that mass cancels out though) + const m = rho*(r*r*r), w = G*m, d = C*w; + + // number of columns/rows + const cols = Math.round(sW/(r*2.5)), + rows = Math.round(sH/(r*2.5)); + // width & height of one column/row in pixels + const cW = sW/cols, rH = sH/rows; + + // list of rooms, every room can have one or more wall bits set + // actual layout: 0 1 2 + // 3 4 5 + // this means that for room with index "i": (except edge cases!) + // i-1 = room to the left + // i+1 = room to the right + // i-cols = room above + // i+cols = room below + let rooms = new Uint8Array(rows*cols); + // shortest route from start to finish + let route; + + let x, y, // current position + px, py, ppx, ppy, // previous positions (for erasing old image) + vx, vy; // velocity + + function start() { + // start in top left corner + x = cW/2; + y = rH/2; + vx = vy = 0; + ppx = px = x; + ppy = py = y + + clearWatch(); + generateMaze(); // this shows unbuffered progress messages + if (settings.cheat && r>1) findRoute(); // not enough memory for r==1 :-( + + Bangle.setLCDMode("doublebuffered"); + clearAll(); + drawAll(drawMaze); + intervalID = setInterval(tick, 100); + } + + // Position conversions + // index: index of room in rooms[] + // rowcol: position measured in roomsizes + // xy: position measured in pixels + /** + * Index from RowCol + * @param row {number} + * @param col {number} + * @returns {number} rooms[] index + */ + function iFromRC(row, col) { + return row*cols+col; + } + + /** + * RowCol from index + * @param index {number} + * @returns {(number)[]} [row,column] + */ + function rcFromI(index) { + return [ + Math.floor(index/cols), + index%cols, + ]; + } + + /** + * RowCol from Xy + * @param x {number} + * @param y {number} + * @returns {(number)[]} [row,column] + */ + function rcFromXy(x, y) { + return [ + Math.floor(y/sH*rows), + Math.floor(x/sW*cols), + ]; + } + + /** + * Link another room up + * @param index {number} Dig from already linked room with this index + * @param dir {number} in this direction + * @return {number} index of room we just linked up + */ + function dig(index, dir) { + rooms[index] &= ~dir; + let neighbour; + switch(dir) { + case LEFT: + neighbour = index-1; + rooms[neighbour] &= ~RIGHT; + break; + case RIGHT: + neighbour = index+1; + rooms[neighbour] &= ~LEFT; + break; + case TOP: + neighbour = index-cols; + rooms[neighbour] &= ~BOTTOM; + break; + case BOTTOM: + neighbour = index+cols; + rooms[neighbour] &= ~TOP; + break; + } + rooms[neighbour] |= LINKED; + return neighbour; + } + + /** + * Generate the maze + */ + function generateMaze() { + // Maze generation basically works like this: + // 1. Start with all rooms set to completely walled off and "unlinked" + // 2. Then mark a room as "linked", and add it to the "to do" list + // 3. When the "to do" list is empty, we're done + // 4. pick a random room from the list + // 5. if all adjacent rooms are linked -> remove room from list, goto 3 + // 6. pick a random unlinked adjacent room + // 7. remove the walls between the rooms + // 8. mark the adjacent room as linked and add it to the "to do" list + // 9. go to 4 + let pdotnum = 0; + const title = "Please wait", + message = "Generating maze\n", + showProgress = (done, total) => { + const dotnum = Math.floor(done/total*10); + if (dotnum>pdotnum) { + const dots = ".".repeat(dotnum)+" ".repeat(10-dotnum); + showMessage(message+dots, title); + pdotnum = dotnum; + } + }; + showProgress(0, 100); + // start with all rooms completely walled off + rooms.fill(TOP|LEFT|BOTTOM|RIGHT); + const + // is room at row,col already linked? + linked = (row, col) => !!(rooms[iFromRC(row, col)]&LINKED), + // pick random array element + pickRandom = (arr) => arr[Math.floor(Math.random()*arr.length)]; + // starting with top-right room seems to generate more interesting mazes + rooms[cols] |= LINKED; + let todo = [cols], done = 1; + while(todo.length) { + const index = pickRandom(todo); + const rc = rcFromI(index), + row = rc[0], col = rc[1]; + let sides = []; + if ((col>0) && !linked(row, col-1)) sides.push(LEFT); + if ((col0) && !linked(row-1, col)) sides.push(TOP); + if ((row0 && !(walls&LEFT) && dist[i-1]>d+1) { + dist[i-1] = d+1; + todo.push(i-1); + } + if (row>0 && !(walls&TOP) && dist[i-cols]>d+1) { + dist[i-cols] = d+1; + todo.push(i-cols); + } + if (cold+1) { + dist[i+1] = d+1; + todo.push(i+1); + } + if (rowd+1) { + dist[i+cols] = d+1; + todo.push(i+cols); + } + } + + route = [rooms.length-1]; + while(true) { + const i = route[0], d = dist[i], walls = rooms[i], + rc = rcFromI(i), + row = rc[0], col = rc[1]; + if (i===0) { break; } + if (col0 && !(walls&TOP) && dist[i-cols]0 && !(walls&LEFT) && dist[i-1] { + const rc = rcFromI(i), + row = rc[0], col = rc[1], + x = (col+0.5)*cW, y = (row+0.5)*rH; + g.lineTo(x, y); + }); + } + + /** + * Move the ball + */ + function move() { + const a = Bangle.getAccel(); + const fx = (-a.x*w)-(sign(vx)*d*a.z), fy = (-a.y*w)-(sign(vy)*d*a.z); + vx += fx/m; + vy += fy/m; + const s = Math.ceil(Math.max(Math.abs(vx), Math.abs(vy))); + for(let n = s; n>0; n--) { + x += vx/s; + y += vy/s; + bounce(); + } + if (x>sW-cW+r && y>sH-rH+r) win(); + } + + /** + * Check whether we hit any walls, and if so: Bounce. + * + * Bounce = reverse velocity in bounce direction, multiply with elasticity + * Also apply drag in perpendicular direction ("friction with the wall") + */ + function bounce() { + const row = Math.floor(y/sH*rows), col = Math.floor(x/sW*cols), + i = row*cols+col, walls = rooms[i]; + if (vx<0) { + const left = col*cW+r; + if ((walls&LEFT) && x<=left) { + x += (1+e)*(left-x); + const fy = sign(vy)*d*Math.abs(vx); + vy -= fy/m; + vx = -vx*e; + } + } else { + const right = (col+1)*cW-r; + if ((walls&RIGHT) && x>=right) { + x -= (1+e)*(x-right); + const fy = sign(vy)*d*Math.abs(vx); + vy -= fy/m; + vx = -vx*e; + } + } + if (vy<0) { + const top = row*rH+r; + if ((walls&TOP) && y<=top) { + y += (1+e)*(top-y); + const fx = sign(vx)*d*Math.abs(vy); + vx -= fx/m; + vy = -vy*e; + } + } else { + const bottom = (row+1)*rH-r; + if ((walls&BOTTOM) && y>=bottom) { + y -= (1+e)*(y-bottom); + const fx = sign(vx)*d*Math.abs(vy); + vx -= fx/m; + vy = -vy*e; + } + } + } + + /** + * You reached the bottom-right corner, you win! + */ + function win() { + clearInterval(intervalID); + Bangle.buzz().then(askAgain); + } + + /** + * You solved the maze, try the next one? + */ + function askAgain() { + const nextLevel = (size>minSize)?"next level":"again"; + const nextSize = (size>minSize)?sizes[sizes.indexOf(size)+1]:size; + showPrompt(`Well done!\n\nPlay ${nextLevel}?`, + {"title": "Congratulations!"}) + .then(function(again) { + if (again) { + playMaze(nextSize); + } else { + startGame(); + } + }); + } + + function tick() { + ppx = px; + ppy = py; + px = x; + py = y; + move(); + drawUpdate(); + } + + start(); + } + + /** + * Ask player what size maze they would like to play + */ + function startGame() { + let menu = { + "": { + title: "Select Maze Size", + selected: sizes.indexOf(settings.size || defaultSize), + }, + }; + sizes.filter(s => s>=minSize).forEach(size => { + let name = sizeNames[size]; + if (size { + // remember chosen size + settings.size = size; + require("Storage").write("ballmaze.json", settings); + playMaze(size); + }; + }); + menu["< Exit"] = () => load(); + showMenu(menu); + } + + startGame(); +})(); diff --git a/apps/ballmaze/icon.js b/apps/ballmaze/icon.js new file mode 100644 index 000000000..10b5a502e --- /dev/null +++ b/apps/ballmaze/icon.js @@ -0,0 +1 @@ +require("heatshrink").decompress(atob("mEwwhC/AH4AU9wAOCw0OC5/gFyowHC+Hs5gACC7HhiMRjwXSCoIADC5wCB4MSkIXDGIoXKiUikQwJC5PhCwIXFGAgXJFwRHEGAnOC5HhC5IwC5gXJIw4XF4AXKFwwXEGAoXCiKlFMAzNCgDpDC4QAKcgZJBC6wADF6kAhgXP5xfEC58SC4iNCC4nhC5McC4S/DC6a9DC4IACC5MhC4XOC5HuLxPMC4PuC5IwHkUeC44ABA4IACFw5cBC5owEkUhjwXPGAyMCC5wxDLgIACC54ADC94AGC7sOCx/gC4owQCwwA/AH4AMA")) diff --git a/apps/ballmaze/icon.png b/apps/ballmaze/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..44697db4b75e49c76f668a4da1275b040f1d6fd7 GIT binary patch literal 444 zcmV;t0Ym4UUAPlroCJC$1Svpf^DXX-r@I8EuYDFP7;1rO2 zQeP^R-1+hGBB5-u_oCr624NNa!kt}*6zh+GX3n<0y z77qV^KI8A&C@o@%1NRV$UR)|ht%%q3{q4zz5FiMJ=Xyj_;3`>vEr zAOGRLg@Rs%ygP%_Ou0Mekl)-Ium z2h$Y%LDzIM<$KYAM%vCa(uZruYsycoDhIUEh?38x4|i0pPN7y2v~XLV)qF2hvoocU z-^k|L2s$mUcVn(3EKzbwYXoEN$y)sby6#WCcip|#ep&mo_x?Wn z`@`PPrvZF#UF}`k6beO`=i_;l?1^_3)+AfbvAGhmgHk+rfiPJ_aLfe?MSGOz=^iKy zUzl`DKJm}(e?Mn!j-shJMNWs2lh5ezPSDi!5E=uY-?}>sHpX>~^yrt7=I2YywPEGT zT$(Iwq57*6uac`C$U3INs|7vx2Zw`#f}C19MW}>piY0&seE%L;WX6s7h+9)m!3qoN z(ja)B2E2`lCjCM*U7}#8C|DE-y=Q!Vh=De4K?D3Gc-P~BgCKMTfG#5RM?ztUw*+^P zaQ6eC0ZlLu(}OkXR+e1F^uOYF17HsY?1JDEZ1&gY+=!ij7LiArZ!*;1VgLXgL?)1p z6;;6xi;2=)<+vbdO8sl--_*Oj6@g&OVmM5Gm7~+@T6H<|F&e5RMKOL=87vH1@P4j^D5GF~tn1oWt_< z*{QVymPwIIO``2!AvJe-p^~MQyD{}Nt`a4V$KHZLZ-l*FJ;b_+ClnPasWy;bY8>QZ4(XgO~11;04$bDW8p$UBM?J#`9L#G4kAY zwX+YoT4c*bk3^K=1^fy8r%bC$xDDt{4-MtB)Ii$FQ zXV_J`saRW2YRO}*`0f$bfSQ?LZ9PvPF~z#ThPj-(4QjdaWsgt7m(dFr3}Vvfs-wSb zsf$6V@RJkITnP&X>7Pnt{Zpf>j#;u~1o{ioHmtScLWHw!H8=ub9dfsrdzpgeCzW}j z{t--!W_NDDq}$}@TtwBf%$sF}xpj$|*g(?Qe4Sf?U|6s`tnN9FT;osWt<6ckoVOD` zxnZ%!A_64+l>>nPaN_L1M5m#%YdiI~fxO$l;|vWN)vDcP_m8QnbWWd2iVX~e2DsS< zw{GTn8-?5yJNLLIuP0cte4U?>FmhMNBN*4KlZPEErE_Tp_`2CZ-g>+zSUGNVH&TIX!e)2tTwz_)< z-IdMQ8>VSc%b*`9!}*z#%$f<+!?un2SvH6w>+Lm%(SZF;IJGRq#{6iD#=_+YaoodlTuOrAbP6FuC* zX3a6aMMMYIPM$S&BHBioznin{#ZzSdY&R1F$_!t{VYAJO7>Inel5(VPV{0YtFlt{+ z!Dhe7Z~z}eFuN!KfMysd!Cv8eMUd%J0mDXuvq92xc{jX;i4yi6RC+m?9xac2xD$&6 zA!DyQ6mXfmmvJ`B+@4IO9>e|Dl2ZB&jK*Tw7Baet8lU@q%FyHf@M?~Jg0gi3qu>b%7 literal 0 HcmV?d00001 diff --git a/apps/ballmaze/size_select.png b/apps/ballmaze/size_select.png new file mode 100644 index 0000000000000000000000000000000000000000..cac278820c92c15dce2a7897279c0299fe50b84a GIT binary patch literal 4409 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 Pg+ToG1^HHalhXbJ%Igu= literal 0 HcmV?d00001