S = 20 TS = 24 M = 30 E = 40 NUM_HISCORES = 5 PLAYER_SPEED_CONCRETE = 10 PLAYER_SPEED_GRASS = 5 LEVELS = [ { goats: 0 hippies: 1 goatSpeed: 10 hippieSpeed: 14 goatRandomInterval: 30 hippieRandomInterval: 10 }, { goats: 1 hippies: 1 goatSpeed: 10 hippieSpeed: 14 goatRandomInterval: 20 hippieRandomInterval: 10 }, { goats: 1 hippies: 2 goatSpeed: 10 hippieSpeed: 14 goatRandomInterval: 15 hippieRandomInterval: 10 }, { goats: 2 hippies: 2 goatSpeed: 10 hippieSpeed: 14 goatRandomInterval: 15 hippieRandomInterval: 10 }, ] TIPS = [ 'Tip: Fill large areas at once for more points!', 'Tip: Keep in mind that you walk slower on grass!', 'Tip: Fill 80% or eliminate all pests to win!', 'Tip: Pests still alive at the end don\'t score you any points!', 'Tip: Keep food out of reach of goats. They are bold and eat anything!', ] window.requestAnimationFrame = window.requestAnimationFrame || window.webkitRequestAnimationFrame || window.mozRequestAnimationFrame || window.oRequestAnimationFrame || window.msRequestAnimationFrame || (callback, element) -> window.setTimeout(callback, 1000/60) window.localStorage = window.localStorage || {} levelIndex = null maxLevel = null level = null game = null hiscores = null paused = false music = null images = entities: null tiles: null background: null carpark: null concrete: 'src/concrete.png' sounds = win: 0.5 lose: 0.7 hippie_bounce: 0.2 hippie_die: 0.5 goat_bounce: 0.4 goat_die: 1.0 pour: 1.0 tape: 0.2 loadAssets = (onDone) -> remaining = 0 for key, value of images remaining++ im = new Image() im.src = if value then value else "#{key}.png" im.onload = -> remaining-- if remaining == 0 onDone() images[key] = im for key, value of sounds sounds[key] = new Sound(key, value) return GRASS = 0 TAPE = 1 CONCRETE = 2 LEFT = 4 RIGHT = 5 class Sound constructor: (id, volume) -> @volume = volume || 1 @audio = null $.ajax( url: id + ".wav.base64" dataType: "text" success: (data) => @src = "data:audio/wav;base64," + data @createAudio() error: (jqXHR, textStatus, errorThrown) -> console.error("Failed to load " + jqXHR.url + ": " + textStatus + " " + errorThrown) ) createAudio: -> @audio = new Audio(@src) @audio.preload = "auto" @audio.volume = @volume play: -> return unless @audio return unless Sound.enabled @audio.play() @createAudio() Sound.enabled = true class Music constructor: (sources, volume, length) -> @musics = [] for i in [0, 1] music = $("") for source in sources $("").appendTo(music) music = music[0] music.addEventListener('ended', => @musics[@current].currentTime = 0 @current = 1 - @current @musics[@current].play() ) music.volume = volume @musics[i] = music @current = 0 @length = length @playing = false play: -> @musics[@current].play() @playing = true pause: -> @musics[@current].pause() @playing = false class Entity constructor: (x, y, tx, ty) -> @x = x @y = y @tx = tx @ty = ty @frameCount = 1 @frameTime = 0 @frame = 0 @remainingFrameTime = 0 update: (dt) -> if @frameCount > 1 && @frameTime > 0 @remainingFrameTime += dt while @remainingFrameTime > @frameTime @remainingFrameTime -= @frameTime @frame++ if @frame >= @frameCount @frame = 0 randomFrame: -> @remainingFrameTime = Math.random() * @frameTime @frame = Math.floor(Math.random() * @frameCount) draw: -> game.context.save() game.context.translate(S * (@x + 0.5), S * (@y + 1)) if @flip && @dx < 0 game.context.scale(-1, 1) game.context.drawImage(images.entities, E*(@tx + @frame), E*@ty, E, E, -E / 2, -E + 2, E, E) game.context.restore() class Enemy extends Entity constructor: (x, y, tx, ty, speed, randomInterval, killScore) -> super(x, y, tx, ty) @speed = speed @randomInterval = randomInterval @nextRandom = Math.random() * (0.5 + Math.random()) * randomInterval @killScore = killScore @pickDirection() pickDirection: -> @dx = 0 @dy = 0 update: (dt) -> return if game.ended super(dt) return if @dead if @randomInterval > 0 @nextRandom -= dt if @nextRandom < 0 count = 0 dx = @dx dy = @dy while count < 10 && dx == @dx && dy == @dy @pickDirection(true) count++ @nextRandom = (0.5 + Math.random()) * @randomInterval nextX = Math.round(@x) + 0.5 * @dx nextY = Math.round(@y) + 0.5 * @dy ttNextX = Math.max(0, (nextX - @x) / (@dx * @speed)) ttNextY = Math.max(0, (nextY - @y) / (@dy * @speed)) ttNextX = 1e99 if @dx == 0 ttNextY = 1e99 if @dy == 0 if game.getFloatTile(@x + dt * @speed * @dx, @y + dt * @speed * @dy) == TAPE game.lose() return if ttNextX <= dt + 1e-5 && ttNextY <= dt + 1e-5 bounceX = game.isEdge(@x, @y, @dx, 0) || game.isEdge(@x, @y + @dy, @dx, 0) bounceY = game.isEdge(@x, @y, 0, @dy) || game.isEdge(@x + @dx, @y, 0, @dy) else if ttNextX <= dt + 1e-5 if game.isEdge(@x, @y, @dx, 0) bounceX = true else if ttNextY <= dt + 1e-5 if game.isEdge(@x, @y, 0, @dy) bounceY = true @dx = -@dx if bounceX @dy = -@dy if bounceY @x += dt * @speed * @dx @y += dt * @speed * @dy die: -> game.addScore(@killScore, @x, @y) @dead = true class Hippie extends Enemy constructor: (x, y) -> super(x, y, 0, 1, level.hippieSpeed, level.hippieRandomInterval, 50000) @frameCount = 4 @frameTime = 1/8 @randomFrame() pickDirection: (sound) -> sounds.hippie_bounce.play() if sound dir = Math.floor(4 * Math.random()) @dx = 1 @dy = 0 switch dir when 0 then @dx = 0; @dy = -1 when 1 then @dx = 0; @dy = 1 when 2 then @dx = -1; @dy = 0 die: -> super() sounds.hippie_die.play() @tx = 4 @frameCount = 2 @frameTime = 1/4 @randomFrame() class Goat extends Enemy constructor: (x, y) -> super(x, y, 0, 2, level.goatSpeed, level.goatRandomInterval, 50000) @frameCount = 4 @frameTime = 1/8 @flip = true @randomFrame() pickDirection: (sound) -> sounds.goat_bounce.play() if sound dir = Math.floor(4 * Math.random()) @dx = -1 @dy = 1 switch dir when 0 then @dx = 1; @dy = -1 when 1 then @dx = -1; @dy = -1 when 2 then @dx = 1; @dy = 1 die: -> super() sounds.goat_die.play() @tx = 4 @frameCount = 2 @frameTime = 1/4 @randomFrame() class Player extends Entity constructor: (x, y) -> super(x, y, 0, 0, 4, 0) @fromX = x @fromY = y @toX = x @toY = y @f = 0 @frameCount = 4 @keys = {} @lefts = [] @rights = [] onKeyDown: (e) -> if e.keyCode >= 37 && e.keyCode <= 40 || e.keyCode == 16 @keys[e.keyCode] = true e.preventDefault() onKeyUp: (e) -> if e.keyCode >= 37 && e.keyCode <= 40 || e.keyCode == 16 @keys[e.keyCode] = false e.preventDefault() update: (dt) -> if game.ended if @ty == 3 && @frame < 5 super(dt) return super(dt) currentTile = game.getFloatTile(@x, @y) speed = if currentTile in [TAPE, GRASS] then PLAYER_SPEED_GRASS else PLAYER_SPEED_CONCRETE dx = 0 dy = 0 if @keys[37] then dx = -1; dy = 0 if @keys[38] then dx = 0; dy = -1 if @keys[39] then dx = 1; dy = 0 if @keys[40] then dx = 0; dy = 1 # if @keys[16] then speed *= 5 # TODO remove before release while true if @fromX != @toX || @fromY != @toY @frameTime = 1/16 tti = (1-@f) / speed if dt > tti dt -= tti - 1e-5 @fromX = @toX @fromY = @toY @f = 0 else @f += dt * speed break else if dx != 0 || dy != 0 toX = Math.max(0, Math.min(game.width - 1, @fromX + dx)) toY = Math.max(0, Math.min(game.height - 1, @fromY + dy)) fromTile = game.getTile(@fromX, @fromY) toTile = game.getTile(toX, toY) if toTile == GRASS || toTile == CONCRETE @toX = toX @toY = toY if toTile == GRASS game.grid[toX][toY] = TAPE sounds.tape.play() if fromTile == CONCRETE game.tape.push({x: @fromX, y: @fromY}) game.tape.push({x: toX, y: toY}) @lefts.push({x: toX + dy, y: toY - dx}) @rights.push({x: toX - dy, y: toY + dx}) game.drawBg() if fromTile == TAPE && toTile == CONCRETE sounds.pour.play() game.tape = [] game.pour(@lefts, @rights) @lefts = [] @rights = [] if @toX == @fromX && @toY == @fromY @frame = 0 @frameTime = 0 break @x = @f * @toX + (1-@f) * @fromX @y = @f * @toY + (1-@f) * @fromY class Game constructor: (level) -> @canvas = $('#canvas')[0] @context = @canvas.getContext('2d') @context.setTransform(1, 0, 0, 1, 0, 0) @context.translate(M, M) @width = Math.floor((canvas.width - 2*M) / S) @height = Math.floor((canvas.height - 2*M) / S) @grid = {} for x in [0...@width] col = {} for y in [0...@height] if x == 0 || x == @width - 1 || y == 0 || y == @height - 1 col[y] = CONCRETE else col[y] = GRASS @grid[x] = col @tape = [] @setScore(0) @updateProgress() @entities = [] for i in [0...level.hippies] x = Math.floor(1 + Math.random() * (@width-2)) y = Math.floor(1 + Math.random() * (@height-2)) @entities.push(new Hippie(x, y)) for i in [0...level.goats] x = Math.floor(1 + Math.random() * (@width-2)) y = Math.floor(1 + Math.random() * (@height-2)) @entities.push(new Goat(x, y)) @player = new Player(Math.floor(@width / 2), @height - 1) last = Date.now() update = => now = Date.now() delta = Math.max(0, Math.min(100, now - last)) / 1000 last = now if !paused @update(delta) @draw() window.requestAnimationFrame(update, @canvas) if !@gone window.requestAnimationFrame(update, @canvas) $(window).on('keydown.game', (e) => @player.onKeyDown(e)) $(window).on('keyup.game', (e) => @player.onKeyUp(e)) @entities.push(@player) @bg = document.createElement('canvas') @bg.width = @canvas.width @bg.height = @canvas.height @bgContext = @bg.getContext('2d') @bgContext.translate(M, M) @drawBg() cleanUp: -> @gone = true $(window).off('keydown.game') $(window).off('keyup.game') getTile: (x, y) -> col = @grid[x] return CONCRETE if col == undefined || col[y] == undefined return col[y] getFloatTile: (x, y) -> @getTile(Math.round(x), Math.round(y)) isEdge: (x, y, dx, dy) -> return @getFloatTile(x, y) == GRASS && @getFloatTile(x + dx, y + dy) != GRASS update: (dt) -> return if @gone if @won @accum ||= 0 @accum += dt if @fillQueue.length > 0 step = 1/500 count = 0 while @accum > step && @fillQueue.length > 0 if @fillStep() count++ @accum -= step @drawBg() else if @accum > 1 @onWon() if @onWon @gone = true else if @lost @lostTime ||= 0 @lostTime += dt @accum ||= 0 @accum += dt @player.update(dt) if @tape.length > 0 step = 1/10 while @accum > step && @tape.length > 0 @tape.shift() @accum -= step @drawBg() else if @lostTime > 2.0 @onLost() if @onLost @gone = true else aliveEnemies = 0 for entity in @entities continue if entity.gone if entity instanceof Enemy && !entity.dead aliveEnemies++ entity.update(dt) if aliveEnemies == 0 @win() pour: (lefts, rights) -> [count, sumX, sumY] = @replace(TAPE, CONCRETE) [leftCount, leftSumX, leftSumY] = [count, sumX, sumY] [rightCount, rightSumX, rightSumY] = [count, sumX, sumY] for left in lefts [c, sx, sy] = @floodFill(left.x, left.y, LEFT) leftCount += c leftSumX += sx leftSumY += sy for right in rights [c, sx, sy] = @floodFill(right.x, right.y, RIGHT) rightCount += c rightSumX += sx rightSumY += sy if leftCount + Math.random() - 0.5 < rightCount [count, sumX, sumY] = [leftCount, leftSumX, leftSumY] fill = LEFT clear = RIGHT else [count, sumX, sumY] = [rightCount, rightSumX, rightSumY] fill = RIGHT clear = LEFT @replace(fill, CONCRETE) @replace(clear, GRASS) @addScore(count*count, sumX / count, sumY / count) for entity in @entities continue if entity.gone if entity instanceof Enemy if @getFloatTile(entity.x, entity.y) != GRASS && !entity.dead entity.die() @updateProgress() if @progress > 0.8 @win() @drawBg() replace: (from, to) -> count = 0 sumX = 0 sumY = 0 for x in [0...@width] col = @grid[x] for y in [0...@height] if col[y] == from count++ sumX += x sumY += y col[y] = to return [count, sumX, sumY] count: (value) -> count = 0 for x in [0...@width] col = @grid[x] for y in [0...@height] if col[y] == value count++ return count floodFill: (x, y, value) -> q = [{x: x, y: y}] count = 0 sumX = 0 sumY = 0 while q.length > 0 p = q.shift() col = @grid[p.x] continue if col == undefined tile = col[p.y] continue if tile == undefined continue if tile != GRASS @grid[p.x][p.y] = value count++ sumX += p.x sumY += p.y q.push({x: p.x - 1, y: p.y}) q.push({x: p.x + 1, y: p.y}) q.push({x: p.x, y: p.y - 1}) q.push({x: p.x, y: p.y + 1}) return [count, sumX, sumY] fillStep: -> return false if !@fillQueue || @fillQueue.length == 0 p = @fillQueue.shift() col = @grid[p.x] return false if col == undefined tile = col[p.y] return false if tile == undefined || tile != GRASS @grid[p.x][p.y] = CONCRETE @fillQueue.push({x: p.x - 1, y: p.y}) @fillQueue.push({x: p.x + 1, y: p.y}) @fillQueue.push({x: p.x, y: p.y - 1}) @fillQueue.push({x: p.x, y: p.y + 1}) return true setScore: (score) -> @score = score $('#score').html(score) addScore: (score, x, y) -> @setScore(@score + score) bubble = $('
' + score + '
') .appendTo($('#game')) bubble .css('left', M + S*(x + 0.5) - bubble.width() / 2) .css('top', M + S*(y + 0.5) - bubble.height()) window.setTimeout((-> bubble.remove()), 2000) updateProgress: -> total = (@width - 2) * (@height - 2) @progress = (total - @count(GRASS)) / total $('#progress').html(Math.floor(@progress * 100)) abort: -> return if @ended @ended = true @gone = true @onAborted() if @onAborted lose: -> return if @ended sounds.lose.play() @lost = true @ended = true @player.tx = 0 @player.ty = 3 @player.frame = 0 @player.remainingFrameTime = 0 @player.frameCount = 6 @player.frameTime = 1/4 win: -> return if @ended sounds.win.play() @won = true @ended = true closest = null dist = 1e99 count = 0 sumX = 0 sumY = 0 for x in [0...@width] for y in [0...@height] tile = @grid[x][y] if tile == GRASS count++ sumX += x sumY += y dx = x - @player.x dy = y - @player.y d = dx*dx + dy*dy if d < dist dist = d closest = {x: x, y: y} if closest @addScore(count*count, sumX / count, sumY / count) @fillQueue = [closest] else @fillQueue = [] drawBg: -> c = @bgContext c.clearRect(-M, -M, canvas.width, canvas.height) c.drawImage(images.background, -M, -M) c.lineWidth = 1 for x in [0...@width] for y in [0...@height] tile = @grid[x][y] neigh = (if @getTile(x-1, y) == tile then 1 else 0) | # left (if @getTile(x+1, y) == tile then 2 else 0) | # right (if @getTile(x, y-1) == tile then 4 else 0) | # up (if @getTile(x, y+1) == tile then 8 else 0) # down corners = (if @getTile(x+1, y-1) != tile then 1 else 0) | # top right (if @getTile(x+1, y+1) != tile then 2 else 0) | # bottom right (if @getTile(x-1, y+1) != tile then 4 else 0) | # bottom left (if @getTile(x-1, y-1) != tile then 8 else 0) # top left switch tile when CONCRETE if neigh == 15 if corners == 0 c.drawImage(images.concrete, M+S*x, M+S*y, S, S, S*x, S*y, S, S) c.drawImage(images.carpark, M+S*x, M+S*y, S, S, S*x, S*y, S, S) continue else tx = 1 ty = corners else tx = 0 ty = neigh else continue c.drawImage(images.tiles, TS*tx + (TS-S)/2, TS*ty + (TS-S)/2, S, S, S*x, S*y, S, S) clock = (x, y) -> return 0 if y < 0 return 1 if x > 0 return 2 if y > 0 return 3 # x < 0 if @tape.length > 1 for i in [1...@tape.length - 1] tx = 2 p = @tape[i-1] q = @tape[i] r = @tape[i+1] inc = clock(p.x - q.x, p.y - q.y) outc = clock(r.x - q.x, r.y - q.y) [outc, inc] = [inc, outc] if inc > outc ty = switch inc when 0 then outc - 1 when 1 then 1 + outc when 2 then 2 + outc c.drawImage(images.tiles, TS*tx + (TS-S)/2, TS*ty + (TS-S)/2, S, S, S*q.x, S*q.y, S, S) draw: -> @context.clearRect(-M, -M, canvas.width, canvas.height) @context.drawImage(@bg, -M, -M) for entity in @entities continue if entity.gone entity.draw() loadLevel = (index) -> levelIndex = Math.max(1, Math.min(LEVELS.length, index)) window.localStorage['levelIndex'] = levelIndex level = LEVELS[levelIndex - 1] $('.levelIndex').html(levelIndex) $(window).on('keydown', (e) -> switch e.keyCode # TODO remove before launch # when 87 then game.win(); e.preventDefault() # when 76 then game.lose(); e.preventDefault() when 32 if $('body').hasClass('ended') && $('#retry').is(':visible') && !$('#retry')[0].disabled $('#retry').click(); e.preventDefault() when 13 if $('body').hasClass('ended') && $('#next').is(':visible') && !$('#next')[0].disabled $('#next').click(); e.preventDefault() when 27 then game.abort(); e.preventDefault() return ) $('#retry').on('click', -> startGame() ) $('#next').on('click', -> loadLevel(levelIndex + 1) startGame() ) loadHiscores = -> hiscores = [] for i in [1..LEVELS.length] h = [] stored = window.localStorage["hiscores_level#{i}"] if stored h = (parseInt(s) for s in stored.split(',')) while h.length > NUM_HISCORES h.pop() while h.length < NUM_HISCORES h.push(0) hiscores[i] = h return hiscores saveHiscores = -> for i in [1..LEVELS.length] window.localStorage["hiscores_level#{i}"] = hiscores[i].join(',') return addHiscore = (level, score) -> h = hiscores[level] if score >= h[h.length - 1] h.push(score) h.sort((a, b) -> if a < b then -1 else if a > b then 1 else 0) h.reverse() h.pop() saveHiscores() for i in [0...h.length] if h[i] == score return i return null endGame = (hiscoreIndex) -> $('body').addClass('ended') hsl = $('#hiscores').empty() h = hiscores[levelIndex] for i in [0...h.length] s = $("
  • #{h[i]}
  • ") if i == hiscoreIndex s.addClass('latest') s.appendTo(hsl) levels = $('#levels').empty() for i in [1..LEVELS.length] li = $("
  • ") a = $("Level #{i} (locked)").appendTo(li) if i <= maxLevel (-> index = i a.on('click', -> loadLevel(index) startGame() ) )() li.addClass('unlocked') else li.addClass('locked') l = LEVELS[i - 1] for enemy in ['hippie', 'goat'] for j in [0...l["#{enemy}s"]] a.append('') li.appendTo(levels) $('.tip').html(TIPS[Math.floor(Math.random() * TIPS.length)]) $('#next')[0].disabled = levelIndex >= maxLevel $('#next').toggle(levelIndex < LEVELS.length) startGame = -> $('body').removeClass('ended finished') game.cleanUp() if game game = new Game(level) game.onWon = -> if levelIndex >= maxLevel maxLevel = Math.min(LEVELS.length, levelIndex + 1) window.localStorage['maxLevel'] = maxLevel if window.localStorage if levelIndex == maxLevel $('body').addClass('finished') game.player.gone = true $('.endstatus').html('All the world paved over!') else $('.endstatus').html('Beautiful concrete!') hiscoreIndex = addHiscore(levelIndex, game.score) endGame(hiscoreIndex) game.onLost = -> $('.endstatus').html('Wrapped in tape!') endGame() game.onAborted = -> $('.endstatus').html('Game aborted') endGame() window.onblur = -> paused = true music.pause() $('body').addClass('paused') window.onfocus = -> paused = false if $('#music')[0].checked music.play() $('body').removeClass('paused') music = new Music([{src: 'music.ogg', type: 'audio/ogg'}, {src: 'music.mp3', type: 'audio/mp3'}], 0.2,48000) $('#music').click -> if music.playing music.pause() else music.play() window.localStorage['musicDisabled'] = !music.playing return null $('#sound').click -> Sound.enabled = !Sound.enabled window.localStorage['soundDisabled'] = !Sound.enabled return null Sound.enabled = window.localStorage['soundDisabled'] != 'true' $('#sound')[0].checked = Sound.enabled if window.localStorage['musicDisabled'] != 'true' music.play() $('#music')[0].checked = music.playing hiscores = loadHiscores() maxLevel = parseInt(window.localStorage['maxLevel']) maxLevel ||= 1 levelIndex = window.localStorage['levelIndex'] levelIndex ||= 1 loadAssets(-> loadLevel(levelIndex); startGame())