Nicholas Hayashi 3 years ago
parent
commit
2852e9c85f
  1. 285
      data/towers.lua
  2. 4
      lib/color.lua
  3. 4
      lib/extra.lua
  4. 56
      lib/gui.lua
  5. 71
      lib/random.lua
  6. 23
      main.lua
  7. BIN
      res/img/farm.jpeg
  8. 14
      src/entity.lua
  9. 24
      src/game.lua
  10. 6
      src/grid.lua
  11. 78
      src/hexyz.lua
  12. 2
      src/map-editor.lua
  13. 2
      src/mob.lua
  14. 9
      src/projectile.lua
  15. 574
      src/tower.lua

285
data/towers.lua

@ -64,288 +64,3 @@
| | | |
| --------------------------| -------- | -------------------------------------------------------------- |
]]
return {
{
id = "WALL",
name = "Wall",
placement_rules_text = "Place on Ground",
short_description = "Restricts movement, similar to a mountain.",
texture = TEXTURES.TOWER_WALL,
icon_texture = TEXTURES.TOWER_WALL_ICON,
cost = 10,
range = 0,
fire_rate = 2,
update = false,
},
{
id = "GATTLER",
name = "Gattler",
placement_rules_text = "Place on Ground",
short_description = "Short-range, fast-fire rate single-target tower.",
texture = TEXTURES.TOWER_GATTLER,
icon_texture = TEXTURES.TOWER_GATTLER_ICON,
cost = 20,
weapons = {
{
range = 4,
fire_rate = 0.5,
projectile_type = 3,
}
},
update = function(tower, tower_index)
if not tower.target_index then
-- we should try and acquire a target
-- passive animation
tower.node("rotate").angle = math.wrapf(tower.node("rotate").angle + 0.1 * am.delta_time, math.pi*2)
else
-- should have a target, so we should try and shoot it
if not game_state.mobs[tower.target_index] then
-- the target we have was invalidated
tower.target_index = false
else
-- the target we have is valid
local mob = game_state.mobs[tower.target_index]
local vector = math.normalize(mob.position - tower.position)
if (game_state.time - tower.last_shot_time) > tower.fire_rate then
local projectile = make_and_register_projectile(
tower.hex,
PROJECTILE_TYPE.BULLET,
vector
)
tower.last_shot_time = game_state.time
play_sfx(SOUNDS.HIT1)
end
-- point the cannon at the dude
local theta = math.rad(90) - math.atan((tower.position.y - mob.position.y)/(tower.position.x - mob.position.x))
local diff = tower.node("rotate").angle - theta
tower.node("rotate").angle = -theta + math.pi/2
end
end
end
},
{
id = "HOWITZER",
name = "Howitzer",
placement_rules_text = "Place on Ground, with a 1 space gap between other towers and mountains - walls/moats don't count.",
short_description = "Medium-range, medium fire-rate area of effect artillery tower.",
texture = TEXTURES.TOWER_HOWITZER,
icon_texture = TEXTURES.TOWER_HOWITZER_ICON,
cost = 50,
weapons = {
{
range = 6,
fire_rate = 4,
projectile_type = 1,
}
},
placement_f = function(blocked, has_water, has_mountain, has_ground, hex)
local has_mountain_neighbour = false
local has_non_wall_non_moat_tower_neighbour = false
for _,h in pairs(hex_neighbours(hex)) do
local towers = towers_on_hex(h)
local wall_on_hex = false
has_non_wall_non_moat_tower_neighbour = table.find(towers, function(tower)
if tower.type == TOWER_TYPE.WALL then
wall_on_hex = true
return false
elseif tower.type == TOWER_TYPE.MOAT then
return false
end
return true
end)
if has_non_wall_non_moat_tower_neighbour then
break
end
local tile = hex_map_get(game_state.map, h)
if not wall_on_hex and tile and tile.elevation >= 0.5 then
has_mountain_neighbour = true
break
end
end
return not (blocked or has_water or has_mountain or has_mountain_neighbour or has_non_wall_non_moat_tower_neighbour)
end,
update = function(tower, tower_index)
if not tower.target_index then
-- we don't have a target
for index,mob in pairs(game_state.mobs) do
if mob then
local d = math.distance(mob.hex, tower.hex)
if d <= tower.range then
tower.target_index = index
break
end
end
end
-- passive animation
tower.node("rotate").angle = math.wrapf(tower.node("rotate").angle + 0.1 * am.delta_time, math.pi*2)
else
-- we should have a target
-- @NOTE don't compare to false, empty indexes appear on game reload
if not game_state.mobs[tower.target_index] then
-- the target we have was invalidated
tower.target_index = false
else
-- the target we have is valid
local mob = game_state.mobs[tower.target_index]
local vector = math.normalize(mob.position - tower.position)
if (game_state.time - tower.last_shot_time) > tower.fire_rate then
local projectile = make_and_register_projectile(
tower.hex,
PROJECTILE_TYPE.SHELL,
vector
)
-- @HACK, the projectile will explode if it encounters something taller than it,
-- but the tower it spawns on quickly becomes taller than it, so we just pad it
-- if it's not enough the shell explodes before it leaves its spawning hex
projectile.props.z = tower.props.z + 0.1
tower.last_shot_time = game_state.time
play_sfx(SOUNDS.EXPLOSION2)
end
-- point the cannon at the dude
local theta = math.rad(90) - math.atan((tower.position.y - mob.position.y)/(tower.position.x - mob.position.x))
local diff = tower.node("rotate").angle - theta
tower.node("rotate").angle = -theta + math.pi/2
end
end
end
},
{
id = "REDEYE",
name = "Redeye",
placement_rules_text = "Place on Mountains.",
short_description = "Long-range, penetrating high-velocity laser tower.",
texture = TEXTURES.TOWER_REDEYE,
icon_texture = TEXTURES.TOWER_REDEYE_ICON,
cost = 75,
weapons = {
{
range = 9,
fire_rate = 3,
projectile_type = 2,
}
},
placement_f = function(blocked, has_water, has_mountain, has_ground, hex)
return not blocked and has_mountain
end,
update = function(tower, tower_index)
if not tower.target_index then
for index,mob in pairs(game_state.mobs) do
if mob then
local d = math.distance(mob.hex, tower.hex)
if d <= tower.range then
tower.target_index = index
break
end
end
end
else
if not game_state.mobs[tower.target_index] then
tower.target_index = false
elseif (game_state.time - tower.last_shot_time) > tower.fire_rate then
local mob = game_state.mobs[tower.target_index]
make_and_register_projectile(
tower.hex,
PROJECTILE_TYPE.LASER,
math.normalize(mob.position - tower.position)
)
tower.last_shot_time = game_state.time
vplay_sfx(SOUNDS.LASER2)
end
end
end
},
{
id = "MOAT",
name = "Moat",
placement_rules_text = "Place on Ground",
short_description = "Restricts movement, similar to water.",
texture = TEXTURES.TOWER_MOAT,
icon_texture = TEXTURES.TOWER_MOAT_ICON,
cost = 10,
range = 0,
fire_rate = 2,
height = -1,
update = false
},
{
id = "RADAR",
name = "Radar",
placement_rules_text = "n/a",
short_description = "Doesn't do anything right now :(",
texture = TEXTURES.TOWER_RADAR,
icon_texture = TEXTURES.TOWER_RADAR_ICON,
cost = 100,
range = 0,
fire_rate = 1,
update = false
},
{
id = "LIGHTHOUSE",
name = "Lighthouse",
placement_rules_text = "Place on Ground, adjacent to Water or Moats",
short_description = "Attracts nearby mobs; temporarily redirects their path",
texture = TEXTURES.TOWER_LIGHTHOUSE,
icon_texture = TEXTURES.TOWER_LIGHTHOUSE_ICON,
cost = 150,
range = 7,
fire_rate = 1,
placement_f = function(blocked, has_water, has_mountain, has_ground, hex)
local has_water_neighbour = false
for _,h in pairs(hex_neighbours(hex)) do
local tile = hex_map_get(game_state.map, h)
if tile and tile.elevation < -0.5 then
has_water_neighbour = true
break
end
end
return not blocked
and not has_mountain
and not has_water
and has_water_neighbour
end,
update = function(tower, tower_index)
-- check if there's a mob on a hex in our perimeter
for _,h in pairs(tower.perimeter) do
local mobs = mobs_on_hex(h)
for _,m in pairs(mobs) do
if not m.path and not m.seen_lighthouse then
-- @TODO only attract the mob if its frame target (direction vector)
-- is within some angle range...? if the mob is heading directly away from the tower, then
-- the lighthouse shouldn't do much
local path, made_it = hex_Astar(game_state.map, tower.hex, m.hex, grid_neighbours, grid_cost, grid_heuristic)
if made_it then
m.path = path
m.seen_lighthouse = true -- right now mobs don't care about lighthouses if they've already seen one.
end
end
end
end
end
},
}

4
lib/color.lua

@ -24,6 +24,8 @@ COLORS = {
SUNRAY = vec4(228/255, 179/255, 99/255, 1),
GREEN_YELLOW = vec4(204/255, 255/255, 102/255, 1),
BLUE = vec4(50/255, 50/255, 180/255, 1),
MAGENTA = vec4(183/255, 0/255, 213/255, 1)
MAGENTA = vec4(183/255, 0/255, 213/255, 1),
TAN1 = vec4(255/255, 216/255, 150/255, 1)
}

4
lib/extra.lua

@ -19,6 +19,10 @@ function math.lerp(v1, v2, t)
return v1 * t + v2 * (1 - t)
end
-- manually found the smallest number, doesn't make sense to me why, but hey it's one less than a power of two which is probably significant
-- pretty sure IEEE-754's smallest value is less than this, 32bit or 64bit
math.SMALLEST_NUMBER_ABOVE_0 = 2 ^ (-1023)
-- don't use this with sparse arrays
function table.rchoice(t)
return t[math.floor(math.random() * #t) + 1]

56
lib/gui.lua

@ -93,16 +93,24 @@ function gui_make_button(args)
return scene
end
function gui_textfield(
position,
dimensions,
max,
disallowed_chars
-- args {
-- position vec2
-- dimensions vec2
-- max number
-- padding number
-- validate function(string) -> bool
-- onchange function(new_value) -> void
-- }
function gui_make_textfield(
args
)
local args = args or {}
local position = args.position or vec2(0)
local dimensions = args.dimensions or vec2(100, 40)
local validate = args.validate or function(string) return true end
local width, height = dimensions.x, dimensions.y
local disallowed_chars = disallowed_chars or {}
local max = max or 10
local padding = padding or 6
local max = args.max or 10
local padding = args.padding or 6
local half_width = width/2
local half_height = height/2
@ -114,13 +122,26 @@ function gui_textfield(
local back_rect = am.rect(x1 - padding/2, y1, x2, y2 + padding/2, vec4(0, 0, 0, 1))
local front_rect = am.rect(x1, y1, x2, y2, vec4(0.4))
local function blink_cursor(cursor)
while true do
am.wait(am.delay(0.4))
cursor.color = vec4(0)
am.wait(am.delay(0.4))
cursor.color = vec4(0, 0, 0, 1)
end
end
local group = am.group{
back_rect,
front_rect,
am.translate(-width/2 + padding, 0) ^ am.scale(2) ^ am.text("", vec4(0, 0, 0, 1), "left"),
am.translate(-width/2 + padding, -8) ^ am.line(vec2(0, 0), vec2(16, 0), 2, vec4(0, 0, 0, 1))
am.translate(position + vec2(-width/2 + padding, 0)) ^ am.group(
am.scale(2) ^ am.text("", vec4(0, 0, 0, 1), "left"),
(am.translate(0, -8) ^ am.line(vec2(0, 0), vec2(16, 0), 2, vec4(0, 0, 0, 1)):action(coroutine.create(blink_cursor))):tag"cursor"
)
}
group"text".text = "";
group:action(function(self)
local keys = win:keys_pressed()
if #keys == 0 then return end
@ -192,8 +213,10 @@ function gui_textfield(
-- @NOTE this doesn't preserve the order of chars in the array so if
-- someone presses a the key "a" then the backspace key in the same frame, in that order
-- the backspace occurs first
self"text".text = self"text".text:sub(1, self"text".text:len() - 1)
if self"text".text:len() ~= 0 then
self"text".text = self"text".text:sub(1, self"text".text:len() - 1)
self"cursor".position2d = self"cursor".position2d - vec2(9 * 2, 0)
end
elseif k == "tab" then
-- @TODO
@ -206,15 +229,20 @@ function gui_textfield(
end
for _,c in pairs(chars) do
if not disallowed_chars[c] then
if validate(self"text".text .. c) then
if self"text".text:len() <= max then
self"text".text = self"text".text .. c
self"cursor".position2d = self"cursor".position2d + vec2(9 * 2, 0)
end
end
end
end)
return group
function get_value()
return group"text".text
end
return group, get_value
end
function gui_open_modal()

71
lib/random.lua

@ -1,43 +1,56 @@
-- seed the random number generator with the current time
math.randomseed(os.clock())
-- https://stackoverflow.com/a/32387452/12464892
local function bitwise_and(a, b)
local result = 0
local bit = 1
while a > 0 and b > 0 do
if a % 2 == 1 and b % 2 == 1 then
result = result + bit
end
bit = bit * 2 -- shift left
a = math.floor(a/2) -- shift-right
b = math.floor(b/2)
end
return result
end
-- https://stackoverflow.com/a/20177466/12464892
--local A1, A2 = 727595, 798405 -- 5^17=D20*A1+A2
--local D20, D40 = 1048576, 1099511627776 -- 2^20, 2^40
--local X1, X2 = 0, 1
--local function rand()
-- local U = X2*A2
-- local V = (X1*A2 + X2*A1) % D20
-- V = (V*D20 + U) % D40
-- X1 = math.floor(V/D20)
-- X2 = V - X1*D20
-- return V/D40
--end
--
--local SEED_BOUNDS = 2^20 - 1
--math.randomseed = function(seed)
-- local v = math.clamp(math.abs(seed), 0, SEED_BOUNDS)
-- X1 = v
-- X2 = v + 1
--end
local A1, A2 = 727595, 798405 -- 5^17=D20*A1+A2
local D20, D40 = 1048576, 1099511627776 -- 2^20, 2^40
local X1, X2 = 0, 1
local function rand()
local U = X2*A2
local V = (X1*A2 + X2*A1) % D20
V = (V*D20 + U) % D40
X1 = math.floor(V/D20)
X2 = V - X1*D20
return V/D40
end
local SEED_BOUNDS = 2^20 - 1
math.randomseed = function(seed)
RANDOM_CALLS_COUNT = 0
-- to enable allowing the random number generator's state to be restored post-load (game-deserialize),
-- we count the number of times we call math.random(), and on deserialize, seed the random
-- number generator, and then discard |count| calls.
local R = math.random
-- 0 <= X1 <= 2^20-1, 1 <= X2 <= 2^20-1 (must be odd!)
-- ensure the number is odd, and within the bounds of
local seed = bitwise_and(seed, 1)
local v = math.clamp(math.abs(seed), 0, SEED_BOUNDS)
X1 = v
X2 = v + 1
end
RANDOM_CALLS_COUNT = 0
local R = math.random
local function random(n, m)
RANDOM_CALLS_COUNT = RANDOM_CALLS_COUNT + 1
if n then
if m then
return R(n, m)
return (rand() + n) * m
else
return R(n)
return rand() * n
end
else
return R()
return rand()
end
end
@ -74,3 +87,7 @@ end
-- return k - 1
--end
-- seed the random number generator with the current time
-- os.clock() is better if the program has been running for a little bit.
math.randomseed(os.time())

23
main.lua

@ -113,6 +113,7 @@ function main_scene(do_backdrop, do_logo)
end
end
group:append(hex_backdrop)
else
group:append(
pack_texture_into_sprite(TEXTURES.CURTAIN, win.width, win.height)
@ -149,11 +150,25 @@ function main_scene(do_backdrop, do_logo)
group:append(logo)
end
local seed_textfield, get_seed_textfield_value = gui_make_textfield{
position = vec2(win.left + 150, 50),
dimensions = vec2(200, 40),
max = 9,
validate = function(string)
return not string.match(string, "%D")
end,
}
group:append(
seed_textfield
)
local main_scene_options = {
false,
{
texture = TEXTURES.NEW_GAME_HEX,
action = game_init
action = function()
game_init(nil, tonumber(get_seed_textfield_value()))
end
},
false,
false,
@ -192,10 +207,9 @@ function main_scene(do_backdrop, do_logo)
false
}
group:append(make_scene_menu(main_scene_options))
group:append(make_scene_menu(main_scene_options, "main_menu"))
group:action(main_action)
return group
end
@ -272,8 +286,7 @@ function switch_context(scene, action)
end
function init()
load_entity_specs()
init_entity_specs()
switch_context(main_scene(true, true))
end

BIN
res/img/farm.jpeg

After

Width: 512  |  Height: 512  |  Size: 164 KiB

14
src/entity.lua

@ -8,10 +8,8 @@ entity structure:
update - function - runs every frame with itself and its index in some array as an argument
node - node - scene graph node - should be initialized by caller after, though all entities have a node
type - enum - sub type - unset if 'basic' entity
type - enum - sub type
props - table - table of properties specific to this entity subtype
... - any - a bunch of other shit depending on what entity type it is
}
--]]
function make_basic_entity(hex, update_f, position)
@ -32,8 +30,6 @@ function make_basic_entity(hex, update_f, position)
end
entity.update = update_f
entity.node = false -- set by caller
entity.type = false -- set by caller
entity.props = {}
return entity
@ -58,10 +54,6 @@ function do_entity_updates()
do_projectile_updates()
end
function load_entity_specs()
resolve_tower_specs("data/towers.lua")
end
function entity_basic_devectored_copy(entity)
local copy = table.shallow_copy(entity)
copy.position = { copy.position.x, copy.position.y }
@ -78,3 +70,7 @@ function entity_basic_json_parse(json_string)
return entity
end
function init_entity_specs()
init_tower_specs()
end

24
src/game.lua

@ -154,6 +154,7 @@ end
local function game_deserialize(json_string)
local new_game_state = am.parse_json(json_string)
log(new_game_state.RANDOM_CALLS_COUNT)
if new_game_state.version ~= version then
gui_alert("loading incompatible old save data.\nstarting a fresh game instead.", nil, 10)
@ -161,6 +162,15 @@ local function game_deserialize(json_string)
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.
for i = 0, new_game_state.RANDOM_CALLS_COUNT do
math.random()
end
log(RANDOM_CALLS_COUNT)
new_game_state.world = make_hex_grid_scene(new_game_state.map, true)
new_game_state.seed = nil
@ -206,6 +216,7 @@ 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
@ -218,7 +229,6 @@ local function game_serialize()
--
-- 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
@ -562,7 +572,7 @@ local function make_game_toolbelt()
toolbelt("toolbelt_select_square"):action(am.tween(0.1, { position2d = new_position }))
end
win.scene:replace("cursor", get_tower_cursor(tower_type):tag"cursor")
win.scene:replace("cursor", get_tower_cursor(tower_type))
play_sfx(SOUNDS.SELECT1)
else
@ -571,7 +581,7 @@ local function make_game_toolbelt()
-- de-selecting currently selected tower if any
toolbelt("toolbelt_select_square").hidden = true
win.scene:replace("cursor", make_hex_cursor_node(0, COLORS.TRANSPARENT3):tag"cursor")
win.scene:replace("cursor", make_hex_cursor_node(0, COLORS.TRANSPARENT3))
end
end
@ -652,7 +662,7 @@ local function game_scene()
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):tag"cursor",
am.translate(HEX_GRID_CENTER):tag"cursor_translate" ^ make_hex_cursor_node(0, COLORS.TRANSPARENT3),
score,
money,
wave_timer,
@ -698,7 +708,7 @@ function make_hex_cursor_node(radius, color_f, action_f, min_radius)
group:action(action_f)
end
return group
return group:tag"cursor"
end
function update_score(diff) game_state.score = game_state.score + diff end
@ -724,7 +734,7 @@ function game_save()
gui_alert("succesfully saved!")
end
function game_init(saved_state)
function game_init(saved_state, seed)
if saved_state then
game_state = game_deserialize(saved_state)
@ -739,7 +749,7 @@ function game_init(saved_state)
-- 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()
game_state = get_initial_game_state(seed)
end
game = true

6
src/grid.lua

@ -64,7 +64,7 @@ function map_elevation_to_tile_type(elevation)
elseif elevation < 0.5 then -- med-high elevation
return "Ground - Dirt"
elseif elevation < 1 then -- high elevation
elseif elevation <= 1 then -- high elevation
return "Mountain"
else
@ -134,7 +134,7 @@ end
function building_tower_breaks_flow_field(tower_type, hex)
local original_elevations = {}
local all_impassable = true
local hexes = hex_spiral_map(hex, get_tower_size(tower_type))
local hexes = hex_spiral_map(hex, get_tower_size(tower_type) - 1)
for _,h in pairs(hexes) do
local tile = hex_map_get(game_state.map, h)
@ -178,7 +178,7 @@ function map_elevation_to_color(elevation)
elseif elevation < 0.5 then -- med-high elevation
return math.lerp(COLORS.DIRT, COLORS.GRASS, elevation + 0.5){ a = (elevation + 1.6) / 2 + 0.3 }
elseif elevation < 1 then -- high elevation
elseif elevation <= 1 then -- high elevation
return COLORS.MOUNTAIN{ ra = elevation }
else

78
src/hexyz.lua

@ -285,8 +285,9 @@ function hex_map_set(map, hex, y, v)
end
-- Returns Unordered Parallelogram-Shaped Map of |width| and |height| with Simplex Noise
-- note that this is the default behavior if you just iterate two dimensions and place hexes naively
function hex_parallelogram_map(width, height, seed)
local seed = seed or math.random(width * height)
local seed = seed or os.time()
local map = {}
for i = 0, width - 1 do
@ -327,7 +328,7 @@ end
-- Returns Unordered Triangular (Equilateral) Map of |size| with Simplex Noise
function hex_triangular_map(size, seed)
local seed = seed or math.random(size * math.cos(size) / 2)
local seed = seed or os.time()
local map = {}
for i = 0, size do
@ -365,11 +366,25 @@ function hex_triangular_map(size, seed)
}})
end
function hex_hexagonal_map_noise(i, j, radius, seed)
local idelta = i / radius
local jdelta = j / radius
local noise = 0
for oct = 1, 6 do
local f = 2/3^oct
local l = 2^oct
local pos = vec2(idelta + seed * radius, jdelta + seed * radius)
noise = noise + f * math.simplex(pos * l)
end
return noise
end
-- Returns Unordered Hexagonal Map of |radius| with Simplex Noise
function hex_hexagonal_map(radius, seed)
-- @NOTE usually i try and generate a seed within the range of the area of the map, but for lua's math.random starts to exhibit some really weird behavior
-- when you seed it with a high integer value, so I changed 'radius^2' to just 'radius' here.
local seed = seed or math.random(math.floor(2 * math.pi * radius))
local seed = seed or os.time()
local size = 0
local map = {}
@ -380,20 +395,7 @@ function hex_hexagonal_map(radius, seed)
local j2 = math.min(radius, -i + radius)
for j = j1, j2 do
-- Calculate Noise
local idelta = i / radius
local jdelta = j / radius
local noise = 0
for oct = 1, 6 do
local f = 2/3^oct
local l = 2^oct
local pos = vec2(idelta + seed * radius, jdelta + seed * radius)
noise = noise + f * math.simplex(pos * l)
end
map[i][j] = noise
map[i][j] = hex_hexagonal_map_noise(i, j, radius, seed)
size = size + 1
end
end
@ -415,10 +417,25 @@ function hex_hexagonal_map(radius, seed)
}})
end
function hex_rectangular_map_noise(i, j, width, height, seed)
local idelta = i / width
local jdelta = j / height
local noise = 0
for oct = 1, 6 do
local f = 2/3^oct
local l = 2^oct
local pos = vec2(idelta + seed * width, jdelta + seed * height)
noise = noise + f * math.simplex(pos * l)
end
return noise
end
-- Returns Unordered Rectangular Map of |width| and |height| with Simplex Noise
function hex_rectangular_map(width, height, orientation, seed, do_generate_noise)
local orientation = orientation or HEX_DEFAULT_ORIENTATION
local seed = seed or math.random(width * height)
local seed = seed or os.time()
local map = {}
if orientation == HEX_ORIENTATION.FLAT then
@ -426,24 +443,13 @@ function hex_rectangular_map(width, height, orientation, seed, do_generate_noise
map[i] = {}
for j = 0, height - 1 do
local noise = 0
if do_generate_noise then
-- begin to calculate noise
local idelta = i / width
local jdelta = j / height
local noise = 0
for oct = 1, 6 do
local f = 2/3^oct
local l = 2^oct
local pos = vec2(idelta + seed * width, jdelta + seed * height)
noise = noise + f * math.simplex(pos * l)
end
j = j - math.floor(i/2) -- this is what makes it rectangular
hex_map_set(map, i, j, noise)
else
j = j - math.floor(i/2) -- this is what makes it rectangular
hex_map_set(map, i, j, 0)
noise = hex_rectangular_map_noise(i, j, width, height, seed)
end
j = j - math.floor(i/2) -- this is what makes it rectangular
hex_map_set(map, i, j, noise)
end
end
elseif orientation == HEX_ORIENTATION.POINTY then

2
src/map-editor.lua

@ -121,7 +121,7 @@ function map_editor_action()
-- fine tune tile's elevation with mouse wheel
local mouse_wheel_delta = win:mouse_wheel_delta().y / 100
if map_editor_state.selected_tile and mouse_wheel_delta ~= 0 then
map_editor_state.selected_tile.elevation = math.clamp(map_editor_state.selected_tile.elevation + mouse_wheel_delta, -1, 1)
map_editor_state.selected_tile.elevation = math.clamp(map_editor_state.selected_tile.elevation + mouse_wheel_delta, -1, 1 - math.SMALLEST_NUMBER_ABOVE_0)
--map_editor_state.selected_tile.node("circle").color = map_elevation_color(map_editor_state.selected_tile.elevation)
end
end

2
src/mob.lua

@ -263,7 +263,7 @@ local function update_mob_spooder(mob, mob_index)
if mob.frame_target then
-- do movement
-- it's totally possible that the target we have was invalidated by a tower placed this frame,
-- it's possible that the target we have was invalidated by a tower placed this frame,
-- or between when we last calculated this target and now
-- check for that now
if mob_can_pass_through(mob, mob.frame_target) then

9
src/projectile.lua

@ -81,7 +81,6 @@ local function update_projectile_shell(projectile, projectile_index)
projectile.node.position2d = projectile.position
projectile.hex = pixel_to_hex(projectile.position, vec2(HEX_SIZE))
projectile.props.z = projectile.props.z - SHELL_GRAVITY * am.delta_time
-- check if we hit something
-- get a list of hexes that could have something we could hit on them
@ -106,14 +105,6 @@ local function update_projectile_shell(projectile, projectile_index)
end
end
local tile = hex_map_get(game_state.map, projectile.hex)
if tile and tile.elevation >= projectile.props.z then
--do_explode = true
elseif projectile.props.z <= 0 then
--do_explode = true
end
if do_explode then
for index,mob in pairs(mobs) do
local damage = (1 / (math.distance(mob.position, projectile.position) / (HEX_PIXEL_WIDTH * 2))) * projectile.damage

574
src/tower.lua

@ -1,235 +1,389 @@
function default_tower_placement_f(blocked, has_water, has_mountain, has_ground, hex)
local function default_tower_placement_f(blocked, has_water, has_mountain, has_ground, hex)
return not (blocked or has_water or has_mountain)
end
function default_weapon_target_acquisition_f(tower, tower_index)
local function default_weapon_target_acquisition_f(tower, tower_index)
for index,mob in pairs(game_state.mobs) do
if mob then
local d = math.distance(mob.hex, tower.hex)
if d <= tower.range then
tower.target_index = index
break
for _,w in pairs(tower.weapons) do
if d <= w.range then
tower.target_index = index
return
end
end
end
end
end
function default_tower_target_acquisition_f(tower, tower_index)
-- first, find out if a tower even *should*, acquire a target.
-- a tower should try and acquire a target if atleast one of its weapons that could be shooting, isn't
local function default_handle_target_f(tower, tower_index, mob)
-- the target we have is valid
local vector = math.normalize(mob.position - tower.position)
for _,w in pairs(tower.weapons) do
if (game_state.time - w.last_shot_time) > w.fire_rate then
local projectile = make_and_register_projectile(
tower.hex,
w.projectile_type,
vector
)
w.last_shot_time = game_state.time
play_sfx(w.hit_sound)
end
end
end
local function default_tower_update_f(tower, tower_index)
if not tower.target_index then
for index,mob in pairs(game_state.mobs) do
if mob then
local d = math.distance(mob.hex, tower.hex)
if d <= tower.range then
tower.target_index = index
break
end
end
-- try and acquire a target
default_weapon_target_acquisition_f(tower, tower_index)
else
-- check if our current target is invalidated
local mob = game_state.mobs[tower.target_index]
if not mob then
tower.target_index = false
else
-- do what we should do with the target
default_handle_target_f(tower, tower_index, mob)
end
end
end
function default_tower_update_f(tower, tower_index)
local function default_tower_weapon_target_acquirer_f(tower, tower_index)
end
-- load tower spec file
TOWER_SPECS = {}
TOWER_TYPE = {}
function get_tower_spec(tower_type)
return TOWER_SPECS[tower_type]
end
function get_tower_name(tower_type)
return TOWER_SPECS[tower_type].name
end
function get_tower_placement_rules_text(tower_type)
return TOWER_SPECS[tower_type].placement_rules_text
end
function get_tower_short_description(tower_type)
return TOWER_SPECS[tower_type].short_description
local function make_tower_sprite(t)
return pack_texture_into_sprite(t.texture, HEX_PIXEL_WIDTH, HEX_PIXEL_HEIGHT)
end
function get_tower_texture(tower_type)
return TOWER_SPECS[tower_type].texture
end
function get_tower_icon_texture(tower_type)
return TOWER_SPECS[tower_type].icon_texture
end
function get_tower_cost(tower_type)
return TOWER_SPECS[tower_type].cost
end
function get_tower_range(tower_type)
return TOWER_SPECS[tower_type].range
end
function get_tower_fire_rate(tower_type)
return TOWER_SPECS[tower_type].fire_rate
end
function get_tower_size(tower_type)
return TOWER_SPECS[tower_type].size
end
function resolve_tower_specs(spec_file_path)
local spec_file = am.load_script(spec_file_path)
local error_message
if spec_file then
local status, tower_specs = pcall(spec_file)
if status then
-- lua managed to run the file without syntax/runtime errors
-- it's not garunteed to be what we want yet. check:
local type_ = type(tower_specs)
if type_ ~= "table" then
error_message = "tower spec file should return a table, but we got " .. type_
function init_tower_specs()
local base_tower_specs = {
{
id = "WALL",
name = "Wall",
placement_rules_text = "Place on Ground",
short_description = "Restricts movement, similar to a mountain.",
texture = TEXTURES.TOWER_WALL,
icon_texture = TEXTURES.TOWER_WALL_ICON,
cost = 10,
range = 0,
fire_rate = 2,
update_f = false,
make_node_f = function(self)
return am.circle(vec2(0), HEX_SIZE, COLORS.TAN1{a=0.6}, 6)
end
-- if we're here, then we're going to assume the spec file is valid, no matter how weird it is
-- last thing to do before returning is fill in missing default values
for i,tower_spec in pairs(tower_specs) do
if not tower_spec.size then
tower_spec.size = 1
end
if not tower_spec.height then
tower_spec.height = 1
},
{
id = "GATTLER",
name = "Gattler",
placement_rules_text = "Place on Ground",
short_description = "Short-range, fast-fire rate single-target tower.",
texture = TEXTURES.TOWER_GATTLER,
icon_texture = TEXTURES.TOWER_GATTLER_ICON,
cost = 20,
weapons = {
{
projectile_type = PROJECTILE_TYPE.BULLET,
range = 4,
fire_rate = 0.5,
hit_sound = SOUNDS.HIT1,
}
},
make_node_f = function(self)
return am.group(
am.circle(vec2(0), HEX_SIZE - 4, COLORS.VERY_DARK_GRAY, 5),
am.rotate(game_state.time or 0)
^ pack_texture_into_sprite(self.texture, HEX_PIXEL_HEIGHT*1.5, HEX_PIXEL_WIDTH*2, COLORS.GREEN_YELLOW)
)
end,
update_f = function(tower, tower_index)
if not tower.target_index then
-- we should try and acquire a target
default_weapon_target_acquisition_f(tower, tower_index)
-- passive animation
tower.node("rotate").angle = math.wrapf(tower.node("rotate").angle + 0.1 * am.delta_time, math.pi*2)
else
-- should have a target, so we should try and shoot it
local mob = game_state.mobs[tower.target_index]
if not mob then
-- the target we have was invalidated
tower.target_index = false
else
default_handle_target_f(tower, tower_index, mob)
-- point the cannon at the dude
local theta = math.rad(90) - math.atan((tower.position.y - mob.position.y)/(tower.position.x - mob.position.x))
local diff = tower.node("rotate").angle - theta
tower.node("rotate").angle = -theta + math.pi/2
end
end
end
},
{
id = "HOWITZER",
name = "Howitzer",
placement_rules_text = "Place on Ground, with a 1 space gap between other towers and mountains - walls/moats don't count.",
short_description = "Medium-range, medium fire-rate area of effect artillery tower.",
texture = TEXTURES.TOWER_HOWITZER,
icon_texture = TEXTURES.TOWER_HOWITZER_ICON,
cost = 50,
weapons = {
{
projectile_type = PROJECTILE_TYPE.SHELL,
range = 6,
fire_rate = 4,
hit_sound = SOUNDS.EXPLOSION2
}
},
make_node_f = function(self)
return am.group(
am.circle(vec2(0), HEX_SIZE - 4, COLORS.VERY_DARK_GRAY, 6),
am.rotate(game_state.time or 0) ^ am.group(
pack_texture_into_sprite(self.texture, HEX_PIXEL_HEIGHT*1.5, HEX_PIXEL_WIDTH*2) -- CHONK
)
)
end,
placement_f = function(blocked, has_water, has_mountain, has_ground, hex)
local has_mountain_neighbour = false
local has_non_wall_non_moat_tower_neighbour = false
for _,h in pairs(hex_neighbours(hex)) do
local towers = towers_on_hex(h)
local wall_on_hex = false
has_non_wall_non_moat_tower_neighbour = table.find(towers, function(tower)
if tower.type == TOWER_TYPE.WALL then
wall_on_hex = true
return false
elseif tower.type == TOWER_TYPE.MOAT then
return false
end
if not tower_spec.update_f then
tower_spec.update_f = default_tower_update_f
end
return true
end)
if not tower_spec.weapons then
tower_spec.weapons = {}
end
for i,w in pairs(tower_spec.weapons) do
if not w.min_range then
w.min_range = 0
if has_non_wall_non_moat_tower_neighbour then
break
end
if not w.target_acquisition_f then
w.target_acquisition_f = default_weapon_target_acquisition_f
local tile = hex_map_get(game_state.map, h)
if not wall_on_hex and tile and tile.elevation >= 0.5 then
has_mountain_neighbour = true
break
end
end
if not tower_spec.placement_f then
tower_spec.placement_f = default_tower_placement_f
return not (blocked or has_water or has_mountain or has_mountain_neighbour or has_non_wall_non_moat_tower_neighbour)
end,
update_f = function(tower, tower_index)
if not tower.target_index then
default_weapon_target_acquisition_f(tower, tower_index)
-- passive animation
tower.node("rotate").angle = math.wrapf(tower.node("rotate").angle + 0.1 * am.delta_time, math.pi*2)
else
-- we should have a target
local mob = game_state.mobs[tower.target_index]
if not mob then
-- the target we have was invalidated
tower.target_index = false
else
default_handle_target_f(tower, tower_index, mob)
-- point the cannon at the dude
local theta = math.rad(90) - math.atan((tower.position.y - mob.position.y)/(tower.position.x - mob.position.x))
local diff = tower.node("rotate").angle - theta
tower.node("rotate").angle = -theta + math.pi/2
end
end
-- resolve a tower's visual range - if not provided we should use the largest range among weapons it has
if not tower_spec.visual_range then
local largest_range = 0
for i,w in pairs(tower_spec.weapons) do
if w.range > largest_range then
largest_range = w.range
end
end
},
{
id = "REDEYE",
name = "Redeye",
placement_rules_text = "Place on Mountains.",
short_description = "Long-range, penetrating high-velocity laser tower.",
texture = TEXTURES.TOWER_REDEYE,
icon_texture = TEXTURES.TOWER_REDEYE_ICON,
cost = 75,
weapons = {
{
projectile_type = PROJECTILE_TYPE.LASER,
range = 9,
fire_rate = 3,
hit_sound = SOUNDS.LASER2
}
},
make_node_f = function(self)
return make_tower_sprite(self)
end,
placement_f = function(blocked, has_water, has_mountain, has_ground, hex)
return not blocked and has_mountain
end,
update_f = default_tower_update_f
},
{
id = "MOAT",
name = "Moat",
placement_rules_text = "Place on Ground",
short_description = "Restricts movement, similar to water.",
texture = TEXTURES.TOWER_MOAT,
icon_texture = TEXTURES.TOWER_MOAT_ICON,
cost = 10,
range = 0,
fire_rate = 2,
height = -1,
make_node_f = function(self)
return am.circle(vec2(0), HEX_SIZE, COLORS.WATER{a=1}, 6)
end,
update_f = false
},
{
id = "RADAR",
name = "Radar",
placement_rules_text = "n/a",
short_description = "Doesn't do anything right now :(",
texture = TEXTURES.TOWER_RADAR,
icon_texture = TEXTURES.TOWER_RADAR_ICON,
cost = 100,
range = 0,
fire_rate = 1,
make_node_f = function(self)
return make_tower_sprite(self)
end,
update_f = false
},
{
id = "LIGHTHOUSE",
name = "Lighthouse",
placement_rules_text = "Place on Ground, adjacent to Water or Moats",
short_description = "Attracts nearby mobs; temporarily redirects their path",
texture = TEXTURES.TOWER_LIGHTHOUSE,
icon_texture = TEXTURES.TOWER_LIGHTHOUSE_ICON,
cost = 150,
range = 7,
fire_rate = 1,
make_node_f = function(self)
return am.group(
make_tower_sprite(self),
am.particles2d{
source_pos = vec2(0, 12),
source_pos_var = vec2(2),
start_size = 1,
start_size_var = 1,
end_size = 1,
end_size_var = 1,
angle = 0,
angle_var = math.pi,
speed = 1,
speed_var = 2,
life = 10,
life_var = 1,
start_color = COLORS.WHITE,
start_color_var = vec4(0.1, 0.1, 0.1, 1),
end_color = COLORS.SUNRAY,
end_color_var = vec4(0.1),
emission_rate = 4,
start_particles = 4,
max_particles = 200,
warmup_time = 5
}
)
end,
placement_f = function(blocked, has_water, has_mountain, has_ground, hex)
local has_water_neighbour = false
for _,h in pairs(hex_neighbours(hex)) do
local tile = hex_map_get(game_state.map, h)
if tile and tile.elevation < -0.5 then
has_water_neighbour = true
break
end
tower_spec.visual_range = largest_range
end
-- do the same for the minimum visual range
if not tower_spec.min_visual_range then
local largest_minimum_range = 0
for i,w in pairs(tower_spec.weapons) do
if w.min_range > largest_minimum_range then
largest_minimum_range = w.min_range
return not blocked
and not has_mountain
and not has_water
and has_water_neighbour
end,
update_f = function(tower, tower_index)
-- check if there's a mob on a hex in our perimeter
for _,h in pairs(tower.perimeter) do
local mobs = mobs_on_hex(h)
for _,m in pairs(mobs) do
if not m.path and not m.seen_lighthouse then
-- @TODO only attract the mob if its frame target (direction vector)
-- is within some angle range...? if the mob is heading directly away from the tower, then
-- the lighthouse shouldn't do much
local path, made_it = hex_Astar(game_state.map, tower.hex, m.hex, grid_neighbours, grid_cost, grid_heuristic)
if made_it then
m.path = path
m.seen_lighthouse = true -- right now mobs don't care about lighthouses if they've already seen one.
end
end
end
tower_spec.min_visual_range = largest_minimum_range
end
end
TOWER_SPECS = tower_specs
for i,t in pairs(TOWER_SPECS) do
TOWER_TYPE[t.id] = i
end
build_tower_cursors()
return
else
-- runtime error - including syntax errors
error_message = result
end
else
-- filesystem/permissions related error - couldn't load the file
error_message = "couldn't load the file"
end
log(error_message)
-- @TODO no matter what fucked up, we should load defaults
TOWER_SPECS = {}
build_tower_cursors()
end
local function default_tower_weapon_target_acquirer(tower, tower_index)
end
local function make_tower_sprite(tower_type)
return pack_texture_into_sprite(get_tower_texture(tower_type), HEX_PIXEL_WIDTH, HEX_PIXEL_HEIGHT)
end
function make_tower_node(tower_type)
-- @TODO move to tower spec
if tower_type == 4 then
return make_tower_sprite(tower_type)
elseif tower_type == 2 then
return am.group{
am.circle(vec2(0), HEX_SIZE - 4, COLORS.VERY_DARK_GRAY, 5),
am.rotate(game_state.time or 0)
^ pack_texture_into_sprite(TEXTURES.TOWER_HOWITZER, HEX_PIXEL_HEIGHT*1.5, HEX_PIXEL_WIDTH*2, COLORS.GREEN_YELLOW)
}
elseif tower_type == 3 then
return am.group{
am.circle(vec2(0), HEX_SIZE - 4, COLORS.VERY_DARK_GRAY, 6),
am.rotate(game_state.time or 0) ^ am.group{
pack_texture_into_sprite(TEXTURES.TOWER_HOWITZER, HEX_PIXEL_HEIGHT*1.5, HEX_PIXEL_WIDTH*2) -- CHONK
}
}
elseif tower_type == 7 then
return am.group{
make_tower_sprite(tower_type),
am.particles2d{
source_pos = vec2(0, 12),
source_pos_var = vec2(2),
start_size = 1,
start_size_var = 1,
end_size = 1,
end_size_var = 1,
angle = 0,
angle_var = math.pi,
speed = 1,
speed_var = 2,
life = 10,
life_var = 1,
start_color = COLORS.WHITE,
start_color_var = vec4(0.1, 0.1, 0.1, 1),
end_color = COLORS.SUNRAY,
end_color_var = vec4(0.1),
emission_rate = 4,
start_particles = 4,
max_particles = 200,
warmup_time = 5
}
}
elseif tower_type == 1 then
return am.circle(vec2(0), HEX_SIZE, COLORS.VERY_DARK_GRAY{a=0.75}, 6)
}
elseif tower_type == 5 then
return am.circle(vec2(0), HEX_SIZE, COLORS.WATER{a=1}, 6)
-- initialize the tower cursors (what you see when you select a tower and hover over buildable hexes)
local tower_cursors = {}
for i,t in pairs(base_tower_specs) do
TOWER_TYPE[t.id] = i
elseif tower_type == 6 then
return make_tower_sprite(tower_type)
end
end
if not t.size then t.size = 1 end
if not t.height then t.height = 1 end
function build_tower_cursors()
local tower_cursors = {}
for i,tower_spec in pairs(TOWER_SPECS) do
local tower_sprite = make_tower_node(i)
tower_sprite.color = COLORS.TRANSPARENT3
if not t.update_f then
t.update_f = default_tower_update_f
end
if not t.placement_f then
t.placement_f = default_tower_placement_f
end
if not t.weapons then
t.weapons = {}
end
-- resolve missing fields among weapons the tower has, as well as
-- the tower's visual range - if not provided we should use the largest range among weapons it has
local largest_range = 0
local largest_minimum_range = 0
for i,w in pairs(t.weapons) do
if not w.min_range then
w.min_range = 0
end
if not w.target_acquisition_f then
w.target_acquisition_f = default_weapon_target_acquisition_f
end
if w.range > largest_range then
largest_range = w.range
end
if w.min_range > largest_minimum_range then
largest_minimum_range = w.min_range
end
end
if not t.min_visual_range then
t.min_visual_range = largest_minimum_range
end
if not t.visual_range then
t.visual_range = largest_range
end
-- build tower cursors
local coroutine_ = coroutine.create(function(node)
local flash_on = {}
local flash_off = {}
@ -245,17 +399,30 @@ function build_tower_cursors()
end
end)
tower_cursors[i] = am.group{
make_hex_cursor_node(tower_spec.visual_range, vec4(0), coroutine_, tower_spec.min_visual_range),
local tower_sprite = t.make_node_f(t)
tower_sprite.color = COLORS.TRANSPARENT3
tower_cursors[i] = am.group(
make_hex_cursor_node(t.visual_range - 1, vec4(0), coroutine_, t.min_visual_range - 1),
tower_sprite
}
end
):tag"cursor"