// title: los hermanos bros. // TODO // re-enable music, it doesn't work right now // maybe don't put clouds in actors, why do we need that 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 = [ { id: 'smallcloud', x: 20, y: 32, dx: -1/32, mode: 'drift', counter: 0, flip: false }, { id: 'mario', counter: 0, x: 20, y: 104, dx: 0, dy: 0, mode: 'idle', flip: false, isOnGround: true }, { id: '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 } ] } ] init = state => { state.mode = 'title' state.checks = [] state.counter = 0 state.score = 0 state.lives = 0 state.coins = 0 state.elapsed = 0 state.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 ? [getTile(tileX, tileY)] : [getTile(tileX, tileY), getTile(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 ? [getTile(tileX, tileY)] : [getTile(tileX, tileY), getTile(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.id === '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.id === '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 ? [getTile(floor(x / 8), floor(y / 8) + 1)] : [ getTile(floor(x / 8), floor(y / 8) + 1), getTile(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.id === '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.id === 'smallcloud') const mario = state.actors.find(d => d.id === 'mario') cloud.x += cloud.dx if (cloud.x < -15) { cloud.x = 127 cloud.y = random(6, 54) } } const moveMario = ({ state, input }) => { const mario = state.actors.find(d => d.id === '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, id: actor.id }) }) } else { drawActor(actor) } }) } const drawActor = actor => { const cycle = sprites[actor.id][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.id === '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.id === 'mario') const cloud = state.actors.find(d => d.id === 'smallcloud') if (state.mode === 'play' || state.mode === 'death') { drawTopBar(state) camera() rectFill(0, 8, 128, 120, 6) drawActors({ actors: state.actors.filter(d => d.id === 'smallcloud') }) const cameraX = floor((mario.x + 4) / 128) * 128 camera(cameraX) map() if (mario.x > 128 * 3) { print(5 + 8 * 0, 43, 'This is all there is.', 0) print(5 + 8 * 1, 43 + 9, "There is no more.", 0) print(5 + 8 * 2, 43 + 9 * 2, 'Now it is your turn', 0) print(5 + 8 * 3, 43 + 9 * 3, 'to make a game.', 0) } drawActors({ actors: state.actors.filter(d => d.id !== 'smallcloud') }) } if (state.mode === 'preplay') { drawLivesScreen(state) drawTopBar(state) } if (state.mode === 'gameover') { camera() 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() 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) } }