# 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_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: ,