// title: los hermanos bros. const { PI, atan2, ceil, floor, min, max, sign, abs, round } = Math const rad2Deg = ø => (ø * 180) / PI const spriteSprite = (a, b) => { const r1RightEdgePastR2Left = a.x + 8 >= b.x const r1LeftEdgePastR2Right = a.x <= b.x + 8 const r1TopEdgePastR2Bottom = a.y + 8 >= b.y const r1BottomEdgePastR2Top = a.y <= b.y + 8 const collision = r1RightEdgePastR2Left && r1LeftEdgePastR2Right && r1TopEdgePastR2Bottom && r1BottomEdgePastR2Top // If we had a collision, try to find the side. if (collision) { let side const angle = rad2Deg(atan2(b.y - a.y, a.x - b.x)) if (angle > 30 && angle < 180 - 30) { side = 'top' } else { side = 'other' } return side } else { return false } } const e = { mario: { maxDx: 0.75, maxDy: 4, ddx: 0.25, bounceBonus: 95 } } const sprites = { mario: { idle: [...range(21).map(d => 0), 7], walk: [1, 2, 3, 4, 5, 6], prejump: [8], jump: [9], fall: [10], postfall: [8], dying: [8] }, goomba: { idle: [...range(21).map(d => 11), 12], walk: [13, 14] }, smallcloud: { drift: [[[39, 41], [55, 57]]] } } const fsm = new StateMachine({ init: 'idle', transitions: [ { name: 'leftright', from: 'idle', to: 'walk' }, { name: 'noleftright', from: 'walk', to: 'idle' }, { name: 'a', from: 'idle', to: 'prejump' }, { name: 'a', from: 'walk', to: 'prejump' }, { name: 'sinkholed', from: 'idle', to: 'fall' }, { name: 'sinkholed', from: 'walk', to: 'fall' }, { name: 'launch', from: 'prejump', to: 'jump' }, { name: 'falling', from: 'jump', to: 'fall' }, { name: 'landed', from: 'fall', to: 'postfall' }, { name: 'bounce', from: 'fall', to: 'prejump' }, { name: 'recovered', from: 'postfall', to: 'idle' }, { name: 'touchEnemy', from: 'idle', to: 'dying' }, { name: 'touchEnemy', from: 'walk', to: 'dying' }, { name: 'died', from: 'dying', to: 'idle' } ] }) const initialActors = [ { name: 'smallcloud', x: 20, y: 32, dx: -1, mode: 'drift', counter: 0, flip: false }, { name: 'mario', counter: 0, x: 20, y: 104, dx: 0, dy: 0, mode: 'idle', flip: false, isOnGround: true }, { name: 'goomba', enemy: true, instances: [ { x: 128 * 2 - 8, y: 104, dx: 0, marioX: 120, counter: 0, mode: 'walk', flip: true }, { x: 256 + 32, y: 104, dx: 0, marioX: 256, counter: 0, mode: 'walk', flip: true } ] } ] initialState = { mode: 'title', checks: [], counter: 0, score: 0, lives: 0, coins: 0, elapsed: 0, actors: [...initialActors] } const scanTileV = ({ x, y, dy, up }) => { const start = (up ? floor(y / 8) : ceil(y / 8) + 1) * 8 const end = (up ? floor((y + dy) / 8) : ceil((y + dy) / 8) + 1) * 8 for (let i = start; up ? i >= end : i <= end; i += up ? -8 : 8) { const tileX = floor(x / 8) const tileY = i / 8 const blockingTiles = (x % 8 === 0 ? [tile(tileX, tileY)] : [tile(tileX, tileY), tile(tileX + 1, tileY)] ).filter(d => d && d[8] === 1) if (blockingTiles.length) { return { tile: blockingTiles[0], y: i } } } return null } const scanTileH = ({ x, dx, y, left }) => { const start = (left ? floor(x / 8) : ceil(x / 8) + 1) * 8 const end = (left ? floor((x + dx) / 8) : ceil((x + dx) / 8) + 1) * 8 if (left && end <= 0) { return { x: -8 } } for (let i = start; left ? i >= end : i <= end; i += left ? -8 : 8) { const tileX = i / 8 const tileY = floor(y / 8) const blockingTiles = (y % 8 === 0 ? [tile(tileX, tileY)] : [tile(tileX, tileY), tile(tileX, tileY + 1)] ).filter(d => d && d[8] === 1) if (blockingTiles.length) { return { tile: blockingTiles[0], x: i } } } return null } const moveMarioHorizontally = ({ state, input }) => { const mario = state.actors.find(d => d.name === 'mario') let { x, dx, y } = mario const { maxDx, ddx } = e.mario if (input.left) { dx = clamp(dx - ddx, -maxDx, maxDx) mario.flip = true } else if (input.right) { dx = clamp(dx + ddx, -maxDx, maxDx) mario.flip = false } else { dx = sign(dx) * max(abs(dx) - 0.125, 0) } // Scan left or right and stop at first tile. if (dx < 0) { const blockingTile = scanTileH({ x, dx, y, left: true }) if (blockingTile) { const distanceToCollider = blockingTile.x - x + 8 dx = max(distanceToCollider, dx) } } else if (dx > 0) { const blockingTile = scanTileH({ x, dx, y }) if (blockingTile) { const distanceToCollider = blockingTile.x - x - 8 dx = min(distanceToCollider, dx) } } mario.x = x mario.dx = dx // Move x by dx. mario.x += mario.dx } const moveEnemies = state => { const enemies = state.actors.filter(d => d.enemy) const mario = state.actors.find(d => d.name === 'mario') enemies.forEach(actor => { actor.instances.forEach(d => { if (d.x <= -8) { d.remove = true } if (mario.x >= d.marioX) { d.dx = -0.25 } state.counter % 10 === 0 && d.counter++ d.x += d.dx }) }) } const isOnGround = ({ x, y }) => { // If the y-coordinate isn't at a multiple of 8, // we're obviously not on ground. if (floor(y) % 8 !== 0) { return false } else { const tiles = (x % 8 === 0 ? [tile(floor(x / 8), floor(y / 8) + 1)] : [ tile(floor(x / 8), floor(y / 8) + 1), tile(floor(x / 8) + 1, floor(y / 8) + 1) ] ).filter(d => d && d[8] === 1) return tiles.length } } const collideWithEnemies = ({ state }) => { const mario = state.actors.find(d => d.name === 'mario') const enemies = state.actors.filter(d => d.enemy) // Check if we're colliding with an enemy. enemies.forEach(d => { d.instances .filter(d => !d.remove) .forEach(actor => { const collision = spriteSprite(mario, actor) if (collision) { if (collision === 'top') { state.score += 100 actor.remove = true if (fsm.can('bounce')) { playPhrase(61) fsm.bounce() mario.dy -= e.mario.bounceBonus / 100 } } else { // Handle touching an enemy, which at the moment means: // setting game mode to death, // decreasing lives, // and launching mario in the air. if (fsm.can('touchEnemy')) { fsm.touchEnemy() state.mode = 'death' --state.lives mario.dy = -e.mario.maxDy playSong(1) } } } }) }) } const moveClouds = state => { const cloud = state.actors.find(d => d.name === 'smallcloud') const mario = state.actors.find(d => d.name === 'mario') const cameraX = floor((mario.x + 4) / 128) * 128 cloud.x = cloud.x % 128 log(`after modulus: ${cloud.x}`) cloud.x += cloud.dx log(`after move: ${cloud.x}`) if (cloud.x < -15) { cloud.x = 127 } else { } // log({ cloudX: cloud.x, cameraX }) cloud.x += cameraX log(`after cameraX: ${cloud.x}`) // cloud.y = random(6, 54) // cloud.x += cameraX } const moveMario = ({ state, input }) => { const mario = state.actors.find(d => d.name === 'mario') if (fsm.state === 'idle') { // Move horizontally. moveMarioHorizontally({ state, input }) // Increase mario counter (for spriting). state.counter % 6 === 0 && mario.counter++ // Disallow idling on air. mario.isOnGround = isOnGround(mario) !mario.isOnGround && fsm.can('sinkholed') && fsm.sinkholed() // Handle left / right press. if (input.left || input.right) { fsm.can('leftright') && fsm.leftright() } // Handle jumping. if (input.a && fsm.can('a')) { fsm.a() } } else if (fsm.state === 'walk') { // Move horizontally. moveMarioHorizontally({ state, input }) // Increase mario counter (for spriting). state.counter % 4 === 0 && mario.counter++ // Disallow walking on air. mario.isOnGround = isOnGround(mario) !mario.isOnGround && fsm.can('sinkholed') && fsm.sinkholed() // Handle not pressing left or right. if (!input.left && !input.right) { fsm.can('noleftright') && fsm.noleftright() } // If we're pressing `a`, and we can fire the `a` event, if (input.a && fsm.can('a')) { // fire it. fsm.a() } } else if (fsm.state === 'prejump') { moveMarioHorizontally({ state, input }) // Set jump velocity. mario.dy = -e.mario.maxDy // Go to launch after one cycle here. ++mario.counter === 2 && fsm.can('launch') && fsm.launch() } else if (fsm.state === 'jump') { moveMarioHorizontally({ state, input }) const prevDy = mario.dy // Decrease jump speed. mario.dy = min(mario.dy + 0.25, e.mario.maxDy) // Scan up and stop at first tile. const blockingTile = scanTileV({ ...mario, up: true }) if (blockingTile) { const distanceToCollider = blockingTile.y - mario.y + 8 mario.dy = max(distanceToCollider, mario.dy) } // If we've gone from going up to going down, switch to falling mode. if (prevDy < 0 && mario.dy >= 0) { fsm.can('falling') && fsm.falling() } // Move mario. mario.y += mario.dy } else if (fsm.state === 'fall') { moveMarioHorizontally({ state, input }) // Decrease fall speed. mario.dy = min(mario.dy + 0.25, e.mario.maxDy) // Scan down and stop at first tile. const blockingTile = scanTileV(mario) if (blockingTile) { const distanceToCollider = blockingTile.y - mario.y - 8 mario.dy = min(distanceToCollider, mario.dy) } // Move mario. mario.y += mario.dy // Check if we're on the ground. mario.isOnGround = isOnGround(mario) // If we are, switch to landed mode. mario.isOnGround && fsm.can('landed') && fsm.landed() } else if (fsm.state === 'postfall') { moveMarioHorizontally({ state, input }) mario.dy = 0 ++mario.counter === 3 && fsm.can('recovered') && fsm.recovered() } else if (fsm.state === 'dying') { // Make mario fall. if (state.counter % 3 === 0) { mario.dy = min(mario.dy + 0.25, e.mario.maxDy) mario.y += mario.dy } // Make mario flip. if (state.counter % 3 === 0) { mario.flip = !mario.flip } // If mario goes past the bottom screen, fire died event. if (mario.y % 128 < 10 && fsm.can('died')) { fsm.died() // Reset actors state.actors = [...initialActors] // and reset game counter. state.counter = 0 // If we still have lives left, change mode to preplay. // Otherwise go to gameover. state.mode = state.lives < 1 ? 'gameover' : 'preplay' } } // If we changed states, if (fsm.state !== mario.mode) { // reset counter. mario.counter = 0 } mario.mode = fsm.state } update = (state, input, elapsed) => { state.checks = [] if (state.mode === 'play') { state.elapsed += elapsed state.counter++ moveEnemies(state) moveMario({ state, input }) collideWithEnemies({ state }) moveClouds(state) } else if (state.mode === 'death') { state.counter++ moveMario({ state, input }) moveClouds(state) } else if (state.mode === 'preplay') { if (state.counter++ > 60 * 2) { state.mode = 'play' playSong(0, true) } } else if (state.mode === 'gameover') { if (state.counter++ > 60 * 3) { state.mode = 'title' } } else if (state.mode === 'title') { state.counter++ moveMario({ state, input: {} }) moveClouds(state) if (input.left || input.right || input.a) { state.counter = 0 state.lives = 3 state.mode = 'preplay' } } } drawActors = (state, fade) => { state.actors.forEach(actor => { if (actor.instances) { actor.instances .filter(d => !d.remove) .forEach(d => { drawActor({ ...d, name: actor.name }) }) } else { drawActor(actor) } }) } const drawActor = actor => { const cycle = sprites[actor.name][actor.mode] const step = cycle[actor.counter % cycle.length] if (Array.isArray(step)) { // Here, step is a grid. Let's go over each entry. step.forEach((row, rowIndex) => { row.forEach((cell, col) => { sprite(actor.x + 8 * col, actor.y + 8 * rowIndex, cell, 0, actor.flip) }) }) } else { sprite(actor.x, actor.y, step, 0, actor.flip) } } const drawTopBar = state => { camera(0) rectFill(0, 0, 128, 8, 7) // clear sprite(-1, 1, 0) // mario head print(8, 1, state.lives.toString().padStart(2, '0'), 0) // lives sprite(32, 1, 48) // coin sprite print(39, 1, state.coins.toString().padStart(2, '0'), 0) // coins sprite(64, 1, 64) // time sprite print(71, 1, round(state.elapsed / 1000), 0) // time print(101, 1, state.score.toString().padStart(7, '0'), 0) // score rectFill(0, 6, 128, 8, 7) // clear bottom } const drawLivesScreen = state => { const mario = state.actors.find(d => d.name === 'mario') camera(0) clear() print(52, 52, `world ${ceil(mario.y / 128)}`, 0) sprite(51, 65, 0) // mario print(63, 67, `x ${state.lives.toString().padStart(2, '0')}`, 0) } draw = state => { const mario = state.actors.find(d => d.name === 'mario') const cloud = state.actors.find(d => d.name === 'smallcloud') if (state.mode === 'play' || state.mode === 'death') { drawTopBar(state) const cameraX = floor((mario.x + 4) / 128) * 128 camera(cameraX) rectFill(cameraX, 8, 128, 120, 6) map(floor(cameraX / 8), 0) drawActors(state) log(`when drawn: ${cloud.x}`) } if (state.mode === 'preplay') { drawLivesScreen(state) drawTopBar(state) } if (state.mode === 'gameover') { camera(0) clear() print(48, 62, 'game over', 0) drawTopBar(state) } if (state.mode === 'title') { drawTopBar(state) const cameraX = floor((mario.x + 4) / 128) * 128 camera(cameraX) rectFill(cameraX, 8, 128, 120, 6) map(floor(cameraX / 8), 0) drawActors(state) rectFill(29, 34, 70, 25, 1) rectFill(30, 35, 70, 25, 5) rectFill(30, 35, 69, 24, 3) rectFill(99, 34, 1, 1, 4) rectFill(29, 59, 1, 1, 4) print(35, 40, 'los', 0) print(35, 48, 'hermanos bros.', 0) print(35, 68, 'left / right / a', 3) } }