# HexBog, a word game # Copyright (C) 2012 Jason Woofenden # This program is free software: you can redistribute it and/or modify # it under the terms of the GNU Affero General Public License as published by # the Free Software Foundation, either version 3 of the License, or # (at your option) any later version. # This program is distributed in the hope that it will be useful, # but WITHOUT ANY WARRANTY; without even the implied warranty of # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the # GNU Affero General Public License for more details. # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . ############################################## ############## settings ################ ############################################## tile_radius = 26 tile_width = tile_radius * 2 fade_ms = 400 slide_ms = 2000 columns = [ { height: 5, spaces: [], fader_count: 0 } { height: 6, spaces: [], fader_count: 0 } { height: 7, spaces: [], fader_count: 0 } { height: 8, spaces: [], fader_count: 0 } { height: 7, spaces: [], fader_count: 0 } { height: 6, spaces: [], fader_count: 0 } { height: 5, spaces: [], fader_count: 0 } ] # code and css will need adjusting if you change HP_MAX HP_MAX = 10 ############################################################## ############## fix javascript some more ################ ############################################################## # so annoying that setTimeout has its arguments in the wrong order timeout = (ms, callback) -> setTimeout callback, ms # warning: it's shalow (sub-elements are not cloned) Array::clone = -> return this.slice(0) Array::sum = -> ret = 0 ret += i for i in this return ret Array::last = -> return this[this.length - 1] ############################################################## ############## cookies (auto-save game) ################ ############################################################## set_cookie = (name, value, days) -> date = new Date() date.setTime date.getTime()+(days*24*60*60*1000) cookie = "#{name}=#{value}; expires=#{date.toGMTString()}; path=/" document.cookie = cookie window.sc = set_cookie get_cookie = (name) -> key = name + '=' for c in document.cookie.split /; */ if c.indexOf key is 0 return c.substr key.length return null delete_cookie = (name) -> set_cookie name, '', -1 window.dc = delete_cookie num_spaces = 0 num_spaces += column.height for column in columns score = 0 spaces = new Array(num_spaces) selected = [] letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" letter_distribution = [ 14355 # a 3968 # b 6325 # c 7045 # d 20258 # e 2739 # f 5047 # g 4372 # h 13053 # i 516 # j 2600 # k 9631 # l 5115 # m 10082 # n 11142 # o 5292 # p 287 # qu 12341 # r 16571 # s 10215 # t 6131 # u 1728 # v 2184 # w 619 # x 3512 # y 831 # z ] letter_distribution_total = 175973 # letter_distribution.sum() new_letter_queue = [] new_letter = -> if new_letter_queue.length l = new_letter_queue.shift() l.letter = l.letter.toUpperCase() if l.letter is 'Q' l.letter = 'Qu' return l hp = 1 + Math.floor(Math.random() * (HP_MAX - 1)) r = Math.floor Math.random() * (letter_distribution_total + 1) for i in [0..25] r -= letter_distribution[i] if r <= 0 if letters[i] is 'Q' return letter: 'Qu', hp: hp return letter: letters[i], hp: hp return letter: 'Z', hp: hp # just in case # in memory it's layed out like this: # a c f j m # b d g k n # e h l # i # for display, columns are slid vertically like so: # f # c j # a g m # d k # b h n # e l # i # # work out which grid spaces are connected init_board_layout = () -> col_offset = 0 middle_col_num = (columns.length - 1) / 2 space_num = 0 for column, col_num in columns if col_num < middle_col_num fw_other = 1 else fw_other = -1 if col_num > middle_col_num bw_other = 1 else bw_other = -1 is_first_col = col_num is 0 is_last_col = col_num is columns.length - 1 neighbors = [] # neighbors are integers for now, but get dereferenced later, after we've created all the spaces push = (offset) -> neighbors.push col_offset + offset col_top_px = Math.abs col_num - middle_col_num col_top_px *= tile_radius above = [] for i in [0 ... column.height] space = { id: space_num } spaces[space_num] = space space_num += 1 column.spaces.push space space.column = column is_top_tile = i is 0 is_bottom_tile = i is column.height - 1 # link tile number to pixel "top" and "left" of containing column space.top_px = col_top_px + i * tile_width space.left_px = col_num * tile_width # aboves: array of spaces, top to bottom space.aboves = above.clone() above.push space # below: SINGLE tile below unless is_top_tile spaces[space.id - 1].below = space # neighbors (array of tile numbers "next to" this one) neighbors = [] unless is_top_tile # upward link push i - 1 unless is_bottom_tile # downward links push i + 1 unless is_first_col # leftward links unless is_bottom_tile and bw_other is -1 push i - columns[col_num - 1].height unless is_top_tile and bw_other is -1 push i - columns[col_num - 1].height + bw_other unless is_last_col # rightward links unless is_bottom_tile and fw_other is -1 push i + columns[col_num].height unless is_top_tile and fw_other is -1 push i + columns[col_num].height + fw_other # will be dereferenced later space.neighbors = neighbors col_offset += column.height # convert all space.neighbors arrays from containing space ids to referencing the space for s in spaces for id, key in s.neighbors s.neighbors[key] = spaces[id] # support obsolete save data format load_game_0 = (encoded) -> letters = (encoded.substr 0, num_spaces).split '' for l in letters new_letter_queue.push { letter: l, hp: 1 + Math.floor(Math.random() * (HP_MAX - 1)) } score = parseInt(encoded.substr(num_spaces), 10) load_game_1 = (encoded) -> int = 0 encoded = encoded.substr 1 score = parseInt(encoded.substr(num_spaces * 3 / 2), 10) for t in [0...(spaces.length * 3 / 2)] by 3 int = 0 for d in [0...3] int *= 44 char = encoded[t + 2 - d] int += save_charset.indexOf(char) t2hp = int % 11 int = Math.floor(int / 11) t2letter = String.fromCharCode(char_a + (int % 26)) int = Math.floor(int / 26) t1hp = int % 11 int = Math.floor(int / 11) t1letter = String.fromCharCode(char_a + (int % 26)) new_letter_queue.push { letter: t1letter, hp: t1hp } new_letter_queue.push { letter: t2letter, hp: t2hp } load_game = (encoded) -> switch encoded.substr 0, 1 when "1" load_game_1(encoded) else load_game_0(encoded) init_board = -> encoded = window.location.hash if encoded? and encoded.charAt 0 is '#' encoded = encoded.substr 1 unless encoded? and encoded.length > num_spaces encoded = get_cookie 'hexbog' if encoded? and encoded.length > num_spaces load_game encoded # work out which grid spaces are connected # (neighbors, above, down) init_board_layout() $big_tip = null # initialized by init_html_board $little_tip = null # initialized by init_html_board $score_display = null # initialized by init_html_board $definition_body = null # initialized by init_html_board update_selection_display = -> word = selected_word() $big_tip.removeClass('good') if word.length > 0 if word.length < 3 $big_tip.html word $little_tip.html "Click more tiles (3 minimum)" else if is_word word if word.indexOf(word.substr(word.length - 1)) < word.length - 1 last = 'last ' else last = '' $little_tip.html "Click the #{last}\"#{word.substr(word.length - 1)}\" for #{score_for word} points" $big_tip.html "#{word}" $big_tip.addClass('good') else $big_tip.html word $little_tip.html "\"#{word}\" is not in the word list." else $big_tip.html "← Click a word" $little_tip.html "(tiles must be touching)" # color the selected tiles according to whether they're a word or not if word.length classes = ['selected_word', 'selected'] if is_word word c = 0 else c = 1 for tile in selected tile.dom.addClass classes[c] tile.dom.removeClass classes[1 - c] # unselects the last tile of the selecetion unselect_tile = -> _unselect_tile() update_selection_display() _unselect_tile = -> tile = selected.pop() dom = tile.dom if tile.connector? tile.connector.remove() delete tile.connector dom.removeClass 'selected_word' dom.removeClass 'selected' unselect_all = -> while selected.length _unselect_tile() update_selection_display() selected_word = -> word = '' word += tile.text for tile in selected return word.toLowerCase() save_charset = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQR' char_a = "a".charCodeAt(0) save_game = -> encoded = '1' # save format for i in [0...spaces.length] by 2 int = spaces[i].tile.text.toLowerCase().charCodeAt(0) - char_a int *= 11 int += spaces[i].tile.hp int *= 26 int += spaces[i+1].tile.text.toLowerCase().charCodeAt(0) - char_a int *= 11 int += spaces[i+1].tile.hp for d in [0...3] encoded += save_charset.substr(int % 44, 1) int = Math.floor(int / 44) encoded += score set_cookie 'hexbog', encoded, 365 window.location.hash = encoded unsink = (tile) -> tile.new_hp = 10 tile.text = new_letter().letter tile.dom.html tile.text # top-level key is word length # arrays are [difficulty] level, easiest to hardest booms = { 3: { neighbors: { flips: [1,1,1,1,0] force: [2,2,1,1,1,1,0] } neighbor_neighbors: { flips: [0] force: [0] } board: { flips: [0] force: [0] } } 4: { neighbors: { flips: ['all', 4,4,4,4,4,3,3,2,2,1] force: [4,3,3,3,3,3,2] } neighbor_neighbors: { flips: [0] force: [2,2,2,1,1,1,0] } board: { flips: [0] force: [0] } } 5: { neighbors: { flips: ['all','all','all','all',5,5,5,4,4,4,3,3,3,2] force: [6,6,6,6,5,5,5,4,4,4,3] } neighbor_neighbors: { flips: [2,2,2,2,2,2,1,1,1,1,0] force: [4,3,3,3,3,2,2,2,1] } board: { flips: [0] force: [0] } } 6: { neighbors: { flips: ['all','all','all','all','all',9,9,9,9,8,8,8,7,7,7,6,6,6,5,5,5,4] force: [9,9,9,9,9,8,8,8,7] } neighbor_neighbors: { flips: [5,5,5,5,5,5,5,4,4,4,3,3,3,2,2,2,1] force: [6,6,5,5,4,4,3,3,2] } board: { flips: [0] force: [0] } } 7: { neighbors: { flips: ['all'] force: [10] } neighbor_neighbors: { flips: ['all','all','all','all',9,8,7,6,5,4,3,2] force: [10] } board: { flips: [0] force: [5,4,3,2,1] } } lots: { neighbors: { flips: [0] force: [0] } neighbor_neighbors: { flips: [0] force: [0] } board: { flips: ['all'] force: [10] } } } difficulty_level = 0 next_level_at = 200 adjust_difficulty_level = -> while score > next_level_at difficulty_level += 1 next_level_at *= 1.4 # remove the selected tiles from the board, create new tiles, and slide everything into place blip_selection = -> adjust_difficulty_level() word_length = selected_word().length faders = selected selected = [] update_selection_display() neighbors = {} nneighbors = {} for tile in faders tile.dom.unbind('click').fadeOut fade_ms tile.new_hp = tile.hp for n in tile.space.neighbors neighbors[n.id] = n.tile for nn in n.neighbors nneighbors[nn.id] = nn.tile # fix overlaps of faders, neighors, nneighbors for tile in faders delete nneighbors[tile.space.id] delete neighbors[tile.space.id] for k, v of neighbors delete nneighbors[k] # convert to arrays so we can sort, etc nneighbors = (v for k, v of nneighbors) neighbors = (v for k, v of neighbors) areas = { neighbors: { tiles: neighbors up: [] down: [] } neighbor_neighbors: { tiles: nneighbors up: [] down: [] } board: { tiles: (space.tile for space in spaces) up: [] down: [] } } for k, v of areas for t in v.tiles if t.hp is 0 v.down.push t else v.up.push t if word_length < 8 boom = booms[word_length] else boom = booms.lots for area_name, effects of boom area = areas[area_name] if difficulty_level < effects.flips.length flips = effects.flips[difficulty_level] else flips = effects.flips.last() if flips is 'all' or flips >= area.down.length for t in area.down unsink t else down_count = area.down.length flips_left = flips while flips_left > 0 and down_count > 0 flips_left -= 1 flipper = Math.floor(Math.random() * down_count) unsink area.down[flipper] down_count -= 1 # move the last tile back into range area.down[flipper] = area.down[down_count] if difficulty_level < effects.force.length force = effects.force[difficulty_level] else force = effects.force.last() if force > 0 for tile in area.up if tile.new_hp # for overlap of board and [n]neigbors tile.new_hp += force else tile.new_hp = tile.hp + force for s in spaces s.tile.new_hp ?= s.tile.hp - 1 if s.tile.new_hp < 0 s.tile.new_hp = 0 else if s.tile.new_hp > HP_MAX s.tile.new_hp = HP_MAX if s.tile.new_hp isnt s.tile.hp s.tile.dom.removeClass "hp#{s.tile.hp}" s.tile.dom.addClass "hp#{s.tile.new_hp}" s.tile.hp = s.tile.new_hp delete s.tile.new_hp timeout fade_ms + 1, -> # delete old tiles, mark where tiles are moving for fader in faders fader.space.column.fader_count += 1 fader.dom.remove() fader.removed = true for above in fader.space.aboves if above.tile.dest? above.tile.dest += 1 else above.tile.dest = above.id + 1 # move tiles down (graphically and in data structure) rspaces = [] for s in spaces rspaces.unshift s for space in rspaces tile = space.tile if tile.dest? and not (tile.removed?) dest_space = spaces[tile.dest] delete tile.dest tile.dom.animate {top: "#{dest_space.top_px}px"}, slide_ms tile.space = dest_space dest_space.tile = tile # create new tiles for column in columns dest = 0 while column.fader_count > 0 column.fader_count -= 1 slide_from = -10 - tile_width slide_from -= (50 + tile_width) * column.fader_count space = column.spaces[dest++] tile = new_tile space, slide_from tile.dom.animate {top: "#{space.top_px}px"}, slide_ms save_game() score_for = (word) -> Math.round(Math.pow(1.7, word.length)) activate_selection = -> word = selected_word() if word.length < 3 # should only happen when trying to blip a word with the keyboard # FIXME make this a hint log "Too short: \"#{word}\"" return unless is_word word # should only happen when trying to blip a word with the keyboard # FIXME make this automatically part of the selection display log "Not on word list: \"#{word}\"" return word_score = score_for word score += word_score $score_display.html score # FIXME make some kind of animation showing score gain blip_selection() look_up_definition word $('#definition').click() show_definition = (word, type, definition, language) -> html = "" html += "#{word.substr(0, 1).toUpperCase() + word.substr(1)}, #{type}" if language isnt 'English' html += " (#{language})" html += ': ' html += definition html += '
Definition ©wiktionary.org CC-BY-SA
' $definition_body.html html connector_width = 11 connector_radius = 5 connector_slant = 12 add_connector = (tile, horiz, vert) -> style = {} switch horiz when 'left' style.right = '100%' when 'mid' style.left = 21 - connector_radius when 'right' style.left = '100%' switch vert when 'top' style.bottom = '100%' when 'up' style.top = 21 - connector_radius - connector_slant when 'down' style.top = 21 - connector_radius + connector_slant when 'bot' style.top = '100%' tile.connector = $("
").css style tile.dom.append tile.connector select_tile = (tile) -> selected.push tile if selected.length > 1 prev = selected[selected.length - 2] if prev.space.top_px < tile.space.top_px if prev.space.left_px < tile.space.left_px add_connector tile, 'left', 'up' else if prev.space.left_px is tile.space.left_px add_connector tile, 'mid', 'top' else add_connector tile, 'right', 'up' else if prev.space.left_px < tile.space.left_px add_connector tile, 'left', 'down' else if prev.space.left_px is tile.space.left_px add_connector tile, 'mid', 'bot' else add_connector tile, 'right', 'down' update_selection_display() new_tile = (space, y) -> x = space.left_px l = new_letter() letter = l.letter hp = l.hp html_tile = $("
#{letter}
") $board.append(html_tile) html_tile.show() tile = { text: letter dom: html_tile hp: hp space: space } space.tile = tile html_tile.click -> return unselect_all() if tile.hp < 1 word = selected_word() if tile in selected if selected_word().length > 2 and is_word(word) and tile is selected.last() activate_selection() else if selected.length is 1 unselect_all() else unselect_all() select_tile tile else # clicked a non-selected tile if selected.length > 0 and not (tile.space in selected.last().space.neighbors) unselect_all() select_tile tile return tile $board = null init_html_board = -> $('#loading').remove() $big_tip = $('#big_tip') $little_tip = $('#little_tip') $score_display = $('#score') $score_display.html score $definition_body = $('#definition_body') $board = $('#board') # make html for board for s in spaces new_tile s, s.top_px word_bins = []; word_bins.push(',') for [0...997] hash_word = (word) -> h = 0 for i in [0...word.length] h ^= word.charCodeAt(i) << ((i*3) % 21) return h % 997 is_word = (str) -> word_bins[hash_word str].indexOf(",#{str},") > -1 # this is called automatically by the compressed wordlist parse_word_list = (compressed) -> prefix = '' cap_a = "A".charCodeAt 0 i = 0 next_chunk = -> chunk = compressed[i] for word in chunk.match(/[a-z]*[A-Z]/g) # the capital letter (at the end of the match) says how many characters # from the end of the previous word should be removed to create the prefix # for the next word. "A" for 0, "B" for 1, "C" for 2, etc bs = word[word.length - 1].charCodeAt(0) - cap_a word = prefix + word[0 ... word.length - 1] word_bins[hash_word word] += word + ',' prefix = word[0 ... word.length - bs] if ++i is compressed.length return else timeout 1, next_chunk timeout 1, next_chunk extract_wiktionary_definiton = (html) -> found = false finds = {} language = false part = false # clean HTML ################## # when we instantiate the html so we can use dom traversal, the browser # will start loading images and such. This section attempts to mangle the # html so no resources are loaded when the html is parsed. # attributes # src: ,