hexyz is tower defense game, and a lua library for dealing with hexagonal grids
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

771 lines
26 KiB

game = false -- flag to tell if there is a game running
game_state = {}
local game_scene_menu_options = {
false,
{
texture = TEXTURES.NEW_GAME_HEX,
action = function()
game_init()
end
},
false,
{
texture = TEXTURES.SAVE_GAME_HEX,
action = function()
game_save()
gui_alert("succesfully saved!")
end
},
false,
{
texture = TEXTURES.LOAD_GAME_HEX,
action = function()
local save = am.load_state("save", "json")
if save then
game_init(save)
else
gui_alert("no saved games")
end
end
},
{
texture = TEXTURES.MAP_EDITOR_HEX,
action = function()
map_editor_init(game_state.map.seed)
end
},
{
texture = TEXTURES.UNPAUSE_HEX,
action = function()
win.scene("context").paused = false
win.scene:remove("menu")
end,
keys = {
"escape"
}
},
{
texture = TEXTURES.SETTINGS_HEX,
action = function()
gui_alert("not yet :)")
end
},
{
texture = TEXTURES.QUIT_HEX,
action = function()
win:close()
end,
keys = {
"f4"
}
},
false
}
local function get_initial_game_state(seed)
--local STARTING_MONEY = 75
local STARTING_MONEY = 10000
local map = random_map(seed)
local world = make_hex_grid_scene(map, true)
return {
map = map, -- map of hex coords map[x][y] to a 'tile'
world = world, -- the root scene graph node for the game 'world'
ui = nil, -- unused, root scene graph node for the 'ui' stuff
frame_start_time = 0, -- timestamp in seconds that this current frame began on
time = 0, -- real time since the *current* game started in seconds
score = 0, -- current game score
money = STARTING_MONEY, -- current money
mobs = {}, -- list of mob entities
towers = {}, -- list of tower entities
projectiles = {}, -- list of projectile entities
current_wave = 1,
time_until_next_wave = 15,
time_until_next_break = 0,
spawning = false,
spawn_chance = 0,
last_mob_spawn_time = 0,
selected_tower_type = false,
selected_toolbelt_button = false,
selected_top_right_display_type = TRDTS.SEED,
}
end
local function get_top_right_display_text(hex, evenq, centered_evenq, display_type)
local str = ""
if display_type == TRDTS.CENTERED_EVENQ then
str = centered_evenq.x .. "," .. centered_evenq.y .. " (cevenq)"
elseif display_type == TRDTS.EVENQ then
str = evenq.x .. "," .. evenq.y .. " (evenq)"
elseif display_type == TRDTS.HEX then
str = hex.x .. "," .. hex.y .. " (hex)"
elseif display_type == TRDTS.PLATFORM then
str = string.format("%s %s lang %s", am.platform, am.version, am.language())
elseif display_type == TRDTS.PERF then
str = table.tostring(am.perf_stats())
elseif display_type == TRDTS.SEED then
str = "SEED: " .. game_state.map.seed
elseif display_type == TRDTS.TILE then
str = table.tostring(hex_map_get(game_state.map, hex))
elseif display_type == TRDTS.MOUSE then
local mouse = win:mouse_position()
str = mouse.x .. "," .. mouse.y .. " (mouse)"
end
return str
end
local function get_wave_timer_text()
if game_state.spawning then
return string.format("WAVE (%d) OVER: %.2f", game_state.current_wave, game_state.time_until_next_break)
else
return string.format("NEXT WAVE (%d): %.2f", game_state.current_wave, game_state.time_until_next_wave)
end
end
-- initialized later, as part of the init of the toolbelt
local function select_tower_type(tower_type) end
local function select_toolbelt_button(i) end
local function get_wave_time(current_wave)
return 45
end
local BASE_BREAK_TIME = 20
local function get_break_time(current_wave)
return BASE_BREAK_TIME - math.min(BASE_BREAK_TIME, BASE_BREAK_TIME / (1 / math.log(game_state.current_wave + 1)))
end
local function do_day_night_cycle()
-- this is a bad idea, atleast with the current bad rendering strategy of not using a single draw call
-- i get flickering as the light level increases
--local tstep = (math.sin(game_state.time * am.delta_time) + 1) / 100
--game_state.world"negative_mask".color = vec4(tstep){a=1}
end
local function game_pause()
win.scene("context").paused = true
win.scene:append(make_scene_menu(game_scene_menu_options, nil, true))
end
local function game_deserialize(json_string)
local new_game_state = am.parse_json(json_string)
if new_game_state.version ~= version then
gui_alert("loading incompatible old save data.\nstarting a fresh game instead.", nil, 10)
return get_initial_game_state()
end
new_game_state.map = random_map(new_game_state.seed)
-- after generating a random map, the random number generator is seeded with the map's seed
-- additionally, we keep track of how many times we make calls to math.random during runtime
-- in order to restore the state of the random number generator on game deserialize, we first
-- seed it with the same seed used in the original state. then, we discard N calls, where N
-- is the number of calls we counted since seeding the generator last time.
--
-- this means it's important that deserializing the rest of the game state doesn't cause any math.random() calls.
for i = 1, new_game_state.RANDOM_CALLS_COUNT do
math.random()
end
new_game_state.world = make_hex_grid_scene(new_game_state.map, true)
new_game_state.seed = nil
for i,t in pairs(new_game_state.towers) do
if t then
new_game_state.towers[i] = tower_deserialize(t)
for _,h in pairs(new_game_state.towers[i].hexes) do
local tile = hex_map_get(new_game_state.map, h.x, h.y)
tile.elevation = tile.elevation + new_game_state.towers[i].height
end
-- @STATEFUL, shouldn't be done here
new_game_state.world:append(new_game_state.towers[i].node)
end
end
-- after we have re-constituted all of the towers and modified the map's elevations accordingly,
-- we should re-calc the flow-field
apply_flow_field(new_game_state.map, generate_flow_field(new_game_state.map, HEX_GRID_CENTER), new_game_state.world)
for i,m in pairs(new_game_state.mobs) do
if m then
new_game_state.mobs[i] = mob_deserialize(m)
-- @STATEFUL, shouldn't be done here
new_game_state.world:append(new_game_state.mobs[i].node)
end
end
for i,p in pairs(new_game_state.projectiles) do
if p then
new_game_state.projectiles[i] = projectile_deserialize(p)
-- @STATEFUL, shouldn't be done here
new_game_state.world:append(new_game_state.projectiles[i].node)
end
end
return new_game_state
end
local function game_serialize()
local serialized = table.shallow_copy(game_state)
serialized.version = version
serialized.RANDOM_CALLS_COUNT = RANDOM_CALLS_COUNT
serialized.seed = game_state.map.seed
serialized.map = nil -- we re-generate the entire map from the seed on de-serialize
-- in order to serialize the game state, we have to convert all relevant userdata into
-- something else.
--
-- this practically means vectors need to become arrays of floats,
-- and the scene graph needs to be re-constituted at load time
--
-- this is dumb and if i forsaw this i would have probably used float arrays instead of vectors
-- (the scene graph bit makes sense though)
serialized.towers = {}
for i,t in pairs(game_state.towers) do
if t then
serialized.towers[i] = tower_serialize(t)
end
end
serialized.mobs = {}
for i,m in pairs(game_state.mobs) do
if m then
serialized.mobs[i] = mob_serialize(m)
end
end
serialized.projectiles = {}
for i,p in pairs(game_state.projectiles) do
if p then
serialized.projectiles[i] = projectile_serialize(p)
end
end
return am.to_json(serialized)
end
local function deselect_tile()
win.scene:remove("tile_select_box")
end
local function game_action(scene)
game_state.frame_start_time = am.current_time()
if game_state.score < 0 then
game_end()
return true
end
game_state.time = game_state.time + am.delta_time
game_state.score = game_state.score + am.delta_time
if game_state.spawning then
game_state.time_until_next_break = game_state.time_until_next_break - am.delta_time
if game_state.time_until_next_break <= 0 then
game_state.time_until_next_break = 0
game_state.current_wave = game_state.current_wave + 1
game_state.spawning = false
game_state.time_until_next_wave = get_break_time(game_state.current_wave)
end
else
game_state.time_until_next_wave = game_state.time_until_next_wave - am.delta_time
if game_state.time_until_next_wave <= 0 then
game_state.time_until_next_wave = 0
game_state.spawning = true
-- calculate spawn chance for next wave
game_state.spawn_chance = math.log(game_state.current_wave)/80 + 0.002
game_state.time_until_next_break = get_wave_time(game_state.current_wave)
end
end
local mouse = win:mouse_position()
local hex = pixel_to_hex(mouse - WORLDSPACE_COORDINATE_OFFSET, vec2(HEX_SIZE))
local rounded_mouse = hex_to_pixel(hex, vec2(HEX_SIZE)) + WORLDSPACE_COORDINATE_OFFSET
local evenq = hex_to_evenq(hex)
local centered_evenq = evenq{ y = -evenq.y } - vec2(math.floor(HEX_GRID_WIDTH/2)
, math.floor(HEX_GRID_HEIGHT/2))
local tile = hex_map_get(game_state.map, hex)
local interactable = evenq_is_in_interactable_region(evenq{ y = -evenq.y })
local buildable = tower_type_is_buildable_on(hex, tile, game_state.selected_tower_type)
if win:mouse_pressed"left" then
deselect_tile()
if interactable then
if buildable then
local broken, flow_field = building_tower_breaks_flow_field(game_state.selected_tower_type, hex)
local cost = get_tower_cost(game_state.selected_tower_type)
if broken then
local node = win.scene("cursor"):child(2)
node.color = COLORS.CLARET
node:action(am.tween(0.1, { color = COLORS.TRANSPARENT3 }))
play_sfx(SOUNDS.BIRD2)
gui_alert("closes the circle")
elseif cost > game_state.money then
local node = win.scene("cursor"):child(2)
node.color = COLORS.CLARET
node:action(am.tween(0.1, { color = COLORS.TRANSPARENT3 }))
play_sfx(SOUNDS.BIRD2)
gui_alert("not enough money")
else
update_money(-cost)
build_tower(hex, game_state.selected_tower_type, flow_field)
if flow_field then
apply_flow_field(game_state.map, flow_field, game_state.world)
end
end
else
-- interactable tile, but no tower type selected
-- depending on what's under the cursor, we can show some information.
local towers = towers_on_hex(hex)
local tcount = table.count(towers)
if tcount > 0 then
play_sfx(SOUNDS.SELECT1)
win.scene:remove("tile_select_box")
win.scene:append((
am.translate(rounded_mouse)
^ pack_texture_into_sprite(TEXTURES.SELECT_BOX, HEX_SIZE*2, HEX_SIZE*2, COLORS.SUNRAY)
)
:tag"tile_select_box"
)
end
end
end
end
if win:mouse_pressed"middle" then
win.scene("world_scale").scale2d = vec2(1)
elseif win:key_down"lctrl" then
local mwd = win:mouse_wheel_delta()
win.scene("world_scale").scale2d = win.scene("world_scale").scale2d + vec2(mwd.y) / 100
end
if win:key_pressed"escape" then
game_pause()
elseif win:key_pressed"f1" then
game_state.selected_top_right_display_type = (game_state.selected_top_right_display_type + 1) % #table.keys(TRDTS)
elseif win:key_pressed"f2" then
game_state.world"flow_field".hidden = not game_state.world"flow_field".hidden
elseif win:key_pressed"f3" then
game_save()
elseif win:key_pressed"tab" then
if win:key_down"lshift" then
select_toolbelt_button((game_state.selected_toolbelt_button + table.count(TOWER_TYPE) - 2) % table.count(TOWER_TYPE) + 1)
else
select_toolbelt_button((game_state.selected_toolbelt_button) % table.count(TOWER_TYPE) + 1)
end
elseif win:key_pressed"1" then select_toolbelt_button( 1)
elseif win:key_pressed"2" then select_toolbelt_button( 2)
elseif win:key_pressed"3" then select_toolbelt_button( 3)
elseif win:key_pressed"4" then select_toolbelt_button( 4)
elseif win:key_pressed"q" then select_toolbelt_button( 5)
elseif win:key_pressed"w" then select_toolbelt_button( 6)
elseif win:key_pressed"e" then select_toolbelt_button( 7)
elseif win:key_pressed"r" then select_toolbelt_button( 8)
elseif win:key_pressed"a" then select_toolbelt_button( 9)
elseif win:key_pressed"s" then select_toolbelt_button(10)
elseif win:key_pressed"d" then select_toolbelt_button(11)
elseif win:key_pressed"f" then select_toolbelt_button(12)
end
do_entity_updates()
do_mob_spawning(game_state.spawn_chance)
do_day_night_cycle()
-- update the cursor
if not interactable then
win.scene("cursor").hidden = true
else
if game_state.selected_tower_type then
if buildable then
win.scene("cursor").hidden = false
else
win.scene("cursor").hidden = true
end
else
-- if we don't have a tower selected, but the tile is interactable, then show the 'select' cursor
win.scene("cursor").hidden = false
end
win.scene("cursor_translate").position2d = rounded_mouse
end
win.scene("score").text = string.format("SCORE: %.2f", game_state.score)
win.scene("money").text = string.format("MONEY: $%d", game_state.money)
win.scene("wave_timer").text = get_wave_timer_text()
win.scene("top_right_display").text = get_top_right_display_text(hex, evenq, centered_evenq, game_state.selected_top_right_display_type)
check_if_can_collect_garbage_for_free(game_state.frame_start_time, 60)
end
local function make_game_toolbelt()
local toolbelt_height = win.height * 0.07
local tower_tooltip_text_position = vec2(win.left + 10, win.bottom + toolbelt_height + 20)
local keys = { '1', '2', '3', '4', 'q', 'w', 'e', 'r', 'a', 's', 'd', 'f' }
--local keys = { '1', '2', '3', '4', '5', '6', '7', '8', '9', '0', '-', '=' }
local function get_tower_tooltip_text_node(tower_type)
local name = get_tower_name(tower_type)
local placement_rules = get_tower_placement_rules_text(tower_type)
local short_desc = get_tower_short_description(tower_type)
local cost = get_tower_cost(tower_type)
local color = COLORS.WHITE
return (am.translate(tower_tooltip_text_position)
^ am.scale(1)
^ am.group(
am.translate(0, 40)
^ am.text(string.format("%s - %s", name, short_desc), color, "left"):tag"tower_name",
am.translate(0, 20)
^ am.text(placement_rules, color, "left"):tag"tower_placement_rules",
am.translate(0, 0)
^ am.text(string.format("$%d", cost), color, "left"):tag"tower_cost"
)
)
:tag"tower_tooltip_text"
end
local padding = 12
local offset = vec2(win.left, win.bottom + padding/3)
local size = toolbelt_height - padding
local half_size = size/2
local function toolbelt_button(i)
local texture = get_tower_icon_texture(i)
local button =
am.translate(vec2(size + padding, 0) * i + offset)
^ am.group(
am.translate(0, half_size)
^ pack_texture_into_sprite(TEXTURES.BUTTON1, size, size),
am.translate(0, half_size)
^ pack_texture_into_sprite(texture, size, size),
am.translate(vec2(half_size))
^ am.group(
pack_texture_into_sprite(TEXTURES.BUTTON1, half_size, half_size, vec4(0.4, 0.4, 0.4, 1)),
am.scale(2)
^ am.text(keys[i], COLORS.WHITE)
)
)
local x1 = (size + padding) * i + offset.x - half_size
local y1 = offset.y
local x2 = (size + padding) * i + offset.x + size - half_size
local y2 = offset.y + size
local rect = { x1 = x1, y1 = y1, x2 = x2, y2 = y2 }
return button, rect
end
local toolbelt = am.group(
am.group():tag"tower_tooltip_text",
am.rect(win.left, win.bottom, win.right, win.bottom + toolbelt_height, COLORS.TRANSPARENT3)
)
:tag"toolbelt"
local tower_select_square = (
am.translate(vec2(size + padding, half_size) + offset)
^ am.rect(-size/2-3, -size/2-3, size/2+3, size/2+3, COLORS.SUNRAY)
)
:tag"toolbelt_select_square"
tower_select_square.hidden = true
toolbelt:append(tower_select_square)
local toolbelt_buttons = {}
for i = 1, #TOWER_SPECS do
local button, rect = toolbelt_button(i)
table.insert(toolbelt_buttons, { node = button, rect = rect })
toolbelt:append(button)
end
toolbelt:action(function(self)
local mouse = win:mouse_position()
if mouse.y <= (win.bottom + toolbelt_height) then
for i,b in pairs(toolbelt_buttons) do
if point_in_rect(mouse, b.rect) then
win.scene:replace("tower_tooltip_text", get_tower_tooltip_text_node(i))
if win:mouse_pressed("left") then
select_toolbelt_button(i)
end
break
end
end
else
if game_state.selected_tower_type then
win.scene:replace("tower_tooltip_text", get_tower_tooltip_text_node(game_state.selected_tower_type))
else
win.scene:replace("tower_tooltip_text", am.group():tag"tower_tooltip_text")
end
end
end)
-- make the 'escape/pause/settings' button in the lower right
local settings_button_position = vec2(win.right - half_size - 20, win.bottom + half_size + padding/3)
local settings_button_rect = {
x1 = settings_button_position.x - size/2,
y1 = settings_button_position.y - size/2,
x2 = settings_button_position.x + size/2,
y2 = settings_button_position.y + size/2
}
toolbelt:append(
am.translate(settings_button_position)
^ am.group(
pack_texture_into_sprite(TEXTURES.BUTTON1, size, size),
pack_texture_into_sprite(TEXTURES.GEAR, size - padding, size - padding)
)
:action(function(self)
if point_in_rect(win:mouse_position(), settings_button_rect) then
if win:mouse_pressed("left") then
game_pause()
end
end
end)
)
select_tower_type = function(tower_type)
game_state.selected_tower_type = tower_type
if get_tower_spec(tower_type) then
win.scene:replace(
"toolbelt_tooltip_text",
get_tower_tooltip_text_node(tower_type)
)
local new_position = vec2((size + padding) * tower_type, size/2) + offset
if toolbelt("toolbelt_select_square").hidden then
toolbelt("toolbelt_select_square").position2d = new_position
toolbelt("toolbelt_select_square").hidden = false
else
toolbelt("toolbelt_select_square"):action(am.tween(0.1, { position2d = new_position }))
end
win.scene:replace("cursor", get_tower_cursor(tower_type))
play_sfx(SOUNDS.SELECT1)
else
deselect_tile()
-- de-selecting currently selected tower if any
toolbelt("toolbelt_select_square").hidden = true
win.scene:replace("cursor", make_hex_cursor_node(0, COLORS.TRANSPARENT3))
end
end
select_toolbelt_button = function(i)
game_state.selected_toolbelt_button = i
if get_tower_spec(i) then
select_tower_type(i)
else
select_tower_type(nil)
end
end
return toolbelt
end
local function game_scene()
local score =
am.translate(win.left + 10, win.top - 15)
^ am.text("", "left", "top"):tag"score"
local money =
am.translate(win.left + 10, win.top - 35)
^ am.text("", "left", "top"):tag"money"
local wave_timer =
am.translate(0, win.top - 20)
^ am.text(get_wave_timer_text()):tag"wave_timer"
local send_now_button_position = vec2(0, win.top - 40)
local send_now_button_dimensions = vec2(200, 20)
local send_now_button_rect = {
x1 = -send_now_button_dimensions.x/2 + send_now_button_position.x,
y1 = -send_now_button_dimensions.y/2 + send_now_button_position.y,
x2 = send_now_button_dimensions.x/2 + send_now_button_position.x,
y2 = send_now_button_dimensions.y/2 + send_now_button_position.y
}
local send_now_button =
am.translate(send_now_button_position)
^ am.text("> SEND NOW <")
:tag"send_now_button"
:action(function(self)
local mouse = win:mouse_position()
if point_in_rect(mouse, send_now_button_rect) then
self.color = COLORS.SUNRAY
if win:mouse_pressed("left") then
if game_state.spawning then
-- in this case, we don't exactly just send the next wave, we turn the current wave into the next
-- wave, and add the amount of time it would have lasted to the amount of remaining time in the
-- current wave
game_state.current_wave = game_state.current_wave + 1
-- calculate spawn chance for next wave
game_state.spawn_chance = math.log(game_state.current_wave)/100 + 0.002
game_state.time_until_next_break = game_state.time_until_next_break + get_break_time(game_state.current_wave)
play_sfx(SOUNDS.EXPLOSION4)
else
game_state.time_until_next_wave = 0
play_sfx(SOUNDS.EXPLOSION4)
end
end
else
self.color = COLORS.WHITE
end
end)
local curtain = am.rect(win.left, win.bottom, win.right, win.top, COLORS.TRUE_BLACK)
curtain:action(coroutine.create(function()
am.wait(am.tween(curtain, 3, { color = vec4(0) }, am.ease.out(am.ease.hyperbola)))
win.scene:remove(curtain)
return true
end))
local scene = am.group(
am.scale(1):tag"world_scale" ^ game_state.world,
am.translate(HEX_GRID_CENTER):tag"cursor_translate" ^ make_hex_cursor_node(0, COLORS.TRANSPARENT3),
score,
money,
wave_timer,
send_now_button,
make_top_right_display_node(),
make_game_toolbelt(),
curtain
)
:tag"game"
-- dangling actions run before the main action
scene:late_action(game_action)
--play_track(SOUNDS.MAIN_THEME)
return scene
end
-- |color_f| can be a function that takes a hex and returns a color, or just a color
-- optionally, |action_f| is a function that operates on the group node every frame
function make_hex_cursor_node(radius, color_f, action_f, min_radius)
local color = type(color_f) == "userdata" and color_f or COLORS.TRANSPARENT3
local group = am.group()
if not min_radius then
local map = hex_spiral_map(vec2(0), radius)
for _,h in pairs(map) do
local hexagon = am.circle(hex_to_pixel(h, vec2(HEX_SIZE)), HEX_SIZE, color or color_f(h), 6)
group:append(hexagon)
end
else
for i = min_radius, radius do
local map = hex_ring_map(vec2(0), i)
for _,h in pairs(map) do
local hexagon = am.circle(hex_to_pixel(h, vec2(HEX_SIZE)), HEX_SIZE, color or color_f(h), 6)
group:append(hexagon)
end
end
end
if action_f then
group:action(action_f)
end
return group:tag"cursor"
end
function update_score(diff) game_state.score = game_state.score + diff end
function update_money(diff) game_state.money = game_state.money + diff end
function game_end()
local hmob = table.highest_index(game_state.mobs)
local htower = table.highest_index(game_state.towers)
local hprojectile = table.highest_index(game_state.projectiles)
gui_alert(string.format(
"\nmobs spawned: %d\ntowers built: %d\nprojectiles spawned: %d\n",
hmob or 0, htower or 0, hprojectile or 0
), COLORS.WHITE, 1000)
game_state = {}
game = false
collectgarbage("restart")
end
function game_save()
am.save_state("save", game_serialize(), "json")
gui_alert("succesfully saved!")
end
function game_init(saved_state, seed)
if saved_state then
game_state = game_deserialize(saved_state)
if not game_state then
-- failed to load a save
log("failed to load a save :(")
win.scene:append(main_scene(true, true))
return
end
-- @HACK fixes a bug where loading game state with a tower type selected,
-- but you don't have a built tower cursor node, so hovering a buildable tile throws an error
select_tower_type(nil)
else
game_state = get_initial_game_state(seed)
end
game = true
switch_context(game_scene())
collectgarbage("stop")
end