SUFFIX = 1 PREFIX = 2 # Average number of words visible on any given iteration. AVG_VISIBLE = 60 clone = (obj) -> return obj if not obj? or typeof obj isnt 'object' newInstance = new obj.constructor() for key of obj newInstance[key] = clone obj[key] newInstance HEIGHT_FUZZ = 8 WIDTH_FUZZ = 6 # A dimensioned object is one that appears on the board: it has an X # and Y coordinate, a width and a height. From this, we can create a # bounding box using the "shape" function. Dimensioned objects can be # compared to other dimensioned objects to assert whether or not # they're in collision. Some objects have bounding boxes that pull in # or push out the borders abstractly, in order to provide for "fuzzy" # collisions that correspond to drop shadows or similar visual effects. shape = (x, y, w, h) -> [{x: x, y: y}, {x: x + w, y: y}, {x: x + w, y: y + h}, {x: x, y: y + h}] class Dimensioned _width: null _height: null _left_p: null _top_p: null _left: null _top: null _pos: null constructor: (@el) -> unset_dims: -> @_left = @_top = @_width = @_height = @_pos = null reset_dims: -> @unset_dims() [@left(), @top(), @width(), @height()] positioned: -> return @_width? and @height? visibleReposition: -> @reposition() @el.css {top: @top(), left: @left()} @ reposition: -> parent = @el.offsetParent() [@_top, @_left] = [parseInt(@_top_p * parent.height()), parseInt(@_left_p * parent.width())] @_pos = {left: @_left, top: @_top} @ width: -> @_width = if @_width? then @_width else @el.outerWidth() height: -> @_height = if @_height? then @_height else @el.outerHeight() pos: -> @_pos = if @_pos? then @_pos else @el.position() left: -> @_left = if @_left? @_left else @_left = @pos().left @_left_p = @_left / @el.offsetParent().width() @_left top: -> @_top = if @_top? @_top else @_top = @pos().top @_top_p = @_top / @el.offsetParent().height() @_top dims: -> [@width(), @height()] shape: -> shape @left(), @top(), @width(), @height() # I can't decide if this is the right way to go, with a two-pass "set # it all up, then make it all blow up," but it works quite well, all # things considered. And after much consideration (like, one minute # of realizing I never, ever used the features) it became obvious I # didn't need Dimensioned. class Heart constructor: (@parent, @top, @left, symbol) -> dv = '' @el = $(dv) @el.css {'font-size': 'larger'} if Math.random() > 0.6 @rot_dist = parseInt(90 * Math.random()) * (if Math.random() < 0.5 then 1 else -1) [@dir, @dst, @dur] = [Math.random() * 2 * Math.PI, Math.random() * 110, Math.random() * 1200 + 700] $(@parent).append(@el) explode: -> el = $(@el) el.show().animate({opacity: 0.0, top: parseInt(@top + (Math.sin(@dir) * @dst)), left: parseInt(@left + (Math.cos(@dir) * @dst)), rotate: @rot_dist}, @dur, "easeOutCubic", (() -> el.remove())) explode_hearts = (@board, @el) -> randomsymbol = -> ['♪','★','✶'][parseInt(Math.random() * 3)] symbol = if Math.random() < 0.3 then randomsymbol() else '♥' parent = @board.el [top, left, height, width] = [@el.top(), @el.left(), @el.height(), @el.width()] hearts = for i in [0..(22 + (6 - Math.floor(Math.random() * 12)))] new Heart(parent, top + (0.5 * height), left + (0.5 * width), symbol) (h.explode() for h in hearts) # The board is the principle object on which all other objects are # dependent. I decided to make it a 'Dimensioned' because I'm going # to be constantly querying its height and width. class Board extends Dimensioned append: (ob) -> @el.append(ob) css: (width, height) -> @el.css width: width height: height @reset_dims() class Footer extends Dimensioned # A Tile is a word tile. It has a single word. class Tile extends Dimensioned base_style: 'font-size': "15px" drag_style: 'font-size': "19px" visible: false # Initial tilt. rotation: (Math.random() * 30) - 15 constructor: (@word, @board, @master) -> @el = $('
' + @word.w + '
') @el.css @base_style @board.append(@el) @rotation = (Math.random() * 30) - 15 @el.draggable helper: "original" refreshPositions: false revertDuration: 1 start: (event) => mod = (Math.random() * 16) - 8 @rotation = if Math.abs(@rotation + mod) > 15 then @rotation - mod else @rotation + mod style = clone(@drag_style) style.rotate = @rotation @el.animate(style, 200, () => @new_width = @el.width()) true stop: (event) => # Drop the thing dead center, at least on the x-axis, # and animate its return to the new font size. mod = (Math.random() * 16) - 8 @rotation = if Math.abs(@rotation + mod) > 15 then @rotation - mod else @rotation + mod style = clone(@base_style) style.rotate = @rotation style['left'] = parseInt(@el.position().left + (0.5 * (@new_width - @width()))) @el.animate style, 200, 'easeOutQuad', () => @reset_dims() explode_hearts(@board, @) @master.poemed(@) true fadeOut: -> $.Deferred((d) => @el.fadeOut('fast', (() => @unset_dims(); @visible = false; d.resolve()))).promise() # Shape for deteriming poemed collision fuzzyshape: -> shape @left() - WIDTH_FUZZ, @top() - HEIGHT_FUZZ, @width() + (2 * WIDTH_FUZZ), @height() + (2 * HEIGHT_FUZZ) get_new_pos: -> bh = => parseInt(Math.random() * (@board.height() - @height()) * 0.985) bw = => parseInt(Math.random() * (@board.width() - @width()) * 0.98) [top, left] = [bh(), bw()] [top, left] = [bh(), bw()] until @master.unoccupied(left, top, @width(), @height()) [top, left] flyIn: -> fd = (mod) -> m = parseInt(40 * Math.random()) if (Math.random() < 0.5) then mod + m else -1 * m @el.css left: fd(@board.width()) top: fd(@board.height()) dfd = $.Deferred() x = Math.random() [top, left] = @get_new_pos() @el.fadeIn().animate {top: top, left: left, rotate: @rotation}, 1500, 'easeOutQuint', () => @visible = true dfd.resolve() dfd.promise() class PoemDisplay extends Dimensioned el: $('#results') _max_box: null dialog: $('#message') dtimer: null constructor: (@board) -> @el.css({top: @board.height()}) sentSuccess: (data, textStatus) => $('p', @dialog).html "Your poem has been immortalized! It can be seen on Twitter at @html5magnets." if data.error $('p', @dialog).html data.message @dialog.dialog("open") if dtimer != null clearTimeout(dtimer) dtimer = null dtimer = setTimeout (() => @dialog.dialog("close")), 7500 sentError: (query, textStatus) => console.log(query, textStatus) sendToServer: (haiku) => $.ajax 'http://html5magnets.elfsternberg.com/poems/', type: "POST" data: {"message": haiku} dataType: 'json' success: @sentSuccess error: @sentError update: (lines) -> lines = (l for l in lines when l.length > 0) if lines.length == 0 @el.fadeOut() return @el.html('') @el.show() res = for words in lines line = words[0].w for word in words[1...words.length] line += if word.s == 1 then word.w else ' ' + word.w @el.append($('

' + line + '

')) sentence = for words in lines line = words[0].w for word in words[1...words.length] line += if word.s == 1 then word.w else ' ' + word.w line haiku_add = 0 if sentence.length > 1 haiku = sentence.join(" / ") if haiku.length < 140 haiku_add = 38 @el.append('
') $('#tweetthis').click(() => @sendToServer(lines)) if lines.length != @lastlines lh = $('p', @el).height() setTimeout((() => @el.animate {top: @board.height() - ((lh * (lines.length + 1.7)) + haiku_add)}), 1) @ max_box: => return shape(@board.height() - (16 * 6.7), 0, 480, (16 * 6.7)) # A poem is three or more *moved* words in fuzzy collision. class Poem words: [] constructor: (@master) -> @poembox = new PoemDisplay(@master.board) real_poem: (poem = null) -> poem = @words if not poem? if poem.length > 1 then poem else [] has: (word) -> return (w for w in @words when w == word).length > 0 find_bbox: (words = null, sp = 0) -> words = @words if not words return null if words.length < 2 [ul, ur, lr, ll] = words[0].shape() [mx, my, nx, ny] = [ul.x, ul.y, lr.x, lr.y] for i in [1...words.length] [ul1, ur1, lr1, ll1] = words[i].shape() mx = ul1.x if ul1.x < mx my = ul1.y if ul1.y < my nx = lr1.x if lr1.x > nx ny = lr1.y if lr1.y > ny return [{x: mx - sp, y: my - sp}, {x: nx + sp, y: my - sp}, {x: nx + sp, y: ny + sp}, {x: nx + sp, y: my - sp}] check_dismissal: (word) -> # If the word is colliding with another word in the poem, it # is not being dismissed. fuzzyshape = word.fuzzyshape() for w in @words if w != word and colliding(fuzzyshape, w.fuzzyshape()) @inorder() return @words # Remove word from @words @words = @real_poem(w for w in @words when w != word) return @words if @words.length < 2 # Reconstitute poem from what remains find_split_poem = (poem) => # Why 2? Because a poem of length 1 is just a word! throw "Don't run on an empty poem!" if poem.length < 2 # Transfer all words in *poem2* that are in collision with # words in poem1. If the poems don't change, return them, # otherwise repeat the process. edgefollow = (poem1, poem2) => to_xfr = (w2 for w2 in poem2 when \ ((w1 for w1 in poem1 when \ colliding(w1.fuzzyshape(), w2.fuzzyshape())).length > 0)) # Words are not being shuffled around return [poem1, poem2] if to_xfr.length == 0 # Else... poem1 = poem1.concat(to_xfr) poem2 = (w for w in poem2 when w not in poem1) edgefollow(poem1, poem2) wordlist = (i for i in poem) first_word = wordlist.pop() [lpoem, rpoem] = edgefollow([first_word], wordlist) return [] if lpoem.length < 2 and lpoem.length < 2 return rpoem if lpoem.length < 2 return lpoem if rpoem.length < 2 return if Math.vector.magnitude(@find_bbox(lpoem)[0]) < Math.vector.magnitude(@find_bbox(rpoem)[0]) lpoem else rpoem @words = @real_poem(find_split_poem(@words)) if @words @inorder() @words # Looks at the bounding box for the current poem and adds any words # to it that are in collision with the existing poem. # :: [tiles] -> [tiles] research_poem: (poem) -> nbbox = @find_bbox(poem) newpoem = (i for i in poem) potentials = (w for w in @master.visible() when \ (w not in newpoem) and colliding(w.fuzzyshape(), nbbox)) # [word, poem] -> boolean collides_with_existing_poem = (nw1, poem1) -> fzs1 = nw1.fuzzyshape() acw1 = nw1.word ((nw2 for nw2 in poem1 when \ acw1 != nw2.word and \ colliding(nw2.fuzzyshape(), fzs1)).length > 0) addenda = (nw for nw in potentials when collides_with_existing_poem(nw, newpoem)) if addenda.length == 0 then newpoem else @research_poem(newpoem.concat(addenda)) # Looks to see if the word has come into collision with another # word, creating a new poem. # :: tile -> [tiles] maybe_new_poem: (word) -> throw "Do not call maybe_new_poem on a working poem." if @words.length > 0 fuzzyshape = word.fuzzyshape() @words = @real_poem((w for w in @master.visible() when \ colliding(w.fuzzyshape(), fuzzyshape))) if @words.length @words = @research_poem(@words) @inorder() @words check_for_addition: (word) -> # See if this word collides with any of the words in our poem: fuzzyshape = word.fuzzyshape() for w in @words if colliding(fuzzyshape, w.fuzzyshape()) and w != word @words.push(word) # One collision is all it takes. break @words = @research_poem(@words) @inorder() @words check: (word) -> return @words = @maybe_new_poem(word) if @words.length == 0 if @has(word) @words = @check_dismissal(word) return @words = if @words.length == 0 then @maybe_new_poem(word) else @words # This word doesn't create a new poem, and it isn't present in # our existing poem. return @words = @check_for_addition(word) inorder: -> return @poembox.update([]) if @words.length < 2 nbbox = @find_bbox(@words) avg_height = 0 for w in @words avg_height = avg_height + w.height() avg_height = parseInt(avg_height / @words.length) ret = [] for i in (i for i in [nbbox[0].y...nbbox[2].y] by avg_height) zbot = i + avg_height zone_words = (w for w in @words when w.top() >= i and w.top() < zbot) zone_words.sort (a, b) -> a.left() - b.left() ret.push((i.word for i in zone_words)) @poembox.update(ret) class Magnets extends Dimensioned constructor: (@wordlist) -> @el = $(window) @footer = new Footer($('#footer')) @board = new Board($('#board')) @recbox = $('#recbox') @results = $('#results') @words = (new Tile(word, @board, @) for word in @wordlist) @resize() @poem = new Poem(@) $('#shuffler').click(@reword) $(window).resize(@resize) resize: => @unset_dims() @board.css('100%', @height() - @footer.height()) (word.visibleReposition() for word in @words when word.visible) @ unoccupied:(left, top, width, height) -> reserved = [] if @poem.real_poem().length > 0 reserved.push(@poem.find_bbox(null, 10)) reserved.push(@poem.poembox.max_box()) target = shape(left, top, width, height) for s in reserved if colliding(target, s) return false true visible: -> (w for w in @words when w.visible) poemed: (word) -> @poem.check(word) livewords: -> (w for w in @words when w.visible) reword: => poemed = (w for w in @words when @poem.has(w)) flyprob = AVG_VISIBLE / (@words.length - poemed.length) $.when.apply(null, (w.fadeOut() for w in @words when not @poem.has(w))).then () => $.when.apply(null, (w.flyIn() for w in @words when not @poem.has(w) and Math.random() < flyprob)).then () => (w.reset_dims() for w in @words when w.visible) @ class MusicPlayer constructor: (control, tunes) -> @control = $(control) @control.data('state', 'on') @active = true @music = new buzz.sound(tunes, {preload:true, autoload: true, loop: true}) @music.setVolume(0) @music.bind 'canplaythrough', () => @music.play() @music.fadeTo(60, 10000) @control.click (ev) => @active = if @active then @fadeOut() else @fadeIn() fadeOut: -> @music.fadeOut(600, () => @music.pause()) $('img', @control).attr('src', 'mute.png') false fadeIn: -> @music.play().fadeIn(1200) $('img', @control).attr('src', 'unmute.png') true $ -> $.ajax url: 'js/wordlist.js' data: {} success: (data) -> (new Magnets(data)).resize().reword() error: -> console.log(arguments) dataType: 'json' v = new MusicPlayer('#muteunmute', ['media/snowflake_-_Ethereal_Space.mp3', 'media/snowflake_-_Ethereal_Space.ogg']) $( "#message" ).dialog autoOpen: false show: "fadeIn" hide: "fadeOut"