diff --git a/README.md b/README.md index ac2f4b2..5eaf598 100644 --- a/README.md +++ b/README.md @@ -43,7 +43,6 @@ Actions - [x] unknown letters are terrain - [x] health items - [x] collect your corpse - - [ ] drop full inventory chars - [x] copy to new buffer on game start - [x] highlight hurt - [x] miss hits and dodges @@ -93,6 +92,33 @@ Actions - [ ] add more specific effects - [ ] build terrain, same but with terrain - [ ] pick up terrain, or only for in place + - [x] rename item +- [ ] movement + - [ ] restrict teleport with an item +- [ ] physics + - [ ] entity weight + - [ ] entity strength + - [ ] force + - [ ] knockdown + - [ ] kick acceleration + - [ ] bump acceleration + - [ ] pickup weight + - [ ] teleport acceleration? + - [ ] water and density? + - [ ] slippery floor? +- [ ] entity component system + - [ ] refactor + - [ ] extract all features into components + - [ ] all player actions can be performed by entities + - [ ] ai for entities to choose action + - [ ] control player movement with a spell + - [x] enemies have inventory + - [x] player and enemies drop inventory the same way + - [ ] enemies have view_distance + - [ ] target for enemies and friends + - [ ] enemies pickup items + - [ ] enemies use weapons + - [ ] npcs - [ ] friends - [x] enchant enemies into friends - [x] friend follows player @@ -108,7 +134,9 @@ Actions - [x] change/replace - both pickup and drop - [x] visual - move without attacking ie sneaking - [x] an entity stat for sneakability + - [ ] steal items - [x] yank - search, works in combination with sneaking + - [ ] paste - ??? - [ ] jumplist - ??? - [ ] use registers for inventory - [x] insert to run actions @@ -125,6 +153,7 @@ Actions - [ ] performance - [x] write buffer once per tick - [x] store map as 2D array +- [ ] vim tutor/tutorial map - [ ] easy run - [x] minimal neovim config with single command to run - [ ] slash screen diff --git a/lua/neohack/actions.lua b/lua/neohack/actions.lua index 3abd3ae..124cbd3 100644 --- a/lua/neohack/actions.lua +++ b/lua/neohack/actions.lua @@ -1,60 +1,68 @@ local chat = require("neohack.chat") -local inventory = require("neohack.inventory") +local state = require("neohack.state") +local message = require("neohack.message") +local utils = require("neohack.utils") + local M = { leader_key = nil, tick = nil, } -local player = require("neohack.player") -local message = require("neohack.message") -local event = require("neohack.event") -local state = require("neohack.state") -local utils = require("neohack.utils") - M.actions = { ---comment inventory = function() - message.notify("You have " .. player.get_inventory()) + message.notify("You have " .. state.player.inventory:get_inventory()) end, ---comment kick = function(request) - event.kick(request.object) + state.player.movement:kick(request.object) M.tick() end, ---comment wear = function(request) - player.wear(request.object, request.target) + state.player.inventory:wear(request.object, request.target) end, ---comment fuse = function(request) --TODO: allow for multiple objects? or just keep to object and target - player.fuse({ request.object, request.target }) + state.player.fuse:fuse({ request.object, request.target }) + M.tick() + end, + + ---comment + rename = function(request) + state.player.inventory:rename(request.object, request.target) M.tick() end, ---comment look = function(request) - player.look({ request.object }) + state.player.inventory:look({ request.object }) + end, + + ---comment + pilfer = function(request) + state.player.inventory:pilfer(request.object) end, ---comment eat = function(request) - player.eat({ request.object }) + state.player:eat_keys({ request.object }) M.tick() end, ---comment drop = function(request) - player.drop({ request.object }) + state.player.inventory:drop({ request.object }) M.tick() end, ---comment say = function(request) - event.say({ request.object, request.target }) + state.player.speak:say({ request.object, request.target }) M.tick() end, @@ -83,7 +91,7 @@ M.actions = { .. M.leader_key .. "'\n" .. "Actions: " - .. "i to show inventory, w to wear, k to kick, f to fuse, l to look, e to eat, s to say, d to drop, to wait, ? for help" + .. "i to show inventory, w to wear, k to kick, f to fuse, r to rename, l to look, p to pilfer, e to eat, s to say, d to drop, to wait, ? for help" ) end, } @@ -105,11 +113,18 @@ M.synonyms = { wield = "wear", f = "fuse", + r = "rename", l = "look", read = "look", examine = "look", + p = "pilfer", + steal = "pilfer", + pickpocket = "pilfer", + loot = "pilfer", + take = "pilfer", + e = "eat", d = "drop", @@ -141,7 +156,7 @@ end ---comment M.prompt_wear = function() - message.notify("You have:\n0:nothing\n" .. inventory.get_inventory_item_with_index()) + message.notify("You have:\n0:nothing\n" .. state.player.inventory:get_inventory_item_with_index()) vim.defer_fn(function() local object = M.prompt_one_word("Wear what?") if object then @@ -156,7 +171,7 @@ end ---comment M.prompt_fuse = function() - message.notify("You have:\n" .. inventory.get_inventory_item_with_index()) + message.notify("You have:\n" .. state.player.inventory:get_inventory_item_with_index()) vim.defer_fn(function() local index = M.prompt("Fuse what?") if index then @@ -169,9 +184,23 @@ M.prompt_fuse = function() end, 50) end +---comment +M.prompt_rename = function() + message.notify("You have:\n" .. state.player.inventory:get_inventory_item_with_index()) + vim.defer_fn(function() + local index = M.prompt_one_word("Rename what?") + if index then + local name = M.prompt_one_word("New name?") + if name then + M.actions.rename({ object = index, target = name }) + end + end + end, 50) +end + ---comment M.prompt_look = function() - message.notify("You have:\n0:self\n" .. inventory.get_inventory_item_with_index()) + message.notify("You have:\n0:self\n" .. state.player.inventory:get_inventory_item_with_index()) vim.defer_fn(function() local index = M.prompt_one_word("Look at what?") if index then @@ -181,9 +210,20 @@ M.prompt_look = function() end, 50) end +---comment +M.prompt_pilfer = function() + message.notify("You have:\n0:self\n" .. state.player.inventory:get_inventory_item_with_index()) + vim.defer_fn(function() + local index = M.prompt_one_word("Pilfer from what?") + if index then + M.actions.pilfer({ object = index }) + end + end, 50) +end + ---comment M.prompt_eat = function() - message.notify("You have:\n" .. inventory.get_inventory_item_with_index()) + message.notify("You have:\n" .. state.player.inventory:get_inventory_item_with_index()) vim.defer_fn(function() local index = M.prompt_one_word("Eat what?") if index then @@ -195,7 +235,7 @@ end ---comment M.prompt_drop = function() - message.notify("You have:\n" .. inventory.get_inventory_item_with_index()) + message.notify("You have:\n" .. state.player.inventory:get_inventory_item_with_index()) vim.defer_fn(function() local index = M.prompt_one_word("Drop what?") if index then @@ -276,7 +316,9 @@ M.setup = function(bufnr, tick) map("w", M.prompt_wear, "wear") map("k", M.prompt_kick, "kick") map("f", M.prompt_fuse, "fuse") + map("r", M.prompt_rename, "rename") map("l", M.prompt_look, "look") + map("p", M.prompt_pilfer, "pilfer") map("e", M.prompt_eat, "eat") map("d", M.prompt_drop, "drop") map("s", M.prompt_say, "say") diff --git a/lua/neohack/attribute_getters.lua b/lua/neohack/attribute_getters.lua new file mode 100644 index 0000000..7b2076a --- /dev/null +++ b/lua/neohack/attribute_getters.lua @@ -0,0 +1,19 @@ +local M = {} + +M.vision = function(item) + return item and item.attributes.vision or 0 +end + +M.randomness = function(item) + return item and item.attributes.randomness or 0 +end + +M.hit_rate = function(item) + return item and item.attributes.hit_rate or 0 +end + +M.durability = function(item) + return item and item.attributes.durability or 0 +end + +return M diff --git a/lua/neohack/buffer.lua b/lua/neohack/buffer.lua index 7895502..d52c685 100644 --- a/lua/neohack/buffer.lua +++ b/lua/neohack/buffer.lua @@ -46,7 +46,7 @@ M.create_new_buffer = function(lines, handlers) M.buffers[bufnr] = init_buffer(bufnr, handlers) table.insert(M.levels, bufnr) M.buffers[bufnr].level = #M.levels - M.read_buf(bufnr) + -- M.read_buf(bufnr) return bufnr, #M.levels end @@ -98,12 +98,12 @@ M.add_handlers = function(bufnr) end end ---- Read buffer content and update the cells for a specific buffer ----@param bufnr integer -M.read_buf = function(bufnr) - -- replace the old frame - M.buffers[bufnr].cells = view_buffer.read_a_buf(bufnr) -end +-- --- Read buffer content and update the cells for a specific buffer +-- ---@param bufnr integer +-- M.read_buf = function(bufnr) +-- -- replace the old frame +-- M.buffers[bufnr].cells = view_buffer.read_a_buf(bufnr) +-- end --- Write the buffer content based on the cells for a specific buffer ---comment @@ -140,10 +140,8 @@ M.get_entity_at_pos = function(row, col) end local entity = line[col] if entity then - if entity.row ~= row or entity.col ~= col then - message.notify( - "entity mismatch " .. entity.char .. " " .. entity.row .. " " .. entity.col .. " " .. row .. " " .. col - ) + if entity.movement.row ~= row or entity.movement.col ~= col then + message.notify("entity mismatch", entity.movement.char, entity.movement.row, entity.movement.col, row, col) end else -- message.notify("nothing at " .. row .. " " .. col) @@ -159,7 +157,7 @@ M.get_under_cursor = function() local row, col = unpack(vim.api.nvim_win_get_cursor(0)) col = col + 1 local entity = M.get_entity_at_pos(row, col) - -- message.notify("under cursor " .. row .. " " .. col .. " " .. entity.char) + -- message.notify("under cursor " .. row .. " " .. col .. " " .. entity.movement.char) return row, col, entity end @@ -169,9 +167,16 @@ end ---@param entity Entity M.set_entity_at_cell = function(row, col, entity) local bufnr = state.current_bufnr - entity.row = row - entity.col = col - M.buffers[bufnr].cells[row][col] = entity + entity.movement.row = row + entity.movement.col = col + -- TODO: deal with nil + local buf = M.buffers[bufnr] + if not buf then + error("no buffer") + end + -- error("set entity at " .. row .. " " .. col) + -- message.notify(vim.inspect(buf.cells[row][col])) + buf.cells[row][col] = entity end --- Insert an entity at a specific position in a specific buffer @@ -180,12 +185,12 @@ end ---@param entity Entity M.insert_entity_at_cell = function(row, col, entity) local bufnr = state.current_bufnr - entity.row = row - entity.col = col + entity.movement.row = row + entity.movement.col = col table.insert(M.buffers[bufnr].cells[row], col, entity) -- increment the remaining cols in the row for i = col + 1, #M.buffers[bufnr].cells[row] do - M.buffers[bufnr].cells[row][i].col = i + M.buffers[bufnr].cells[row][i].movement.col = i end end diff --git a/lua/neohack/components/attributes.lua b/lua/neohack/components/attributes.lua new file mode 100644 index 0000000..b040139 --- /dev/null +++ b/lua/neohack/components/attributes.lua @@ -0,0 +1,52 @@ +--- store all attributes +--- + +---@class Attributes +---Combat +---@field dodge_attribute number ability to dodge +---@field deflect_attribute number ability to deflect +---@field weapon_attribute number ability to hit +---@field damage number +---@field durability number +---@field hit_rate number +---@field hit_highlight string +---Decision +---@field randomness number +---Eat +---@field eat_attribute number +---Fuse +---@field fuse_attribute number +---Health +---@field health number +---Movement +---@field scared number +---@field block_vision boolean +---Sneak +---@field sneak_attribute number +---Vision +---@field view_attribute number +---@field search_attribute number +---@field vision number +local Attributes = {} +Attributes.__index = Attributes +Attributes.__name = "Attributes" + +---comment +---@return Attributes +function Attributes.new() + local instance = { + -- this instances stores all attributes, but they are not set here + -- each component sets the attributes it uses + } + setmetatable(instance, Attributes) + return instance +end + +function Attributes:inspect() + return "" +end +function Attributes:attributes() + return {} +end + +return Attributes diff --git a/lua/neohack/components/combat.lua b/lua/neohack/components/combat.lua new file mode 100644 index 0000000..1405fe0 --- /dev/null +++ b/lua/neohack/components/combat.lua @@ -0,0 +1,227 @@ +--- attacking, deflecting, and dodging +--- + +local message = require("neohack.message") +local state = require("neohack.state") +local chance = require("neohack.chance") +local constant_defs = require("neohack.constant_defs") +local attributes = require("neohack.attribute_getters") + +---@class Combat +---@field parent Entity +---@field dodge_threshold number required to dodge +---@field hit_threshold number required to hit +---@field default_weapon function +local Combat = {} +Combat.__index = Combat +Combat.__name = "Combat" + +---comment +---@param parent Entity +---@param default_weapon function +---@return Combat +function Combat.new(parent, damage, durability, hit_rate, default_weapon) + local instance = { + -- the parent Entity + parent = parent, + + --thresholds + dodge_threshold = 0.5, + hit_threshold = 0.5, + + --other + default_weapon = default_weapon, + } + --attributes + parent.attributes.dodge_attribute = 0.5 + parent.attributes.deflect_attribute = 0.01 + parent.attributes.weapon_attribute = 0.5 + parent.attributes.damage = damage + parent.attributes.durability = durability + parent.attributes.hit_rate = hit_rate + --status + parent.attributes.hit_highlight = nil + + setmetatable(instance, Combat) + return instance +end + +function Combat:inspect() + return "durability=" + .. self.parent.attributes.durability + .. " damage=" + .. self.parent.attributes.damage + .. " hit_rate=" + .. self.parent.attributes.hit_rate + .. " dodge_skill=" + .. self:dodge_skill() + .. " weapon_skill=" + .. self:weapon_skill() + .. " deflect_skill=" + .. self:deflect_skill() +end +function Combat:attributes() + return { + durability = self.parent.attributes.durability, + damage = self.parent.attributes.damage, + hit_rate = self.parent.attributes.hit_rate, + dodge_attribute = self.parent.attributes.dodge_attribute, + weapon_attribute = self.parent.attributes.weapon_attribute, + deflect_attribute = self.parent.attributes.deflect_attribute, + } +end + +-- --TODO: deprecated +-- ---comment +-- ---@param attacker Entity +-- function Combat:_try_dodge(attacker) +-- if chance.action_success(self:dodge_skill(), 0, attacker.hit_rate or 0, self.dodge_threshold) then +-- message.notify(self.parent.name .. " dodged " .. attacker.name .. ".") +-- return true +-- else +-- return false +-- end +-- end + +---comment +---@return number +function Combat:dodge_skill() + return self.parent.attributes.dodge_attribute + + self.parent.inventory:wear_effect(state.slot_types.feet, attributes.randomness, 0.3) + + self.parent.inventory:wear_effect(state.slot_types.feet, attributes.hit_rate, 0.2) +end + +function Combat:deflect_skill() + return self.parent.attributes.deflect_attribute + + self.parent.inventory:wear_effect(state.slot_types.body, attributes.durability, 0.4) + + self.parent.inventory:wear_effect(state.slot_types.left_hand, attributes.durability, 0.3) +end + +function Combat:weapon_skill() + return self.parent.attributes.weapon_attribute + + self.parent.inventory:wear_effect(state.slot_types.left_hand, attributes.hit_rate, 0.7) +end + +---comment +---@param target Combat +---@param weapon Entity +---@return boolean +function Combat:try_weapon(target, weapon) + return chance.action_success( + self:weapon_skill(), + weapon.attributes.hit_rate, + target.parent.attributes.vision, + self.hit_threshold + ) +end + +---comment +---@param target Combat +---@param weapon Entity +---@return boolean +function Combat:try_dodge(target, weapon) + return chance.action_success( + target:dodge_skill(), + target.parent.attributes.vision, + weapon.attributes.hit_rate or 0, + target.dodge_threshold + ) +end + +---comment +---@param target Combat +---@param weapon Entity +---@return integer damage +---@return boolean true if any damage was dealt +function Combat:try_damage_dealt(target, weapon) + --TODO: test sneak to avoid being seen + + local weapon_success = self:try_weapon(target, weapon) + if not weapon_success then + target.hit_highlight = constant_defs.missing_highlight + message.notify(self.parent.name .. " missed " .. target.parent.name) + return 0, false + end + + local dodge_success = self:try_dodge(target, weapon) + if dodge_success then + self:dodge(target) + -- return constant_defs.cursor_dodge_highlight + return 0, false + end + + local damage = weapon.attributes.damage - target:deflect_skill() + if damage <= 0 then + message.notify(target.parent.name .. " deflected " .. self.parent.name) + -- return constant_defs.cursor_dodge_highlight + return 0, false + end + + return damage, true +end + +---comment +---@param target Combat +function Combat:dodge(target) + message.notify(target.parent.name .. " dodged " .. self.parent.name .. ".") +end + +---comment +---@param target Combat +function Combat:apply_highlights(target) + -- TODO: hit player highlight not working + -- highlight is set, but cursor overrides it + -- view_buffer.highlight_hit(attacker.movement.row, attacker.movement.col, defs.hit_by_highlight, 200) + target.hit_highlight = constant_defs.hurt_highlight + self.hit_highlight = constant_defs.hitting_highlight +end + +---comment +---@param damage number +---@param weapon Entity +---@return number damage +function Combat:apply_durability(damage, weapon) + if weapon.attributes.durability <= 0 then + damage = damage * 0.1 + end + weapon.attributes.durability = weapon.attributes.durability - 1 + if weapon.attributes.durability <= 0 then + message.notify(self.parent.name .. " broke " .. weapon.name) + end + return damage +end + +---comment +---@param damage number +---@param target Combat +---@param weapon Entity +function Combat:apply_damage(damage, target, weapon) + target.parent.attributes.health = target.parent.attributes.health - damage + if target.parent.health:check_dead() then + table.insert(state.bodies, target) + message.notify( + self.parent.name .. " killed " .. target.parent.name .. " with a " .. weapon.name .. " for " .. damage + ) + else + message.notify(self.parent.name .. " hit " .. target.parent.name .. " with a " .. weapon.name .. " for " .. damage) + end +end + +---comment +---@param target Combat +function Combat:attack(target) + local weapon = self.parent.inventory:get_weapon() or self.default_weapon() + + local damage, dealt = self:try_damage_dealt(target, weapon) + if not dealt then + return false + end + + -- damage dealt + self:apply_highlights(target) + damage = self:apply_durability(damage, weapon) + self:apply_damage(damage, target, weapon) + return true +end + +return Combat diff --git a/lua/neohack/components/decision.lua b/lua/neohack/components/decision.lua new file mode 100644 index 0000000..f0a6a75 --- /dev/null +++ b/lua/neohack/components/decision.lua @@ -0,0 +1,32 @@ +--- making decisions +--- + +---@class Decision +---@field parent Entity +local Decision = {} +Decision.__index = Decision +Decision.__name = "Decision" + +---comment +---@param parent Entity +---@param randomness number +---@return Decision +function Decision.new(parent, randomness) + local instance = { + parent = parent, + } + parent.attributes.randomness = randomness + setmetatable(instance, Decision) + return instance +end + +function Decision:inspect() + return "randomness=" .. self.parent.attributes.randomness +end +function Decision:attributes() + return { + randomness = self.parent.attributes.randomness, + } +end + +return Decision diff --git a/lua/neohack/components/eat.lua b/lua/neohack/components/eat.lua new file mode 100644 index 0000000..104d609 --- /dev/null +++ b/lua/neohack/components/eat.lua @@ -0,0 +1,86 @@ +--- ability to sneak +--- + +local state = require("neohack.state") +local chance = require("neohack.chance") +local message = require("neohack.message") +local attributes = require("neohack.attribute_getters") + +---@class Eat +---@field parent Entity +local Eat = {} +Eat.__index = Eat +Eat.__name = "Eat" + +---comment +---@param parent Entity +---@return Eat +function Eat.new(parent) + local instance = { + parent = parent, + } + parent.attributes.eat_attribute = 0.1 + setmetatable(instance, Eat) + return instance +end + +function Eat:inspect() + return "eat_skill=" .. self:eat_skill() +end +function Eat:attributes() + return { + eat_attribute = self.parent.attributes.eat_attribute, + } +end + +---comment +---@return number +function Eat:eat_skill() + return self.parent.attributes.eat_attribute + + self.parent.inventory:wear_effect(state.slot_types.body, attributes.randomness, 0.7) +end + +---comment +---@param keys string[] +---@return boolean true if player died +function Eat:eat(keys) + local items = self.parent.inventory:retrieve_items(keys) + if not items then + return false + end + local eat_skill = self:eat_skill() + for _, item in ipairs(items) do + if item then + local loot = item.inventory:extract_all() + if #loot > 0 then + for _, loot_item in ipairs(loot) do + message.notify("Threw up a " .. loot_item.name) + self.parent.inventory:pickup_item(loot_item) + end + end + + local p = self.parent.attributes + local i = item.attributes + p.health = p.health + (i.health or 0) - chance.action_eat(eat_skill, 0.8, i.damage) + -- TODO: make these changes revert after a timer + p.view_attribute = p.view_attribute + chance.action_eat(eat_skill, 1, i.vision) + p.dodge_attribute = p.dodge_attribute + chance.action_eat(eat_skill, 0.1, i.randomness) + p.deflect_attribute = p.deflect_attribute + chance.action_eat(eat_skill, 0.1, i.durability) + p.weapon_attribute = p.weapon_attribute + chance.action_eat(eat_skill, 0.05, i.hit_rate) + p.search_attribute = p.search_attribute + chance.action_eat(eat_skill, 0.1, i.vision) + p.sneak_attribute = p.sneak_attribute + chance.action_eat(eat_skill, 0.1, i.vision) + p.fuse_attribute = p.fuse_attribute + chance.action_eat(eat_skill, 0.1, i.randomness) + + p.eat_attribute = p.eat_attribute + 0.01 + + -- TODO what other affects could eating have? + message.notify("Ate a", item.name, ". health:", p.health) --, vim.inspect(p.attributes)) + if self.parent.health:check_dead() then + return true + end + end + end + return false +end + +return Eat diff --git a/lua/neohack/components/fuse.lua b/lua/neohack/components/fuse.lua new file mode 100644 index 0000000..8698a9f --- /dev/null +++ b/lua/neohack/components/fuse.lua @@ -0,0 +1,70 @@ +--- ability to fuse +--- + +local fuse = require("neohack.fuser") +local state = require("neohack.state") +local message = require("neohack.message") +local attributes = require("neohack.attribute_getters") + +---@class Fuse +---@field parent Entity +---@field fuse_low_threshold number +---@field fuse_high_threshold number +local Fuse = {} +Fuse.__index = Fuse +Fuse.__name = "Fuse" + +---comment +---@param parent Entity +---@return Fuse +function Fuse.new(parent) + local instance = { + parent = parent, + + fuse_low_threshold = 0.3, + fuse_high_threshold = 0.8, + } + parent.attributes.fuse_attribute = 0.5 + setmetatable(instance, Fuse) + return instance +end + +function Fuse:inspect() + return "fuse_skill=" .. self:fuse_skill() +end +function Fuse:attributes() + return { + fuse_attribute = self.parent.attributes.fuse_attribute, + } +end + +function Fuse:fuse_skill() + return self.parent.attributes.fuse_attribute + + self.parent.inventory:wear_effect(state.slot_types.left_hand, attributes.randomness, 0.7) +end + +---comment +---@param keys string[] +function Fuse:fuse(keys) + local items = self.parent.inventory:retrieve_items(keys) + if not items then + return + end + message.notify("Fusing " .. vim.inspect(items)) + + -- got all items, fuse them + ---@type Entity + local new_item = items[1] + for i = 2, #items do + local latest = + fuse.fuse_entities(new_item, items[i], self:fuse_skill(), self.fuse_low_threshold, self.fuse_high_threshold) + if latest then + new_item = latest + end + end + message.notify("Fused " .. table.concat(keys, " ") .. " into " .. new_item.name) + -- all other items disappear as part of fusing + self.parent.inventory:pickup_item(new_item) +end + +return Fuse diff --git a/lua/neohack/components/health.lua b/lua/neohack/components/health.lua new file mode 100644 index 0000000..0160414 --- /dev/null +++ b/lua/neohack/components/health.lua @@ -0,0 +1,65 @@ +--- health and life +--- + +local constant_defs = require("neohack.constant_defs") +local Def = require("neohack.def") + +---@class Health +---@field parent Entity +---@field alive boolean +local Health = {} +Health.__index = Health +Health.__name = "Health" +---comment +---@param parent Entity +---@param health number +---@return Health +function Health.new(parent, health) + local instance = { + parent = parent, + + alive = true, + } + parent.attributes.health = health + setmetatable(instance, Health) + return instance +end + +function Health:inspect() + return "health=" .. self.parent.attributes.health +end +function Health:attributes() + return { health = self.parent.attributes.health } +end + +function Health:is_dead() + return not self.alive or self.parent.attributes.health <= 0 +end + +---comment +---@return boolean true if entity is now dead +function Health:check_dead() + if self:is_dead() then + self.alive = false + self:drop_corpse() + return true + end + return false +end + +function Health:drop_corpse() + self.parent.movement.char = constant_defs.enemy_corpse + self.parent.name = self.parent.name .. "_corpse" + self.parent.type = Def.DefType.item + self.parent.attributes.block_vision = false + + -- move worn items to inventory + local items = self.parent.inventory:get_items() + for _, item in pairs(self.parent.inventory.slots) do + table.insert(items, item) + end + self.parent.inventory.items = items + -- error("Dropping" .. vim.inspect(self.inventory.slots)) +end + +return Health diff --git a/lua/neohack/components/inventory.lua b/lua/neohack/components/inventory.lua new file mode 100644 index 0000000..982fa61 --- /dev/null +++ b/lua/neohack/components/inventory.lua @@ -0,0 +1,305 @@ +--- player inventory +--- + +local utils = require("neohack.utils") +local Def = require("neohack.def") +local state = require("neohack.state") +local message = require("neohack.message") +local buffer = require("neohack.buffer") + +---@class Inventory +---@field parent Entity +---@field items Entity[] +---@field slots table +local Inventory = {} +Inventory.__index = Inventory +Inventory.__name = "Inventory" + +---comment +---@param parent Entity +---@return Inventory +function Inventory.new(parent) + local instance = { + parent = parent, + + --- a stack of items + ---@type Entity[] + items = {}, + + ---@type table slot char to equipped item + slots = {}, + } + setmetatable(instance, Inventory) + return instance +end + +function Inventory:inspect() + return self:get_inventory() +end +function Inventory:attributes() + return {} +end + +---comment +---@return Entity[] +function Inventory:get_items() + return self.items +end + +---comment +---@param entity Entity +---@return boolean +function Inventory:can_pickup(entity) + return entity.type == Def.DefType.item or entity.type == Def.DefType.floor +end + +---comment +---@param entity Entity +function Inventory:pickup(entity) + if entity.type == Def.DefType.item then + self:pickup_item(entity) + else + -- do nothing + end +end + +--- pick up item +---@param item Entity +function Inventory:pickup_item(item) + table.insert(self.items, 1, item) + message.notify("Got a " .. item.name) +end + +---comment +---@return string +function Inventory:get_inventory_item_with_index() + local items_str = "" + for index, item in pairs(self.items) do + items_str = items_str .. index .. ":" .. item.name .. "\n" + end + return items_str +end + +---comment +---comment +---@return Entity[] +function Inventory:get_spell_items() + local spells = {} + for _, item in pairs(self.items) do + if item.speak.spell then + table.insert(spells, item) + end + end + return spells +end + +---drop the oldest item that matches char +---@param char string +---@return Entity? +function Inventory:retrieve_item_char(char) + for i = #self.items, 1, -1 do + if self.items[i].movement.char == char then + local entity = table.remove(self.items, i) + -- TODO: deal with retrieve for action being too verbose + message.notify("Retrieved " .. entity.movement.char) + return entity + end + end + message.notify("No " .. char .. " to retrieve") + return nil +end + +---get the named item out of inventory +---@param keys string[] names or indexes +---@return Entity[]? +function Inventory:retrieve_items(keys) + local items = {} + local indexes = {} + for _, name in ipairs(keys) do + local found = false + local index = utils.string_to_int(name) + if index then + table.insert(items, self.items[index]) + table.insert(indexes, index) + found = true + else + for i, v in ipairs(self.items) do + if v.name == name then + -- matches first found + table.insert(items, v) + table.insert(indexes, i) + found = true + break + end + end + end + if not found then + message.notify("No " .. name .. " to retrieve") + -- get all or nothing + return nil + end + end + + -- highest index first, so they all get removed + table.sort(indexes, function(a, b) + return a > b + end) + for _, index in ipairs(indexes) do + table.remove(self.items, index) + end + -- message.notify("Retrieved " .. table.concat(indexes, " ")) + return items +end + +---comment +---@return Entity[] all extracted items +function Inventory:extract_all() + local items = self:get_items() + for key, value in pairs(self.slots) do + table.insert(items, value) + self.slots[key] = nil + end + self.items = {} + return items +end + +function Inventory:_wield(key, slot) + if key == "0" or key == "nothing" then + self.slots[slot] = nil + return + end + local items = self:retrieve_items({ key }) + if items and items[1] then + self.slots[slot] = items[1] + message.notify("Wearing " .. items[1].name .. " on " .. state.slot_name(slot)) + else + return + end +end + +---comment +---@param key string +---@param slot string either short or long, eg h or head +function Inventory:wear(key, slot) + -- unwear the current item + if #slot > 1 then + -- handle slot name + slot = state.slot_types[slot] + end + local previous = self.slots[slot] + + self:_wield(key, slot) + -- pickup old after wielding new, to maintain order + if previous then + self:pickup_item(previous) + end +end + +---comment +---@param slot string +---@param get_attribute function +---@param effectiveness number +---@return number +function Inventory:wear_effect(slot, get_attribute, effectiveness) + --TODO: refactor this + local item = self.slots[slot] + return (get_attribute(item) or 0) * effectiveness +end + +---comment +---@return Entity +function Inventory:get_weapon() + return self.slots[state.slot_types.right_hand] +end + +---comment +---@return string +function Inventory:get_wearing() + local wearing = "" + for name, char in utils.sorted_pairs(state.slot_types) do + local item = self.slots[char] + local item_name = item and item.name or "" + wearing = wearing .. name .. "=" .. item_name .. " " + end + return wearing +end + +---comment +---@return string +function Inventory:get_inventory() + local items_str = self:get_inventory_item_with_index() + return "wearing: " .. self:get_wearing() .. "\nitems:\n" .. items_str +end + +---comment +---@param key string +---@param new_name string +function Inventory:rename(key, new_name) + --TODO: don't pull out + local items = self:retrieve_items({ key }) + if not items then + return + end + local item = items[1] + message.notify("Renaming " .. item.name .. " to " .. new_name) + item.name = new_name + self:pickup_item(item) +end + +---comment +---@param keys string[] +function Inventory:look(keys) + for i, key in ipairs(keys) do + if key == "0" or key == "self" then + message.notify("Looked at self " .. self.parent:inspect()) + table.remove(keys, i) + end + end + local items = self:retrieve_items(keys) + if not items then + return + end + for _, item in ipairs(items) do + if item then + self:pickup_item(item) + message.notify("Looked at " .. item:inspect()) + end + end +end + +---pilfer/loot items out of the inventory of another item +---@param key string +function Inventory:pilfer(key) + local lootables = self:retrieve_items({ key }) + if not lootables then + return + end + for _, lootable in ipairs(lootables) do + if lootable then + local extracted = "" + local loot = lootable.inventory:extract_all() + for _, item in ipairs(loot) do + self:pickup_item(item) + extracted = extracted .. item.name .. " " + end + self:pickup_item(lootable) + message.notify("Took", extracted, "from", lootable.name) + end + end +end + +---comment +---@param keys string[] +function Inventory:drop(keys) + local items = self:retrieve_items(keys) + if not items then + return + end + local row, col = self.parent.movement.row, self.parent.movement.col + for _, item in ipairs(items) do + -- TODO: allow drop without overwrite + buffer.set_entity_at_cell(row, col, item) + end +end + +return Inventory diff --git a/lua/neohack/components/movement.lua b/lua/neohack/components/movement.lua new file mode 100644 index 0000000..eb028cd --- /dev/null +++ b/lua/neohack/components/movement.lua @@ -0,0 +1,232 @@ +--- ability to move, and visibility +--- + +local moves = require("neohack.moves") +local Move = require("neohack.move") +local buffer = require("neohack.buffer") +local constant_defs = require("neohack.constant_defs") +local view_buffer = require("neohack.view_buffer") +local utils = require("neohack.utils") +local state = require("neohack.state") +local Def = require("neohack.def") +local message = require("neohack.message") + +---@class Movement +---@field parent Entity +---@field char string +---@field moves Move[]|nil +---@field row integer +---@field col integer +---@field visible boolean +---@field seen boolean +local Movement = {} +Movement.__index = Movement +Movement.__name = "Movement" + +---comment +---@param parent Entity +---@param char string +---@param moves_set Move[] +---@param row integer +---@param col integer +---@param block_vision boolean +---@return Movement +function Movement.new(parent, char, moves_set, row, col, block_vision) + local instance = { + parent = parent, + + --TODO: should these be attributes? + char = char, + moves = moves_set, + --TODO: if these were attributes, spells could affect position + row = row, + col = col, + + --TODO: could these be attributes? + visible = true, + seen = false, + } + parent.attributes.scared = 0.01 + --TODO: make this a number + parent.attributes.block_vision = block_vision + setmetatable(instance, Movement) + return instance +end + +function Movement:inspect() + return "char=" .. self.char .. " row=" .. self.row .. " col=" .. self.col +end +function Movement:attributes() + return { + char = self.char, + moves = self.moves, + row = self.row, + col = self.col, + visible = self.visible, + seen = self.seen, + scared = self.parent.attributes.scared, + block_vision = self.parent.attributes.block_vision, + } +end + +---comment +---@param row integer +---@param col integer +---@return Move[] sorted moves sorted by closest, moves are still relative +function Movement:moves_by_distance(row, col) + ---@type Move[] + local moves_with_distances = {} + for _, move in pairs(self.moves or moves.eight) do + local distance = move.manhattan_distance(self.row + move.row, self.col + move.col, row, col) + table.insert(moves_with_distances, Move.new({ dir = move.dir, row = move.row, col = move.col, dist = distance })) + end + table.sort(moves_with_distances, function(a, b) + return a.dist < b.dist + end) + return moves_with_distances +end + +---comment +---@param move Move +function Movement:make_move(move) + local new_row, new_col = self.row + move.row, self.col + move.col + local entity = buffer.get_entity_at_pos(new_row, new_col) + if entity and entity.movement.char == constant_defs.floor_char and new_row > 0 and new_col > 0 then + -- message.notify("making move " .. vim.inspect(self) .. " " .. vim.inspect(move)) + buffer.set_entity_at_cell(self.row, self.col, state.entity_generator.new_floor(self.row, self.col)) + view_buffer.set_highlight(self.row, self.col, nil) + + buffer.set_entity_at_cell(new_row, new_col, self.parent) + -- TODO: without this it leaves a hit to the enemy location + if self.visible then + view_buffer.set_highlight(new_row, new_col, constant_defs.enemy_highlight) + end + return true + else + -- message.notify("can't move to " .. char or "" .. " " .. vim.inspect(mover) .. " " .. vim.inspect(move)) + return false + end +end + +---comment +---@param target_row integer +---@param target_col integer +---@param try_move function +function Movement:move_closer_to(target_row, target_col, try_move) + local move_list = self:moves_by_distance(target_row, target_col) + local in_fear = false + if self.parent.attributes.scared > math.random() then + move_list = utils.reverse_array(move_list) + in_fear = true + end + -- message.notify("moves " .. vim.inspect(moves)) + if math.random() < self.parent.attributes.randomness then + -- randomly do a random move + -- message.notify("random move") + self:make_move(move_list[math.random(#move_list)]) + else + -- message.notify("move " .. mover.name .. vim.inspect(move_list)) + for _, move in ipairs(move_list) do + if try_move(self, move, in_fear, target_row, target_col) then + return + end + end + end +end + +---comment +---@param move Move +---@param in_fear boolean +---@param target_row integer +---@param target_col integer +---@return boolean move successful +function Movement:enemy_try_move(move, in_fear, target_row, target_col) + local new_row = self.row + move.row + local new_col = self.col + move.col + local entity = buffer.get_entity_at_pos(new_row, new_col) + local player_row, player_col = buffer.get_under_cursor() + -- message.notify( "move " .. mover.name .. " to 'nil' " .. vim.inspect(move) .. " dist: " .. move.dist .. " r:" .. self.row + move.row .. " c:" .. self.col + move.col) + if entity then + -- message.notify("move " .. mover.name .. " to '" .. char .. "' " .. vim.inspect(move)) + -- invincible for a couple of moves + if new_row == player_row and new_col == player_col then + if state.turn_counter < 2 then + state.player.combat:dodge(self.parent.combat) + else + -- hit player + state.player:hit_by(self.parent, target_row, target_col) + end + return true + elseif + entity + --TODO: make enemy factions to generalize friend vs enemy + and entity.type == Def.DefType.friend -- attack friends + and entity.type == Def.DefType.enemy -- attack other enemies + then + -- mover.combat:entity_attack_entity(mover, entity) + self.parent.combat:attack(entity.combat) + else + self:make_move(move) + if in_fear then + message.notify(self.parent.name .. " is scared") + end + return true + end + end + return false +end + +---comment +---@param move Move +---@param in_fear boolean +---@param target_row integer +---@param target_col integer +---@return boolean move successful +function Movement:friend_try_move(move, in_fear, target_row, target_col) + local new_row = self.row + move.row + local new_col = self.col + move.col + local entity = buffer.get_entity_at_pos(new_row, new_col) + local player_row, player_col = buffer.get_under_cursor() + -- message.notify( "move " .. mover.name .. " to 'nil' " .. vim.inspect(move) .. " dist: " .. move.dist .. " r:" .. self.row + move.row .. " c:" .. self.col + move.col) + if entity then + -- message.notify("move " .. mover.name .. " to '" .. char .. "' " .. vim.inspect(move)) + -- invincible for a couple of moves + if new_row == player_row and new_col == player_col then + state.player.attributes.health = state.player.attributes.health + 0.5 + message.notify(self.parent.name .. " Hugged you") + elseif entity and entity.type == Def.DefType.enemy then + -- mover.combat:entity_attack_entity(mover, entity) + self.parent.combat:attack(entity.combat) + else + self:make_move(move) + if in_fear then + message.notify(self.parent.name .. " is scared") + end + return true + end + end + return false +end + +---comment +---@param direction string hjkl +function Movement:kick(direction) + local move = Move.directions[direction] + if move == nil then + return + end + local player_row, player_col = buffer.get_under_cursor() + local kick_row, kick_col = player_row + move.row, player_col + move.col + local target = buffer.get_entity_at_pos(kick_row, kick_col) + if target then + if target.movement:make_move(move) then + message.notify("Kicked " .. target.name) + else + message.notify("Kick failed on " .. target.name) + end + else + message.notify("Kicked the air") + end +end + +return Movement diff --git a/lua/neohack/components/sneak.lua b/lua/neohack/components/sneak.lua new file mode 100644 index 0000000..74b2dbb --- /dev/null +++ b/lua/neohack/components/sneak.lua @@ -0,0 +1,53 @@ +--- ability to sneak +--- + +local state = require("neohack.state") +local chance = require("neohack.chance") +local attributes = require("neohack.attribute_getters") + +---@class Sneak +---@field parent Entity +---@field sneak_threshold number +local Sneak = {} +Sneak.__index = Sneak +Sneak.__name = "Sneak" + +---comment +---@param parent Entity +---@return Sneak +function Sneak.new(parent) + local instance = { + parent = parent, + + sneak_threshold = 0.5, + } + parent.attributes.sneak_attribute = 0.5 + setmetatable(instance, Sneak) + return instance +end + +function Sneak:inspect() + return "sneak_skill=" .. self:sneak_skill() +end +function Sneak:attributes() + return { sneak_attribute = self.parent.attributes.sneak_attribute } +end + +--TODO: enemy sneaking + +---comment +---@return number +function Sneak:sneak_skill() + return self.parent.attributes.sneak_attribute + + self.parent.inventory:wear_effect(state.slot_types.feet, attributes.vision, 0.7) +end + +---comment +---comment +---@param target Sneak +---@return boolean +function Sneak:try_sneak(target) + return chance.action_success(self:sneak_skill(), 0, target.parent.attributes.vision, self.sneak_threshold) +end + +return Sneak diff --git a/lua/neohack/components/speak.lua b/lua/neohack/components/speak.lua new file mode 100644 index 0000000..8112452 --- /dev/null +++ b/lua/neohack/components/speak.lua @@ -0,0 +1,89 @@ +--- ability to speak +--- + +local state = require("neohack.state") +local message = require("neohack.message") +local Def = require("neohack.def") + +---@class Speak +---@field parent Entity +---@field spell Spell +local Speak = {} +Speak.__index = Speak +Speak.__name = "Speak" + +---comment +---@param parent Entity +---@return Speak +function Speak.new(parent, spell) + local instance = { + parent = parent, + + spell = spell, + } + setmetatable(instance, Speak) + return instance +end + +function Speak:inspect() + return "inscription=" .. (self.spell and self.spell.inscription or "") +end +function Speak:attributes() + return { + --TODO: is there a way to treat this like attributes? + -- inscription = self.spell and self.spell.inscription, + -- effect = self.spell and self.spell.effect, + } +end + +function Speak:say(words) + local word_str = table.concat(words, " ") + message.notify(self.parent.name, "said", word_str) + + local spell_names = "" + ---@type Entity[] + local casting = {} + for _, spell_item in pairs(self.parent.inventory:get_spell_items()) do + if string.find(word_str, spell_item.speak.spell.inscription) ~= nil then + if spell_item.speak.spell.effect then + table.insert(casting, spell_item) + spell_names = spell_names .. spell_item.speak.spell.effect.name .. " " + end + end + end + if #casting > 0 then + message.notify(self.parent.name, "casting", spell_names) + end + + local enemies, items = state.scan_area( + state.current_bufnr, + self.parent.movement.row, + self.parent.movement.col, + self.parent.vision:view_distance() + ) + + local seen = {} + for _, enemy in ipairs(enemies) do + table.insert(seen, enemy) + end + for _, item in ipairs(items) do + table.insert(seen, item) + end + + for _, entity in ipairs(seen) do + local cast = (#casting > 0 and " cast " .. spell_names) or "" + if string.find(word_str, entity.name) ~= nil then + if entity.type == Def.DefType.enemy then + message.notify(entity.name, "heard", self.parent.name, cast) + end + for _, spell in ipairs(casting) do + if spell.speak.spell.effect then + spell.speak.spell.effect.cast(entity, 1) + message.notify(self.parent.name, "Cast", spell.speak.spell.effect.name, "on", entity.name) + end + end + end + end +end + +return Speak diff --git a/lua/neohack/components/vision.lua b/lua/neohack/components/vision.lua new file mode 100644 index 0000000..b57864d --- /dev/null +++ b/lua/neohack/components/vision.lua @@ -0,0 +1,65 @@ +--- ability to sneak +--- + +local state = require("neohack.state") +local attributes = require("neohack.attribute_getters") + +---@class Vision +---@field parent Entity +---@field search_threshold number +---@field corpse_threshold number +local Vision = {} +Vision.__index = Vision +Vision.__name = "Vision" + +---comment +---@param parent Entity +---@param vision_attribute number +---@return Vision +function Vision.new(parent, vision_attribute) + local instance = { + parent = parent, + + corpse_threshold = 0.5, + search_threshold = 0.5, + } + --TODO: simplify to a single attribute + parent.attributes.view_attribute = 10 + parent.attributes.search_attribute = 0.5 + parent.attributes.vision = vision_attribute + setmetatable(instance, Vision) + return instance +end + +function Vision:inspect() + return "view_distance=" + .. self:view_distance() + .. " search_skill=" + .. self:search_skill() + .. " vision=" + .. self.parent.attributes.vision +end +function Vision:attributes() + return { + view_attribute = self.parent.attributes.view_attribute, + search_attribute = self.parent.attributes.search_attribute, + vision = self.parent.attributes.vision, + } +end + +---comment +---@return number +function Vision:search_skill() + return self.parent.attributes.search_attribute + + self.parent.inventory:wear_effect(state.slot_types.head, attributes.vision, 0.7) +end + +---comment +---@return number +function Vision:view_distance() + return self.parent.attributes.view_attribute + + self.parent.inventory:wear_effect(state.slot_types.head, attributes.vision, 4) + + self.parent.inventory:wear_effect(state.slot_types.left_hand, attributes.vision, 4) +end + +return Vision diff --git a/lua/neohack/constant_defs.lua b/lua/neohack/constant_defs.lua new file mode 100644 index 0000000..b2beb55 --- /dev/null +++ b/lua/neohack/constant_defs.lua @@ -0,0 +1,33 @@ +--- the definitions of the game pieces and movements +--- + +local Def = require("neohack.def") + +local M = {} + +M.player = "@" +M.enemy_corpse = "x" +M.not_visible = "." +M.floor_char = " " + +M.hidden_highlight = "LineNr" -- gray fg +M.hurt_highlight = "CurSearch" -- red bg +M.hitting_highlight = "Search" -- green bg +M.missing_highlight = "Question" -- yellow fg +M.cursor_hit_highlight = { bg = "#8f1122" } -- dark red bg +M.cursor_dodge_highlight = { bg = "#8f5d11" } -- dark orange bg + +M.item_highlight = "Directory" -- green fg +M.enemy_highlight = "ErrorMsg" -- red fg + +M.slap = Def.new({ + type = Def.DefType.item, + name = "slap", + char = "slap", -- TODO: is this needed? + damage = 1, + durability = 10, + hit_rate = 0.5, + block_vision = false, +}) + +return M diff --git a/lua/neohack/def.lua b/lua/neohack/def.lua index 74b07d6..26b1e76 100644 --- a/lua/neohack/def.lua +++ b/lua/neohack/def.lua @@ -1,5 +1,5 @@ --- @class Def ---- @field type string +--- @field type "terrain" | "item" | "enemy" | "friend" | "floor" | "player" --- @field block_vision boolean --- @field char string --- @field name string @@ -10,7 +10,7 @@ --- @field moves nil | Move[] -- the moves this entity can make --- @field randomness nil | number -- how chaotic entity is --- @field vision nil | number -- how well entity can see movements and attacks ---- @field inscription nil | Spell -- a spell written on the item +--- @field spell nil | Spell -- a spell written on the item local Def = {} Def.__index = Def @@ -31,32 +31,10 @@ function Def.new(args) instance.moves = args.moves instance.randomness = args.randomness instance.vision = args.vision - instance.inscription = args.inscription + instance.spell = args.spell return instance end ----comment ----@return string description -function Def:look() - return self.char - .. ":" - .. self.name - .. " health:" - .. (self.health or 0) - .. " durability:" - .. (self.durability or 0) - .. " damage:" - .. (self.damage or 0) - .. " hit_rate:" - .. (self.hit_rate or 0) - .. " randomness:" - .. (self.randomness or 0) - .. " vision:" - .. (self.vision or 0) - .. " inscription:" - .. (self.inscription and self.inscription.inscription or "") -end - -- enum of Def type values Def.DefType = { terrain = "terrain", @@ -64,6 +42,7 @@ Def.DefType = { enemy = "enemy", friend = "friend", floor = "floor", + player = "player", } return Def diff --git a/lua/neohack/defs.lua b/lua/neohack/defs.lua index 7f7c21b..6e77ed3 100644 --- a/lua/neohack/defs.lua +++ b/lua/neohack/defs.lua @@ -6,24 +6,10 @@ local Entity = require("neohack.entity") local Def = require("neohack.def") local generated_defs = require("neohack.generated_defs") local utils = require("neohack.utils") +local constant_defs = require("neohack.constant_defs") local M = {} -M.player = "@" -M.enemy_corpse = Entity.enemy_corpse -M.player_corpse = "X" -M.not_visible = "." - -M.slap = Def.new({ - type = Def.DefType.item, - name = "slap", - char = "slap", -- TODO: is this needed? - damage = 1, - durability = 10, - hit_rate = 0.5, - block_vision = false, -}) - ---@type table M.item_defs = { ["$"] = Def.new({ @@ -99,9 +85,9 @@ M.item_defs = { randomness = 0.01, }), - [M.enemy_corpse] = Def.new({ + [constant_defs.enemy_corpse] = Def.new({ type = Def.DefType.item, - char = M.enemy_corpse, + char = constant_defs.enemy_corpse, name = "corpse", damage = 0, durability = 2, @@ -111,18 +97,6 @@ M.item_defs = { vision = 0.5, randomness = 0.8, }), - [M.player_corpse] = Def.new({ - type = Def.DefType.item, - name = "player_corpse", - char = "X", - damage = 0, - durability = 5, - hit_rate = 0.5, - health = 0.5, - block_vision = false, - vision = 0.5, - randomness = 0.01, - }), } M.terrain_defs = { @@ -176,7 +150,7 @@ M.terrain_defs = { char = "0", name = "snake_pit", damage = 3, - hit_rate = 0.5, + hit_rate = 0.9, block_vision = false, vision = 0.7, }), @@ -320,7 +294,7 @@ M.enemy_defs = { M.floor = Def.new({ type = Def.DefType.floor, name = "floor", - char = " ", + char = constant_defs.floor_char, block_vision = false, vision = -0.5, }) @@ -356,22 +330,11 @@ M.tome = Def.new({ randomness = 0.99, }) -M.hidden_highlight = "LineNr" -M.hit_by_highlight = "CurSearch" -M.hitting_highlight = "Search" -M.missing_highlight = "Question" -M.cursor_hit_highlight = { bg = "#8f1122" } -M.cursor_dodge_highlight = { bg = "#8f5d11" } - ---comment ---@param defs Def ---@return Entity M.new_random_of_type = function(defs, row, col) local random_key = utils.random_key(defs) - if random_key == M.player_corpse then - -- don't randomly pick a player corpse - return M.new_floor(row, col) - end local entity = Entity.new(defs[random_key], row, col) return entity end @@ -386,15 +349,17 @@ M.new_random_entity = function(row, col) entity = M.new_random_of_type(M.item_defs, row, col) elseif math.random() < 0.005 then entity = M.new_random_of_type(M.enemy_defs, row, col) + table.insert(entity.inventory.items, M.new_random_of_type(M.item_defs, 0, 0)) -- elseif math.random() < 0.001 then -- entity = M.new_down(row, col) elseif math.random() < 0.1 then entity = Entity.new(M.tome, row, col) - entity.inscription = generated_defs.generate_spell() + entity.speak.spell = generated_defs.generate_spell() elseif math.random() < 0.02 then entity = Entity.new(generated_defs.generate_item(), row, col) elseif math.random() < 0.005 then entity = Entity.new(generated_defs.generate_enemy(), row, col) + table.insert(entity.inventory.items, M.new_random_of_type(M.item_defs, 0, 0)) end return entity end @@ -427,7 +392,9 @@ M.new_entity_from_char = function(char, row, col) end local enemy = M.enemy_defs[char] if enemy then - return Entity.new(enemy, row, col) + local entity = Entity.new(enemy, row, col) + table.insert(entity.inventory.items, M.new_random_of_type(M.item_defs, 0, 0)) + return entity end local def = M.get_def_from_char(char) if def then @@ -465,7 +432,7 @@ end ---@param col integer ---@return Entity M.new_player_corpse = function(row, col) - return Entity.new(M.item_defs[M.player_corpse], row, col) + return Entity.new(M.player_corpse, row, col) end ---comment diff --git a/lua/neohack/edits.lua b/lua/neohack/edits.lua index d79af9d..f59f33d 100644 --- a/lua/neohack/edits.lua +++ b/lua/neohack/edits.lua @@ -1,6 +1,5 @@ local M = {} -local player = require("neohack.player") local Def = require("neohack.def") local defs = require("neohack.defs") local buffer = require("neohack.buffer") @@ -9,10 +8,7 @@ local map = require("neohack.map") local state = require("neohack.state") local utils = require("neohack.utils") local chance = require("neohack.chance") -local event = require("neohack.event") local actions = require("neohack.actions") -local view_buffer = require("neohack.view_buffer") -local inventory = require("neohack.inventory") M.handle_insert_enter = function() state.insert_enter_start = vim.api.nvim_win_get_cursor(0) @@ -74,7 +70,7 @@ M.handle_insert_exit = function() state.insert_enter_start = {} if #inserted > 0 then -- reset cursor position before performing any action - view_buffer.move_prev_cursor() + state.player:restore_previous_position() for _, line in ipairs(inserted) do actions.execute_action(line) @@ -109,7 +105,7 @@ M.handle_changed = function() local deleted_e = "" for _, entity in ipairs(deleted_entities) do -- message.notify("Deleted entity: '" .. vim.inspect(entity) .. "'") - deleted_e = deleted_e .. entity.char + deleted_e = deleted_e .. entity.movement.char end -- message.notify("Deleted entities: '" .. deleted_e .. "'") @@ -120,9 +116,9 @@ M.handle_changed = function() local char = deleted_chars:sub(i, i) local found = false for e_i, entity in ipairs(deleted_entities) do - if entity.char == char then + if entity.movement.char == char then found = true - if not inventory.can_pickup(entity) then + if not state.player.inventory:can_pickup(entity) then all_items = false else table.insert(entities, entity) @@ -141,15 +137,19 @@ M.handle_changed = function() -- message.notify("Can't pickup non items '" .. deleted_chars .. "'") else for _, entity in ipairs(entities) do - inventory.pickup(entity) - buffer.set_entity_at_cell(entity.row, entity.col, defs.new_floor(entity.row, entity.col)) + state.player.inventory:pickup(entity) + buffer.set_entity_at_cell( + entity.movement.row, + entity.movement.col, + state.entity_generator.new_floor(entity.movement.row, entity.movement.col) + ) -- TODO: do this once for all entities -- in the real buffer too, otherwise it only updates on the next tick local line = vim.api.nvim_get_current_line() - local new_line = line:sub(1, entity.col) + local new_line = line:sub(1, entity.movement.col) .. string.rep(defs.floor.char, #deleted_chars) - .. line:sub(entity.col or #line) + .. line:sub(entity.movement.col or #line) vim.api.nvim_set_current_line(new_line) end -- -- deleted not always under cursor @@ -192,8 +192,9 @@ M.handle_yanked = function() if entities then for _, entity in ipairs(entities) do if entity.type ~= Def.DefType.floor then - local search_success = chance.action_success(player.search_skill(), 0.3, 0.1, player.corpse_threshold) - if entity.visible or search_success then + local search_success = + chance.action_success(state.player.vision:search_skill(), 0.3, 0.1, state.player.vision.corpse_threshold) + if entity.movement.visible or search_success then table.insert(names, entity.name) else table.insert(names, "unknown") diff --git a/lua/neohack/entity.lua b/lua/neohack/entity.lua index 29a140b..0403d17 100644 --- a/lua/neohack/entity.lua +++ b/lua/neohack/entity.lua @@ -1,135 +1,146 @@ -local Def = require("neohack.def") -local Move = require("neohack.move") -local moves = require("neohack.moves") +local Inventory = require("neohack.components.inventory") +local Health = require("neohack.components.health") +local constant_defs = require("neohack.constant_defs") +local Combat = require("neohack.components.combat") +local Sneak = require("neohack.components.sneak") +local Fuse = require("neohack.components.fuse") +local Eat = require("neohack.components.eat") +local Vision = require("neohack.components.vision") +local Movement = require("neohack.components.movement") +local highlight = require("neohack.highlight") +local Speak = require("neohack.components.speak") +local Decision = require("neohack.components.decision") +local utils = require("neohack.utils") +local Attributes = require("neohack.components.attributes") ---- @class Entity : Def ---- @field row integer ---- @field col integer ---- @field hit_highlight string ---- @field visible boolean ---- @field scared number ---- @field seen boolean seen before by player -local Entity = setmetatable({}, { __index = Def }) +---@class Entity +---@field movement Movement +---@field inventory Inventory +---@field health Health +---@field combat Combat +---@field sneak Sneak +---@field fuse Fuse +---@field eat Eat +---@field vision Vision +---@field decision Decision +---@field highlight_group string +local Entity = {} Entity.__index = Entity - -Entity.enemy_corpse = "x" +Entity.__name = "Entity" ---comment ----@param def Def a def +---@param def Def the template to base this entity on ---@param row integer ---@param col integer ---@return Entity function Entity.new(def, row, col) ---@class Entity - local instance = Def.new(def) + local instance = {} setmetatable(instance, Entity) - -- if def.row < 1 or def.col < 1 then - -- error("Invalid cell coordinates " .. def.row .. " " .. def.col) - -- end - instance.row = row - instance.col = col - instance.visible = true - instance.scared = 0.01 - instance.seen = false - instance.hit_highlight = nil - instance.highlight_group = Entity.create_highlight(instance) - return instance -end - ----comment ----@param row integer ----@param col integer ----@return Move[] sorted moves sorted by closest, moves are still relative -function Entity:moves_by_distance(row, col) - ---@type Move[] - local moves_with_distances = {} - for _, move in pairs(self.moves or moves.eight) do - local distance = move.manhattan_distance(self.row + move.row, self.col + move.col, row, col) - table.insert(moves_with_distances, Move.new({ dir = move.dir, row = move.row, col = move.col, dist = distance })) - end - table.sort(moves_with_distances, function(a, b) - return a.dist < b.dist - end) - return moves_with_distances -end + -- from def + instance.name = def.name + instance.type = def.type -Entity.item_highlight = "Directory" -Entity.enemy_highlight = "ErrorMsg" + -- components + instance.attributes = Attributes.new() + instance.movement = Movement.new(instance, def.char, def.moves, row, col, def.block_vision) + instance.inventory = Inventory.new(instance) + instance.health = Health.new(instance, def.health) + instance.combat = Combat.new(instance, def.damage, def.durability, def.hit_rate, Entity.default_weapon) + instance.sneak = Sneak.new(instance) + instance.fuse = Fuse.new(instance) + instance.eat = Eat.new(instance) + instance.vision = Vision.new(instance, def.vision) + instance.speak = Speak.new(instance, def.spell) + instance.decision = Decision.new(instance, def.randomness) -Entity.create_highlight = function(instance) - local colour = Entity.generate_colour(instance) - local highlight_group = instance.name:gsub("[^%w]", "_") - -- print("highlight_group", "'" .. highlight_group .. "'") - vim.api.nvim_command("highlight " .. highlight_group .. " guifg=" .. colour) - return highlight_group + -- must come last to include above attributes + instance.highlight_group = highlight.create_highlight(instance) + return instance end ---- Convert a number to a value between 0 and 255 for color calculation ---- @param num number ---- @return number -local function normalize(num, max_value) - if not num then - return 0 - end - return math.floor((num / max_value) * 255) +---comment +---@return Entity +Entity.default_weapon = function() + return Entity.new(constant_defs.slap, 0, 0) end ---- Generate a color based on the values of a Def ---- @param def Def ---- @return string -Entity.generate_colour = function(def) - -- add type offsets - local redOffset = 0 - local greenOffset = 0 - local blueOffset = 0 - if def.type == Def.DefType.item then - greenOffset = 60 - blueOffset = 20 - redOffset = -10 - elseif def.type == Def.DefType.enemy then - redOffset = 60 - blueOffset = 20 - greenOffset = -20 - elseif def.type == Def.DefType.terrain then - blueOffset = 60 - redOffset = 20 - greenOffset = 10 - end - - -- Normalize attributes to create RGB values - local greenAmount = - normalize(greenOffset + normalize(def.health or 0, 10) + normalize(def.durability or 0, 10) * 0.4, 255) - local redAmount = normalize(redOffset + normalize(def.damage or 0, 10) + normalize(def.hit_rate or 1, 1) * 0.4, 255) - local blueAmount = - normalize(blueOffset + normalize(def.randomness or 0, 1) * 0.3 + normalize(def.vision or 10, 1) * 0.4, 255) - - -- Calculate RGB values - local red = math.min(255, redAmount) - local green = math.min(255, greenAmount) - local blue = math.min(255, blueAmount) - - -- Convert RGB to hex - local color = string.format("#%02X%02X%02X", red, green, blue) - return color +function Entity:components() + return { + self.movement, + self.health, + self.combat, + self.sneak, + self.fuse, + self.eat, + self.vision, + self.speak, + self.decision, + self.inventory, + self.attributes, + } end ---comment ----@return string | nil -function Entity:get_highlight() - return self.highlight_group +---@return table all entity attributes +function Entity:get_attributes() + ---@type table + local result = { + name = self.name, + } + for _, component in ipairs(self:components()) do + for key, value in utils.sorted_pairs(component:attributes()) do + result[key] = value + end + end + return result end ---comment ----@return boolean if entity is dead -function Entity:check_dead() - if self.health <= 0 then - self.char = Entity.enemy_corpse - self.name = self.name .. "_corpse" - self.type = Def.DefType.item - self.block_vision = false - return true +---@return string description +function Entity:inspect() + local result = "name=" .. self.name .. " " + for _, component in ipairs(self:components()) do + result = result .. component:inspect() .. " " end - return false + + return result + -- return self.movement.char + -- .. ":" + -- .. self.name + -- .. " health:" + -- .. (self.health.health or 0) + -- .. " durability:" + -- .. (self.combat.durability or 0) + -- .. " damage:" + -- .. (self.combat.damage or 0) + -- .. " hit_rate:" + -- .. (self.combat.hit_rate or 0) + -- .. " randomness:" + -- .. (self.decision.randomness or 0) + -- .. " vision:" + -- .. (self.vision.vision or 0) + -- .. " inscription:" + -- .. (self.speak.spell and self.speak.spell.inscription or "") + -- .. "\n" + -- .. " dodge:" + -- .. self.combat:dodge_skill() + -- .. " weapon:" + -- .. self.combat:weapon_skill() + -- .. " deflect:" + -- .. self.combat:deflect_skill() + -- .. " sneak:" + -- .. self.sneak:sneak_skill() + -- .. " fuse:" + -- .. self.fuse:fuse_skill() + -- .. " eat:" + -- .. self.eat:eat_skill() + -- .. " view distance:" + -- .. self.vision:view_distance() + -- .. " search:" + -- .. self.vision:search_skill() + -- .. "\n" + -- .. self.inventory:get_inventory() end return Entity diff --git a/lua/neohack/event.lua b/lua/neohack/event.lua index 256479f..34ea04a 100644 --- a/lua/neohack/event.lua +++ b/lua/neohack/event.lua @@ -3,20 +3,11 @@ local M = {} -local Def = require("neohack.def") -local defs = require("neohack.defs") local message = require("neohack.message") -local player = require("neohack.player") local map = require("neohack.map") local buffer = require("neohack.buffer") local state = require("neohack.state") -local moves = require("neohack.moves") -local Entity = require("neohack.entity") -local utils = require("neohack.utils") -local chance = require("neohack.chance") -local Move = require("neohack.move") local view_buffer = require("neohack.view_buffer") -local inventory = require("neohack.inventory") ---comment ---@return boolean @@ -29,132 +20,15 @@ end ---comment M.player_move = function() - if M.in_sneak_move() then - player.player_sneak_move() - else - player.player_hit_move() - end -end + state.player:set_position_from_cursor() ----comment ----@param mover Entity ----@param move Move -M.make_move = function(mover, move) - local new_row, new_col = mover.row + move.row, mover.col + move.col - local entity = buffer.get_entity_at_pos(new_row, new_col) - if entity and entity.char == defs.floor.char and new_row > 0 and new_col > 0 then - -- message.notify("making move " .. vim.inspect(mover) .. " " .. vim.inspect(move)) - buffer.set_entity_at_cell(mover.row, mover.col, defs.new_floor(mover.row, mover.col)) - view_buffer.set_highlight(mover.row, mover.col, nil) - - buffer.set_entity_at_cell(new_row, new_col, mover) - -- TODO: without this it leaves a hit to the enemy location - if mover.visible then - view_buffer.set_highlight(new_row, new_col, Entity.enemy_highlight) - end - return true - else - -- message.notify("can't move to " .. char or "" .. " " .. vim.inspect(mover) .. " " .. vim.inspect(move)) - return false - end -end - ----comment ----@param mover Entity ----@param target_row integer ----@param target_col integer ----@param try_move function -M.move_closer_to = function(mover, target_row, target_col, try_move) - local move_list = mover:moves_by_distance(target_row, target_col) - local in_fear = false - if mover.scared > math.random() then - move_list = utils.reverse_array(move_list) - in_fear = true - end - -- message.notify("moves " .. vim.inspect(moves)) - if math.random() < mover.randomness then - -- randomly do a random move - -- message.notify("random move") - M.make_move(mover, move_list[math.random(#move_list)]) + if M.in_sneak_move() then + state.player:player_sneak_move() else - -- message.notify("move " .. mover.name .. vim.inspect(move_list)) - for _, move in ipairs(move_list) do - if try_move(mover, move, in_fear, target_row, target_col) then - return - end - end + state.player:player_hit_move() end end ----comment ----@param mover Entity ----@param move Move ----@param in_fear boolean ----@param target_row integer ----@param target_col integer ----@return boolean move successful -M.enemy_try_move = function(mover, move, in_fear, target_row, target_col) - local new_row = mover.row + move.row - local new_col = mover.col + move.col - local entity = buffer.get_entity_at_pos(new_row, new_col) - local player_row, player_col = buffer.get_under_cursor() - -- message.notify( "move " .. mover.name .. " to 'nil' " .. vim.inspect(move) .. " dist: " .. move.dist .. " r:" .. mover.row + move.row .. " c:" .. mover.col + move.col) - if entity then - -- message.notify("move " .. mover.name .. " to '" .. char .. "' " .. vim.inspect(move)) - -- invincible for a couple of moves - if new_row == player_row and new_col == player_col then - if state.turn_counter < 2 then - player.dodge(mover) - else - -- hit player - player.hit_by(mover, target_row, target_col) - end - return true - elseif entity and entity.type == Def.DefType.friend then - M.entity_attack_entity(mover, entity) - else - M.make_move(mover, move) - if in_fear then - message.notify(mover.name .. " is scared") - end - return true - end - end - return false -end - ----comment ----@param mover Entity ----@param move Move ----@param in_fear boolean ----@param target_row integer ----@param target_col integer ----@return boolean move successful -M.friend_try_move = function(mover, move, in_fear, target_row, target_col) - local new_row = mover.row + move.row - local new_col = mover.col + move.col - local entity = buffer.get_entity_at_pos(new_row, new_col) - local player_row, player_col = buffer.get_under_cursor() - -- message.notify( "move " .. mover.name .. " to 'nil' " .. vim.inspect(move) .. " dist: " .. move.dist .. " r:" .. mover.row + move.row .. " c:" .. mover.col + move.col) - if entity then - -- message.notify("move " .. mover.name .. " to '" .. char .. "' " .. vim.inspect(move)) - -- invincible for a couple of moves - if new_row == player_row and new_col == player_col then - state.player.health = state.player.health + 0.5 - message.notify(mover.name .. " Hugged you") - elseif entity and entity.type == Def.DefType.enemy then - M.entity_attack_entity(mover, entity) - else - M.make_move(mover, move) - if in_fear then - message.notify(mover.name .. " is scared") - end - return true - end - end - return false -end - ---comment M.move_enemies = function() -- get player again after hitting things @@ -163,14 +37,14 @@ M.move_enemies = function() local enemies = map.scan_buf(state.current_bufnr) -- message.notify("enemies " .. vim.inspect(enemies)) for _, enemy in ipairs(enemies) do - if enemy:check_dead() then + if enemy.health:check_dead() then message.notify(string.gsub(enemy.name, "_corpse", "") .. " died") else - M.move_closer_to(enemy, finalRow, finalCol, M.enemy_try_move) - if enemy.hit_highlight then - view_buffer.highlight_hit(enemy.row, enemy.col, enemy.hit_highlight, 400) + enemy.movement:move_closer_to(finalRow, finalCol, enemy.movement.enemy_try_move) + if enemy.combat.hit_highlight then + view_buffer.highlight_hit(enemy.movement.row, enemy.movement.col, enemy.combat.hit_highlight, 400) end - enemy.hit_highlight = nil + enemy.combat.hit_highlight = nil end end end @@ -183,110 +57,14 @@ M.move_friends = function() local _, friends = map.scan_buf(state.current_bufnr) -- message.notify("friends " .. vim.inspect(friends)) for _, friend in ipairs(friends) do - if friend:check_dead() then + if friend.health:check_dead() then message.notify(string.gsub(friend.name, "_corpse", "") .. " died") else - M.move_closer_to(friend, finalRow, finalCol, M.friend_try_move) - if friend.hit_highlight then - view_buffer.highlight_hit(friend.row, friend.col, friend.hit_highlight, 400) - end - friend.hit_highlight = nil - end - end -end - ----comment ----@param attacker Entity ----@param target Entity -------@return boolean if hit was successful -M.entity_attack_entity = function(attacker, target) - if chance.action_success(1, attacker.hit_rate, target.vision, player.hit_threshold) then - target.health = target.health - attacker.damage - attacker.hit_highlight = defs.hit_by_highlight - target.hit_highlight = defs.hitting_highlight - if target:check_dead() then - message.notify(attacker.name .. " Killed " .. target.name .. " for " .. attacker.damage) - else - message.notify(attacker.name .. " Hit " .. target.name .. " for " .. attacker.damage) - end - return true - else - message.notify(attacker.name .. " Missed " .. target.name) - return false - end -end - ----comment ----@param direction string hjkl -M.kick = function(direction) - local move = Move.directions[direction] - if move == nil then - return - end - local player_row, player_col = buffer.get_under_cursor() - local kick_row, kick_col = player_row + move.row, player_col + move.col - local target = buffer.get_entity_at_pos(kick_row, kick_col) - if target then - -- message.notify("Kicking " .. target.row .. " " .. target.col .. " kick " .. kick_row .. " " .. kick_col) - if target.char == defs.enemy_corpse then - local item = player.enemy_drop_item(target) - if item then - local spawn_point = buffer.get_entity_at_pos(target.row + move.row, target.col + move.col) - if spawn_point and spawn_point.type == Def.DefType.floor then - buffer.set_entity_at_cell(target.row + move.row, target.col + move.col, item) - return - end - end - end - - if M.make_move(target, move) then - message.notify("Kicked " .. target.name) - else - message.notify("Kick failed on " .. target.name) - end - else - message.notify("Kicked the air") - end -end - -M.say = function(words) - local word_str = table.concat(words, " ") - message.notify("You said: " .. word_str) - - local spell_names = "" - ---@type Entity[] - local casting = {} - for _, spell_item in pairs(inventory.get_spell_items()) do - if string.find(word_str, spell_item.inscription.inscription) ~= nil then - table.insert(casting, spell_item) - spell_names = spell_names .. spell_item.inscription.inscription .. " " - end - end - if #casting > 0 then - message.notify("Casting " .. spell_names) - end - - local row, col = buffer.get_under_cursor() - local enemies, items = map.scan_area(state.current_bufnr, row, col, player.view_distance()) - - local seen = {} - for _, enemy in ipairs(enemies) do - table.insert(seen, enemy) - end - for _, item in ipairs(items) do - table.insert(seen, item) - end - - for _, entity in ipairs(seen) do - local cast = (#casting > 0 and " cast " .. spell_names) or "" - if string.find(word_str, entity.name) ~= nil then - if entity.type == Def.DefType.enemy then - message.notify(entity.name .. " heard you" .. cast) - end - for _, spell in ipairs(casting) do - spell.inscription.effect.cast(entity, 1) - message.notify("Cast " .. spell.inscription.effect.name .. " on " .. entity.name) + friend.movement:move_closer_to(finalRow, finalCol, friend.movement.friend_try_move) + if friend.combat.hit_highlight then + view_buffer.highlight_hit(friend.movement.row, friend.movement.col, friend.combat.hit_highlight, 400) end + friend.combat.hit_highlight = nil end end end diff --git a/lua/neohack/fuse.lua b/lua/neohack/fuser.lua similarity index 77% rename from lua/neohack/fuse.lua rename to lua/neohack/fuser.lua index b38ddee..07f5e4c 100644 --- a/lua/neohack/fuse.lua +++ b/lua/neohack/fuser.lua @@ -1,6 +1,5 @@ local M = {} -local Entity = require("neohack.entity") local message = require("neohack.message") local chance = require("neohack.chance") @@ -11,13 +10,13 @@ local chance = require("neohack.chance") ---@param low_chance number ---@param high_chance number ---@return Entity | nil -function M.fuse(one, two, skill, low_chance, high_chance) - local n = setmetatable({}, Entity) - n.type = one.type - n.block_vision = one.block_vision or two.block_vision - n.char = M.average_chars(one.char, two.char) or one.char +function M.fuse_entities(one, two, skill, low_chance, high_chance) + local n = one + local a = n.attributes + a.block_vision = one.attributes.block_vision or two.attributes.block_vision + n.movement.char = M.average_chars(one.movement.char, two.movement.char) or one.movement.char n.name = M.combine_strings_exclusive_alternating(one.name, two.name) - n.moves = M.combine_arrays_exclusive_alternating(one.moves, two.moves) + n.movement.moves = M.combine_arrays_exclusive_alternating(one.movement.moves, two.movement.moves) local met = chance.action_success_bucket(skill, 0.1, 0, { 0, low_chance, high_chance }) if met == nil then @@ -26,22 +25,24 @@ function M.fuse(one, two, skill, low_chance, high_chance) end local function select_attribute(func) - return func(one.health, two.health), - func(one.durability, two.durability), - func(one.damage, two.damage), - func(one.hit_rate, two.hit_rate), - func(one.randomness, two.randomness), - func(one.vision, two.vision) + local o = one.attributes + local t = two.attributes + return func(o.health, t.health), + func(o.durability, t.durability), + func(o.damage, t.damage), + func(o.hit_rate, t.hit_rate), + func(o.randomness, t.randomness), + func(o.vision, t.vision) end if met == 0 then message.notify("Fuse of low quality for " .. two.name) - n.health, n.durability, n.damage, n.hit_rate, n.randomness, n.vision = select_attribute(M.min) + a.health, a.durability, a.damage, a.hit_rate, a.randomness, a.vision = select_attribute(M.min) elseif met == low_chance then message.notify("Fuse of mid quality for " .. two.name) - n.health, n.durability, n.damage, n.hit_rate, n.randomness, n.vision = select_attribute(M.average_numbers) + a.health, a.durability, a.damage, a.hit_rate, a.randomness, a.vision = select_attribute(M.average_numbers) else message.notify("Fuse of high quality for " .. two.name) - n.health, n.durability, n.damage, n.hit_rate, n.randomness, n.vision = select_attribute(M.max) + a.health, a.durability, a.damage, a.hit_rate, a.randomness, a.vision = select_attribute(M.max) end return n @@ -84,7 +85,7 @@ end --- Combine two arrays by picking an alternating element from each, exclusively --- @param one table | nil --- @param two table | nil ---- @return table | nil +--- @return Move[] | nil M.combine_arrays_exclusive_alternating = function(one, two) if one == nil or two == nil then return M.handle_nil(one, two) diff --git a/lua/neohack/game.lua b/lua/neohack/game.lua index e9f27cc..e2f0e91 100644 --- a/lua/neohack/game.lua +++ b/lua/neohack/game.lua @@ -1,5 +1,6 @@ local tick_timer = require("neohack.tick_timer") local view_buffer = require("neohack.view_buffer") +local defs = require("neohack.defs") --- the overall game --- @@ -9,7 +10,7 @@ local M = { local message = require("neohack.message") local edits = require("neohack.edits") -local player = require("neohack.player") +local Player = require("neohack.player") local map = require("neohack.map") local buffer = require("neohack.buffer") local state = require("neohack.state") @@ -22,9 +23,8 @@ local generated_defs = require("neohack.generated_defs") M.start_game = function() vim.cmd("silent! only") message.open() - state.init_state() + state.init_state(Player.new()) generated_defs.init() - state.player.alive = true vim.fn.setreg('"', "") if #utils.keys(buffer.buffers) == 0 then @@ -34,8 +34,11 @@ M.start_game = function() M.setup_buffer(buffer.buffers[bufnr].bufnr, 1) end - player.handle_down = M.handle_down - player.handle_up = M.handle_up + state.entity_generator.new_floor = defs.new_floor + state.entity_generator.new_entity_from_char = defs.new_entity_from_char + state.scan_area = map.scan_area + state.player.handle_down = M.handle_down + state.player.handle_up = M.handle_up message.notify("New game started.") actions.actions.help() end @@ -92,7 +95,7 @@ end ---comment M.handle_moved = function() - if player.is_dead() then + if state.player.health:is_dead() then M.end_game() end M.tick() @@ -100,7 +103,7 @@ end ---comment M.handle_insert = function() - if player.is_dead() then + if state.player.health:is_dead() then M.end_game() end edits.handle_insert() @@ -114,7 +117,7 @@ end ---TODO: deprecated cursor moved also handles changed ---comment M.handle_changed = function() - if player.is_dead() then + if state.player.health:is_dead() then M.end_game() end -- message.notify("changed") @@ -134,8 +137,7 @@ M.end_game = function() vim.schedule(function() for bufnr, _ in pairs(buffer.buffers) do local _, _, first_floor = map.scan_buf(bufnr) - vim.api.nvim_set_current_buf(bufnr) - view_buffer.set_cursor(first_floor.row, first_floor.col) + view_buffer.setup_buffer(bufnr, first_floor) map.all_visible(bufnr) end buffer.end_game() @@ -167,12 +169,12 @@ M.tick = function() event.move_friends() - if player.is_dead() then + if state.player.health:is_dead() then M.end_game() end -- map.generate_new_map_sections(state.current_bufnr, state.player.attributes.view_distance) - map.show_visible_line_of_sight(state.current_bufnr, player.view_distance()) + map.show_visible_line_of_sight(state.current_bufnr, state.player.vision:view_distance()) end) end @@ -193,7 +195,7 @@ M.status_line = function() game_mode[mode] or mode, state.current_floor, state.turn_counter, - state.player.health, + state.player.attributes.health, line, col ) diff --git a/lua/neohack/generated_defs.lua b/lua/neohack/generated_defs.lua index cc1523b..8cb6a67 100644 --- a/lua/neohack/generated_defs.lua +++ b/lua/neohack/generated_defs.lua @@ -1,5 +1,7 @@ local M = { + ---@type table enemy_list = nil, + ---@type table item_list = nil, } @@ -48,7 +50,7 @@ M.random_letter = function() end -- Helper function to allocate portions of total points to each stat -local function allocate_portions(total_points) +M.allocate_portions = function(total_points) local portions = {} local health = math.random() @@ -83,7 +85,7 @@ M.generate_item = function() local name = M.random_word_starting_with(M.item_list, char) local total_points = state.current_floor * math.random() * 10 -- local total_points = 10 * math.random() * 10 - local portions = allocate_portions(total_points) + local portions = M.allocate_portions(total_points) local health = calculate_stat_integer(1, portions.health, 2) local durability = calculate_stat_integer(0, portions.durability, 5) @@ -92,7 +94,7 @@ M.generate_item = function() local randomness = calculate_stat_float(0.2, portions.randomness, 0.5) local vision = calculate_stat_float(0.2, portions.vision, 0.1) local block_vision = math.random() > 0.9 - local inscription = nil + local spell = nil local new_def = Def.new({ type = Def.DefType.item, @@ -105,7 +107,7 @@ M.generate_item = function() randomness = randomness, block_vision = block_vision, vision = vision, - inscription = inscription, + spell = spell, -- TODO: random moveset moves = moves.eight, }) @@ -136,7 +138,7 @@ M.generate_enemy = function() local name = M.random_word_starting_with(M.enemy_list, char) local total_points = state.current_floor * math.random() * 2 -- local total_points = 10 * math.random() * 2 - local portions = allocate_portions(total_points) + local portions = M.allocate_portions(total_points) local health = calculate_stat_integer(1, portions.health, 2) local durability = calculate_stat_integer(1, portions.durability, 2) @@ -202,22 +204,27 @@ local function generate_spell() end end +---comment +---@return Entity +M.example_entity = function() + return Entity.new(M.generate_enemy(), 0, 0) +end + ---comment ---@return table M.numeric_attributes = function() - local entity = Entity.new(M.generate_enemy(), 0, 0) - local numeric_attributes = {} - for key, value in pairs(entity) do + local attrs = {} + for key, value in utils.sorted_pairs(M.example_entity():get_attributes()) do -- includes both float and integer if type(value) == "number" then - numeric_attributes[key] = value + attrs[key] = value end end - return numeric_attributes + return attrs end ---comment ----@return Spell +---@return Effect M.generate_effect = function() -- use an example enemy definition local attribute = utils.random_key(M.numeric_attributes()) @@ -236,9 +243,9 @@ M.generate_effect = function() local old = target[attribute] local change_value = 1 * amount if plus == 0 then - target[attribute] = target[attribute] + change_value + target.attributes[attribute] = target.attributes[attribute] + change_value else - target[attribute] = target[attribute] - change_value + target.attributes[attribute] = target.attributes[attribute] - change_value end tick_timer.add_event(time, function(tick) if tick <= 0 then diff --git a/lua/neohack/highlight.lua b/lua/neohack/highlight.lua new file mode 100644 index 0000000..1a97393 --- /dev/null +++ b/lua/neohack/highlight.lua @@ -0,0 +1,73 @@ +local Def = require("neohack.def") +local M = {} + +---comment +---@param instance Entity +---@return string +M.create_highlight = function(instance) + local colour = M.generate_colour(instance) + local highlight_group = instance.name:gsub("[^%w]", "_") + -- print("highlight_group", "'" .. highlight_group .. "'") + vim.api.nvim_command("highlight " .. highlight_group .. " guifg=" .. colour) + return highlight_group +end + +--- Convert a number to a value between 0 and 255 for color calculation +--- @param num number +--- @return number +local function normalize(num, max_value) + if not num then + return 0 + end + return math.floor((num / max_value) * 255) +end + +--- Generate a color based on the values of a Def +--- @param entity Entity +--- @return string +M.generate_colour = function(entity) + -- add type offsets + local redOffset = 0 + local greenOffset = 0 + local blueOffset = 0 + if entity.type == Def.DefType.item then + greenOffset = 60 + blueOffset = 20 + redOffset = -10 + elseif entity.type == Def.DefType.enemy then + redOffset = 60 + blueOffset = 20 + greenOffset = -20 + elseif entity.type == Def.DefType.terrain then + blueOffset = 60 + redOffset = 20 + greenOffset = 10 + end + + -- Normalize attributes to create RGB values + local greenAmount = normalize( + greenOffset + normalize(entity.attributes.health or 0, 10) + normalize(entity.attributes.durability or 0, 10) * 0.4, + 255 + ) + local redAmount = normalize( + redOffset + normalize(entity.attributes.damage or 0, 10) + normalize(entity.attributes.hit_rate or 1, 1) * 0.4, + 255 + ) + local blueAmount = normalize( + blueOffset + + normalize(entity.attributes.randomness or 0, 1) * 0.3 + + normalize(entity.attributes.vision or 10, 1) * 0.4, + 255 + ) + + -- Calculate RGB values + local red = math.min(255, redAmount) + local green = math.min(255, greenAmount) + local blue = math.min(255, blueAmount) + + -- Convert RGB to hex + local color = string.format("#%02X%02X%02X", red, green, blue) + return color +end + +return M diff --git a/lua/neohack/inventory.lua b/lua/neohack/inventory.lua deleted file mode 100644 index 3a04702..0000000 --- a/lua/neohack/inventory.lua +++ /dev/null @@ -1,128 +0,0 @@ ---- player inventory ---- - -local utils = require("neohack.utils") -local Def = require("neohack.def") -local defs = require("neohack.defs") -local state = require("neohack.state") -local message = require("neohack.message") - -local M = {} - ----comment ----@param entity Entity ----@return boolean -M.can_pickup = function(entity) - return entity.type == Def.DefType.item or entity.type == Def.DefType.floor -end - ----comment ----@param entity Entity -M.pickup = function(entity) - if entity.char == defs.player_corpse then - M.pickup_player_corpse() - elseif entity.type == Def.DefType.item then - M.pickup_item(entity) - else - -- do nothing - end -end - ---- collect items from your corpse -M.pickup_player_corpse = function() - for _, value in ipairs(state.corpse.items) do - table.insert(state.player.items, value) - end - state.corpse = { row = nil, col = nil, items = {} } - message.notify("Got your corpse.") -end - ---- pick up item ----@param item Entity -M.pickup_item = function(item) - table.insert(state.player.items, 1, item) - message.notify("Got a " .. item.name) -end - ----comment ----@return string -M.get_inventory_item_with_index = function() - local items_str = "" - for index, item in pairs(state.player.items) do - items_str = items_str .. index .. ":" .. item.name .. "\n" - end - return items_str -end - ----comment ----comment ----@return Entity[] -M.get_spell_items = function() - local spells = {} - for _, item in pairs(state.player.items) do - if item.inscription then - table.insert(spells, item) - end - end - return spells -end - ----drop the oldest item that matches char ----@param char string ----@return Entity? -M.retrieve_item_char = function(char) - for i = #state.player.items, 1, -1 do - if state.player.items[i].char == char then - local entity = table.remove(state.player.items, i) - -- TODO: deal with retrieve for action being too verbose - message.notify("Retrieved " .. entity.char) - return entity - end - end - message.notify("No " .. char .. " to retrieve") - return nil -end - ----get the named item out of inventory ----@param keys string[] names or indexes ----@return Entity[]? -M.retrieve_items = function(keys) - local items = {} - local indexes = {} - for _, name in ipairs(keys) do - local found = false - local index = utils.string_to_int(name) - if index then - table.insert(items, state.player.items[index]) - table.insert(indexes, index) - found = true - else - for i, v in ipairs(state.player.items) do - if v.name == name then - -- matches first found - table.insert(items, v) - table.insert(indexes, i) - found = true - break - end - end - end - if not found then - message.notify("No " .. name .. " to retrieve") - -- get all or nothing - return nil - end - end - - -- highest index first, so they all get removed - table.sort(indexes, function(a, b) - return a > b - end) - for _, index in ipairs(indexes) do - table.remove(state.player.items, index) - end - -- message.notify("Retrieved " .. table.concat(indexes, " ")) - return items -end - -return M diff --git a/lua/neohack/map.lua b/lua/neohack/map.lua index 5b3df52..c1a1f80 100644 --- a/lua/neohack/map.lua +++ b/lua/neohack/map.lua @@ -6,9 +6,9 @@ local M = {} local Def = require("neohack.def") local defs = require("neohack.defs") local buffer = require("neohack.buffer") -local message = require("neohack.message") local state = require("neohack.state") local view_buffer = require("neohack.view_buffer") +local constant_defs = require("neohack.constant_defs") --- scan the buf, looking for points of interest ---@return Entity[] enemies @@ -21,15 +21,15 @@ M.scan_buf = function(bufnr) local cells = buffer.buffers[bufnr].cells for row_index, line in ipairs(cells) do for col_index, col in ipairs(line) do - if col.char == defs.floor.char and not first_floor then + if col.movement.char == defs.floor.char and not first_floor then first_floor = { row = row_index, col = col_index } end - if col.type == Def.DefType.enemy and col.char ~= defs.enemy_corpse then + if col.type == Def.DefType.enemy and col.movement.char ~= constant_defs.enemy_corpse then table.insert(enemies, col) end - if col.type == Def.DefType.friend and col.char ~= defs.enemy_corpse then + if col.type == Def.DefType.friend and col.movement.char ~= constant_defs.enemy_corpse then table.insert(friends, col) end end @@ -54,7 +54,7 @@ M.scan_area = function(bufnr, row, col, range) for col_index = col - range, col + range do if cells[row_index] and cells[row_index][col_index] then local cell = cells[row_index][col_index] - if cell.type == Def.DefType.enemy and cell.char ~= defs.enemy_corpse then + if cell.type == Def.DefType.enemy and cell.movement.char ~= constant_defs.enemy_corpse then table.insert(enemies, cell) elseif cell.type == Def.DefType.item then table.insert(items, cell) @@ -93,9 +93,8 @@ M.is_visible_line_of_sight = function(row, col, target_row, target_col, cells) elseif cells[row] and cells[row][col] - -- TODO: add block_vision - and cells[row][col].block_vision - and cells[row][col].char ~= defs.floor.char + and cells[row][col].attributes.block_vision + and cells[row][col].movement.char ~= defs.floor.char then -- hidden return false @@ -134,15 +133,15 @@ M.show_visible_line_of_sight = function(bufnr, view_distance) M.manhattan_distance(row_index, col_index, player_row, player_col) > view_distance or not M.is_visible_line_of_sight(player_row, player_col, row_index, col_index, cells) then - entity.visible = false + entity.movement.visible = false -- setting highlights individually for non visible chars hurts performance a lot - -- view_buffer.set_highlight(row_index, col_index, defs.hidden_highlight) + -- view_buffer.set_highlight(row_index, col_index, constant_defs.hidden_highlight) else - entity.visible = true + entity.movement.visible = true if entity.type == Def.DefType.terrain then - entity.seen = true + entity.movement.seen = true end - local hi = entity:get_highlight() + local hi = entity.highlight_group if hi then view_buffer.set_highlight(row_index, col_index, hi) end @@ -156,7 +155,7 @@ M.all_visible = function(bufnr) local cells = buffer.buffers[bufnr].cells for _, line in ipairs(cells) do for _, cell in ipairs(line) do - cell.visible = true + cell.movement.visible = true end end buffer.write_buf(bufnr) @@ -219,48 +218,49 @@ end ---comment ---@param bufnr integer ---@return Entity[][] -M.find_deleted = function(bufnr) - local new_buffer = view_buffer.read_a_buf(bufnr) - local old_buffer = buffer.buffers[bufnr].cells - -- TODO: this doesn't work properly on the last line - while #new_buffer < #old_buffer do - -- there are deleted lines - local cursor_row = vim.api.nvim_win_get_cursor(0)[1] - if cursor_row == #new_buffer then - cursor_row = cursor_row + 1 - end - -- message.notify("inserted empty row at " .. cursor_row) - table.insert(new_buffer, cursor_row, {}) - end - return M.find_deleted_positions(old_buffer, new_buffer) -end --- Function to compare two states of the buffer and find deleted positions ----comment ----@param old_buffer Entity[][] ----@param new_buffer Entity[][] ----@return Entity[] -M.find_deleted_positions = function(old_buffer, new_buffer) - local deleted_positions = {} - - for row = 1, #old_buffer do - for col = 1, #old_buffer[row] do - if old_buffer[row][col].visible then - if not new_buffer[row] or not new_buffer[row][col] then - table.insert(deleted_positions, old_buffer[row][col]) - elseif - new_buffer[row][col].char ~= defs.not_visible and new_buffer[row][col].char ~= old_buffer[row][col].char - then - -- message.notify(old_buffer[row][col].char .. " vs " .. new_buffer[row][col].char) - table.insert(deleted_positions, old_buffer[row][col]) - end - else - -- message.notify("not visible " .. vim.inspect(old_buffer[row][col])) - end - end - end - - return deleted_positions -end +-- M.find_deleted = function(bufnr) +-- local new_buffer = view_buffer.read_a_buf(bufnr) +-- local old_buffer = buffer.buffers[bufnr].cells +-- -- TODO: this doesn't work properly on the last line +-- while #new_buffer < #old_buffer do +-- -- there are deleted lines +-- local cursor_row = vim.api.nvim_win_get_cursor(0)[1] +-- if cursor_row == #new_buffer then +-- cursor_row = cursor_row + 1 +-- end +-- -- message.notify("inserted empty row at " .. cursor_row) +-- table.insert(new_buffer, cursor_row, {}) +-- end +-- return M.find_deleted_positions(old_buffer, new_buffer) +-- end +-- -- Function to compare two states of the buffer and find deleted positions +-- ---comment +-- ---@param old_buffer Entity[][] +-- ---@param new_buffer Entity[][] +-- ---@return Entity[] +-- M.find_deleted_positions = function(old_buffer, new_buffer) +-- local deleted_positions = {} +-- +-- for row = 1, #old_buffer do +-- for col = 1, #old_buffer[row] do +-- if old_buffer[row][col].movement.visible then +-- if not new_buffer[row] or not new_buffer[row][col] then +-- table.insert(deleted_positions, old_buffer[row][col]) +-- elseif +-- new_buffer[row][col].movement.char ~= constant_defs.not_visible +-- and new_buffer[row][col].movement.char ~= old_buffer[row][col].movement.char +-- then +-- -- message.notify(old_buffer[row][col].movement.char .. " vs " .. new_buffer[row][col].movement.char) +-- table.insert(deleted_positions, old_buffer[row][col]) +-- end +-- else +-- -- message.notify("not visible " .. vim.inspect(old_buffer[row][col])) +-- end +-- end +-- end +-- +-- return deleted_positions +-- end ---comment ---@param grid Entity[][] @@ -276,10 +276,10 @@ M.find_patterns = function(grid, row_start, col_start, pattern_lines) local row = row_start + row_inner - 1 local col = col_start + col_inner - 1 local cell = grid[row][col] - -- message.notify("looking at " .. cell.char .. " " .. row .. " " .. col) + -- message.notify("looking at " .. cell.movement.char .. " " .. row .. " " .. col) -- not visible matches anything - if cell.char ~= sub_pattern and sub_pattern ~= defs.not_visible then - -- message.notify("no match " .. cell.char .. " " .. sub_pattern) + if cell.movement.char ~= sub_pattern and sub_pattern ~= constant_defs.not_visible then + -- message.notify("no match " .. cell.movement.char .. " " .. sub_pattern) return nil end @@ -334,7 +334,7 @@ M.find_closest_match = function(bufnr, pattern_lines, player_row, player_col) for _, match in ipairs(matches) do local first_cell = match[1] - local distance = math.abs(player_row - first_cell.row) + math.abs(player_col - first_cell.col) + local distance = math.abs(player_row - first_cell.movement.row) + math.abs(player_col - first_cell.movement.col) if distance < min_distance then closest_match = match min_distance = distance @@ -358,12 +358,12 @@ end M.present_in_buffer = function(entities) local cells = view_buffer.read_buf_chars(state.current_bufnr) for _, entity in ipairs(entities) do - local char = cells[entity.row][entity.col] - if char ~= entity.char then - -- message.notify("difference '" .. entity.char .. "'!='" .. char .. "' at " .. entity.row .. " " .. entity.col) + local char = cells[entity.movement.row][entity.movement.col] + if char ~= entity.movement.char then + -- message.notify("difference '" .. entity.movement.char .. "'!='" .. char .. "' at " .. entity.movement.row .. " " .. entity.movement.col) return false else - -- message.notify("no difference '" .. entity.char .. "'=='" .. char .. "' at " .. entity.row .. " " .. entity.col) + -- message.notify("no difference '" .. entity.movement.char .. "'=='" .. char .. "' at " .. entity.movement.row .. " " .. entity.movement.col) end end return true diff --git a/lua/neohack/message.lua b/lua/neohack/message.lua index 0a917f4..838c8f3 100644 --- a/lua/neohack/message.lua +++ b/lua/neohack/message.lua @@ -15,7 +15,7 @@ end ---comment ---@param ... any M.notify = function(...) - M.notify_func(...) + M.notify_func({ ... }) end ---comment @@ -35,7 +35,7 @@ end ---comment ---@param ... any M.send_to_message_buf = function(...) - local message = table.concat({ ... }, " ") + local message = table.concat(..., " ") vim.schedule(function() vim.api.nvim_buf_set_lines(M.message_buf, -1, -1, false, split_string_into_lines(message)) local win_ids = vim.fn.win_findbuf(M.message_buf) diff --git a/lua/neohack/player.lua b/lua/neohack/player.lua index f99f524..7a15823 100644 --- a/lua/neohack/player.lua +++ b/lua/neohack/player.lua @@ -1,439 +1,190 @@ --- player stuff --- -local fuse = require("neohack.fuse") local Def = require("neohack.def") -local defs = require("neohack.defs") local buffer = require("neohack.buffer") local state = require("neohack.state") local message = require("neohack.message") local Entity = require("neohack.entity") -local chance = require("neohack.chance") local view_buffer = require("neohack.view_buffer") -local inventory = require("neohack.inventory") - -local M = { - ---@type number required to search a corpse - corpse_threshold = 0.5, - ---@type number required to search the map - search_threshold = 0.5, - ---@type number required to sneak - sneak_threshold = 0.5, - ---@type number required to dodge - dodge_threshold = 0.5, - ---@type number required to hit - hit_threshold = 0.5, - ---@type number player ability to sneak - fuse_low_threshold = 0.3, - ---@type number player ability to sneak - fuse_high_threshold = 0.8, +local constant_defs = require("neohack.constant_defs") + +---@class Player: Entity +---@field handle_down function +---@field handle_up function +---@field attributes table +local Player = setmetatable({}, { __index = Entity }) +Player.__index = Player +Player.__name = "Player" +function Player.new() + -- defaults for player + local def = { + type = Def.DefType.player, + block_vision = false, + char = constant_defs.player, + name = "player", + health = 10, + durability = 20, + damage = 1, + hit_rate = 0.5, + moves = nil, + randomness = 0.01, + vision = 0.5, + spell = nil, + } + ---@class Player + local instance = Entity.new(def, 0, 0) + setmetatable(instance, Player) ---@type function - handle_down = nil, + instance.handle_down = nil ---@type function - handle_up = nil, -} + instance.handle_up = nil ----comment ----@return boolean -M.is_dead = function() - return not state.player.alive or state.player.health <= 0 + return instance end ---comment -M.player_sneak_move = function() +function Player:player_sneak_move() local _, _, entity = buffer.get_under_cursor() - if chance.action_success(M.sneak_skill(), 0, entity.vision, M.sneak_threshold) then + if self.sneak:try_sneak(entity.sneak) then message.notify("Sneaked past a " .. entity.name) else - view_buffer.move_prev_cursor() + self:restore_previous_position() message.notify("Sneaked failed") end - M.store_previous_position() + + self:store_previous_position() end ---comment -M.player_hit_move = function() +function Player:player_hit_move() local row, col, entity = buffer.get_under_cursor() -- TODO: refactor to put hit logic inside the entity - if row == state.corpse.row and col == state.corpse.col then - -- delete to pickup - -- M.hit_corpse(row, col) - elseif entity.type == Def.DefType.item then + if entity.type == Def.DefType.item then -- delete to pickup - -- M.hit_item(row, col, defs.item_defs[curr]) + -- self:hit_item(row, col, defs.item_defs[curr]) elseif entity.type == Def.DefType.floor then -- do nothing elseif entity.type == Def.DefType.friend then - view_buffer.move_prev_cursor() - entity.health = entity.health + 0.5 + self:restore_previous_position() + entity.attributes.health = entity.attributes.health + 0.5 message.notify("Hugged " .. entity.name) elseif entity.type == Def.DefType.enemy then - M.hit_enemy(row, col) + self:hit_enemy(row, col) elseif entity.type == Def.DefType.terrain then - M.hit_terrain(row, col, entity) + self:hit_terrain(row, col, entity) else -- assume other characters are terrain - M.hit_terrain(row, col, entity) + self:hit_terrain(row, col, entity) end - M.store_previous_position() + self:store_previous_position() -- TODO: do we need to mark the player? -- mark the player position - -- buffer.replace_char_at(state.prev_cursor.row, state.prev_cursor.col, defs.player) + -- buffer.replace_char_at(state.prev_cursor.row, state.prev_cursor.col, constant_defs.player) end -M.store_previous_position = function() +function Player:store_previous_position() -- message.notify("store prev position") local prevRow, prevCol = unpack(vim.api.nvim_win_get_cursor(0)) state.prev_cursor = { row = prevRow, col = prevCol + 1 } end ----comment ----@param entity Entity ----@return Entity | nil -M.enemy_drop_item = function(entity) - local resistance = (entity.randomness and entity.randomness * 0.3) or 0 - if chance.action_success(M.search_skill(), 0.3, resistance, M.corpse_threshold) then - return defs.new_random_item(nil, nil) - else - message.notify("Empty corpse.") - return nil - end +function Player:restore_previous_position() + view_buffer.move_prev_cursor() + self:set_position_from_cursor() +end + +--TODO: when does the cursor need to be moved based on the entity? ie in the other direction +---updates the entity position based on the cursor +function Player:set_position_from_cursor() + local cursor_row, cursor_col = buffer.get_under_cursor() + self.movement.row = cursor_row + self.movement.col = cursor_col end --- bounce off enemies, unless you kill them ---@param row integer ---@param col integer -M.hit_enemy = function(row, col) +function Player:hit_enemy(row, col) local enemy = buffer.get_entity_at_pos(row, col) if enemy then - local weapon = M.get_weapon() - -- message.notify("Attacking " .. enemy.name .. " with a " .. vim.inspect(weapon.name)) - if chance.action_success(M.weapon_skill(), weapon.hit_rate, enemy.vision, M.hit_threshold) then - M.hit_enemy_success(enemy, weapon) - else - view_buffer.move_prev_cursor() - enemy.hit_highlight = defs.missing_highlight - message.notify("Missed " .. enemy.name) + -- if not self.combat:try_hit_enemy(enemy) then + self.combat:attack(enemy.combat) + if not enemy.health:is_dead() then + -- bounce if enemy didn't die + self:restore_previous_position() end end end ----comment ----@param enemy Entity ----@param weapon Entity -M.hit_enemy_success = function(enemy, weapon) - local damage = weapon.damage - if weapon.durability <= 0 then - damage = weapon.damage * 0.1 - end - enemy.health = enemy.health - damage - if enemy:check_dead() then - table.insert(state.player.bodies, enemy) - message.notify("Killed " .. enemy.name .. " with a " .. weapon.name .. " for " .. damage) - else - enemy.hit_highlight = defs.hitting_highlight - view_buffer.move_prev_cursor() - message.notify("Hit " .. enemy.name .. " with a " .. weapon.name .. " for " .. damage) - end - - weapon.durability = weapon.durability - 1 - if weapon.durability <= 0 then - message.notify("Broke " .. weapon.name) - end -end - --- bounce off terrain, optionally take damage ---@param row integer ---@param col integer ---@param terrain Entity -M.hit_terrain = function(row, col, terrain) - view_buffer.highlight_hit(row, col, defs.hitting_highlight, 150) +function Player:hit_terrain(row, col, terrain) + view_buffer.highlight_hit(row, col, constant_defs.hitting_highlight, 150) -- bounce off terrain - view_buffer.move_prev_cursor() + self:restore_previous_position() if terrain.name == "down" then - local level = M.handle_down() + local level = self:handle_down() message.notify("Went down to " .. level) elseif terrain.name == "up" then - local level = M.handle_up() + local level = self:handle_up() if level then message.notify("Went up to " .. level) else message.notify("Up is blocked. On " .. state.current_floor) end - elseif terrain.damage then - M.hit_by(terrain, state.prev_cursor.row, state.prev_cursor.col) + elseif terrain.attributes.damage then + self:hit_by(terrain, state.prev_cursor.row, state.prev_cursor.col) else message.notify("Bumped a " .. terrain.name) end end ----comment ----@return string -M.get_inventory = function() - local p = state.player - local parts = {} - for _, body in pairs(p.bodies) do - table.insert(parts, body.name) +function Player:eat_keys(keys) + local died = self.eat:eat(keys) + if died then + local row, col = buffer.get_under_cursor() + self:died(row, col) end - local bodies = table.concat(parts, " ") - local items_str = inventory.get_inventory_item_with_index() - return "wearing: " .. M.get_wearing() .. "\nitems:\n" .. items_str .. "\nkills: " .. bodies -end - ----comment ----@return Entity -M.get_weapon = function() - return state.player.slots[state.slots.right_hand] or Entity.new(defs.slap, 0, 0) -end - -M.get_wearing = function() - local wearing = "" - for name, char in pairs(state.slots) do - local item = state.player.slots[char] - local item_name = item and item.name or "" - wearing = wearing .. name .. ":" .. item_name .. " " - end - return wearing -end - ----comment ----@param key string ----@param slot string either short or long, eg h or head -M.wear = function(key, slot) - -- unwear the current item - local p = state.player - if #slot > 1 then - -- handle slot name - slot = state.slots[slot] - end - local current = p.slots[slot] - if current then - table.insert(state.player.items, current) - message.notify("Stowed " .. current.name .. ".") - p.slots[slot] = nil - end - - if key == "0" or key == "nothing" then - return - end - local items = inventory.retrieve_items({ key }) - if items and items[1] then - p.slots[slot] = items[1] - message.notify("Wearing " .. items[1].name .. " on " .. state.slot_name(slot)) - else - return - end -end - ----comment ----@param keys string[] -M.fuse = function(keys) - local items = inventory.retrieve_items(keys) - if not items then - return - end - message.notify("Fusing " .. vim.inspect(items)) - - -- got all items, fuse them - local new_def = items[1] - for i = 2, #items do - local latest = fuse.fuse(new_def, items[i], M.fuse_skill(), M.fuse_low_threshold, M.fuse_high_threshold) - if latest then - new_def = latest - end - end - local new_item = Entity.new(new_def, 0, 0) - message.notify("Fused " .. table.concat(keys, " ") .. " into " .. new_item.name) - inventory.pickup_item(new_item) -end - ----comment ----@param keys string[] -M.look = function(keys) - for i, key in ipairs(keys) do - if key == "0" or key == "self" then - message.notify("Looked at self " .. M.get_attributes()) - table.remove(keys, i) - end - end - local items = inventory.retrieve_items(keys) - if not items then - return - end - for _, item in ipairs(items) do - if item then - inventory.pickup_item(item) - message.notify("Looked at " .. item:look()) - end - end -end - ----comment ----@param keys string[] -M.eat = function(keys) - local items = inventory.retrieve_items(keys) - if not items then - return - end - local eat_skill = M.eat_skill() - for _, item in ipairs(items) do - if item then - local p = state.player - p.health = p.health + (item.health or 0) - chance.action_eat(eat_skill, 0.8, item.damage) - -- TODO: make these changes revert after a timer - p.attributes.view_distance = p.attributes.view_distance + chance.action_eat(eat_skill, 1, item.vision) - p.attributes.dodge_skill = p.attributes.dodge_skill + chance.action_eat(eat_skill, 0.1, item.randomness) - p.attributes.weapon_skill = p.attributes.weapon_skill + chance.action_eat(eat_skill, 0.05, item.hit_rate) - p.attributes.search_skill = p.attributes.search_skill + chance.action_eat(eat_skill, 0.1, item.vision) - p.attributes.sneak_skill = p.attributes.sneak_skill + chance.action_eat(eat_skill, 0.1, item.vision) - p.attributes.fuse_skill = p.attributes.fuse_skill + chance.action_eat(eat_skill, 0.1, item.randomness) - - p.attributes.eat_skill = p.attributes.eat_skill + 0.01 - - -- TODO what other affects could eating have? - message.notify("Ate a " .. item.name .. ". health:" .. p.health .. " " .. vim.inspect(p.attributes)) - local row, col = buffer.get_under_cursor() - M.check_dead(row, col) - end - end -end - ----comment ----@param keys string[] -M.drop = function(keys) - local items = inventory.retrieve_items(keys) - if not items then - return - end - local row, col = buffer.get_under_cursor() - for _, item in ipairs(items) do - -- TODO: allow drop without overwrite - buffer.set_entity_at_cell(row, col, item) - end -end - ----comment ----@param mover Entity -M.dodge = function(mover) - message.notify("Dodged " .. mover.name .. ".") -end - ----comment ----@return boolean true if player is now dead -M.check_dead = function(row, col) - if not state.player.alive or state.player.health <= 0 then - -- TODO: fix not ending game when we die - state.player.alive = false - message.notify("YOU DIED " .. M.get_inventory()) - local items = state.player.items - for _, item in ipairs(state.player.slots) do - table.insert(items, item) - end - state.corpse = { row = row, col = col, items = items } - buffer.set_entity_at_cell(row, col, defs.new_player_corpse(row, col)) - return true - end - return false end --- player was hit by something ---@param attacker Entity ---@param row integer ---@param col integer -M.hit_by = function(attacker, row, col) - -- test dodge success - if chance.action_success(M.dodge_skill(), 0, attacker.hit_rate or 0, M.dodge_threshold) then - buffer.highlight_cursor(defs.cursor_dodge_highlight) - M.dodge(attacker) +function Player:hit_by(attacker, row, col) + -- local cursor_highlight = self.combat:hit_by(attacker) + local hit = attacker.combat:attack(self.combat) + if hit then + buffer.highlight_cursor(constant_defs.cursor_hit_highlight) else - local damage = attacker.damage - M.deflect_skill() - -- message.notify("damage calc " .. state.player.health .. " " .. M.deflect_skill() .. " " .. attacker.damage) - if damage > 0 then - state.player.health = state.player.health - damage - -- TODO: hit player highlight not working - -- highlight is set, but cursor overrides it - buffer.highlight_cursor(defs.cursor_hit_highlight) - view_buffer.highlight_hit(attacker.row, attacker.col, defs.hit_by_highlight, 200) - message.notify("Hurt by " .. attacker.name .. " for " .. damage) - - M.check_dead(row, col) - else - buffer.highlight_cursor(defs.cursor_dodge_highlight) - message.notify("Deflected " .. attacker.name) - end + buffer.highlight_cursor(constant_defs.cursor_dodge_highlight) end -end - ----comment ----@param slot string ----@param attribute string ----@param effectiveness number ----@return number -M.wear_effect = function(slot, attribute, effectiveness) - local item = state.player.slots[slot] - return (item and item[attribute] or 0) * effectiveness -end - -M.view_distance = function() - return state.player.attributes.view_distance - + M.wear_effect(state.slots.head, "vision", 4) - + M.wear_effect(state.slots.left_hand, "vision", 4) -end - -M.dodge_skill = function() - return state.player.attributes.dodge_skill - + M.wear_effect(state.slots.feet, "randomness", 0.3) - + M.wear_effect(state.slots.feet, "hit_rate", 0.2) -end - -M.weapon_skill = function() - return state.player.attributes.weapon_skill + M.wear_effect(state.slots.left_hand, "hit_rate", 0.7) -end -M.search_skill = function() - return state.player.attributes.search_skill + M.wear_effect(state.slots.head, "vision", 0.7) -end - -M.sneak_skill = function() - return state.player.attributes.sneak_skill + M.wear_effect(state.slots.feet, "vision", 0.7) + if self.health:check_dead() then + self:died(row, col) + end end -M.fuse_skill = function() - return state.player.attributes.fuse_skill + M.wear_effect(state.slots.left_hand, "randomness", 0.7) +function Player:inspect() + return Entity.inspect(self) .. " turns:" .. state.turn_counter end -M.eat_skill = function() - return state.player.attributes.eat_skill + M.wear_effect(state.slots.body, "randomness", 0.7) -end +function Player:died(row, col) + local parts = {} + for _, body in pairs(state.bodies) do + table.insert(parts, body.name) + end + local bodies = table.concat(parts, " ") -M.deflect_skill = function() - return state.player.attributes.deflect_skill - + M.wear_effect(state.slots.body, "durability", 0.4) - + M.wear_effect(state.slots.left_hand, "durability", 0.3) -end + message.notify(self:inspect(), "\n", self.inventory:get_inventory(), "kills:", bodies) + message.notify("YOU DIED") -M.get_attributes = function() - return "health:" - .. state.player.health - .. " turns:" - .. state.turn_counter - .. " view distance:" - .. M.view_distance() - .. " dodge:" - .. M.dodge_skill() - .. " weapon:" - .. M.weapon_skill() - .. " search:" - .. M.search_skill() - .. " sneak:" - .. M.sneak_skill() - .. " fuse:" - .. M.fuse_skill() - .. " eat:" - .. M.eat_skill() - .. " deflect:" - .. M.deflect_skill() + -- put the dead player's corpse at the current cursor location + buffer.set_entity_at_cell(row, col, self) end -return M +return Player diff --git a/lua/neohack/read_buffer.lua b/lua/neohack/read_buffer.lua new file mode 100644 index 0000000..27dac3a --- /dev/null +++ b/lua/neohack/read_buffer.lua @@ -0,0 +1,23 @@ +local state = require("neohack.state") + +local M = {} + +--TODO: is this actually used now that maps are generated? it creates entities from the existing chars +--- Read buffer content and update the cells for a specific buffer +---@param bufnr integer +---@return Entity[][] +M.read_a_buf = function(bufnr) + local new_cells = {} -- prepare a new frame + local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) + for row, value in ipairs(lines) do + local result = {} + for col = 1, #value do + local char = value:sub(col, col) + result[col] = state.entity_generator.new_entity_from_char(char, row, col) + end + new_cells[row] = result + end + return new_cells +end + +return M diff --git a/lua/neohack/spells.lua b/lua/neohack/spells.lua index 33fd0ad..4c3d77f 100644 --- a/lua/neohack/spells.lua +++ b/lua/neohack/spells.lua @@ -19,14 +19,17 @@ M.effects = { friendship = { name = "friendship", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) if target.type == Def.DefType.enemy then target.type = Def.DefType.friend - target.block_vision = false + target.attributes.block_vision = false tick_timer.add_event(4, function(tick) if tick <= 0 then target.type = Def.DefType.enemy - target.block_vision = false + target.attributes.block_vision = false message.notify("friendship wore off on " .. target.name) return true end @@ -37,13 +40,16 @@ M.effects = { sentience = { name = "sentience", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) -- TODO: bring items to life -- TODO: randomly enemy or friend if target.type == Def.DefType.item then if amount > 0.5 then target.type = Def.DefType.friend - target.block_vision = false + target.attributes.block_vision = false else target.type = Def.DefType.enemy end @@ -60,8 +66,11 @@ M.effects = { peering = { name = "peering", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - target.block_vision = false + target.attributes.block_vision = false end, }, @@ -71,6 +80,9 @@ M.effects = { hurt = { name = "hurt", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) target.health = target.health - (1 * amount) -- death with be checked on next tick @@ -79,19 +91,25 @@ M.effects = { erosion = { name = "erosion", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - target.durability = target.durability - (1 * amount) + target.attributes.durability = target.attributes.durability - (1 * amount) end, }, blunting = { name = "blunting", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - local old = target.damage - target.damage = target.damage - (1 * amount) + local old = target.attributes.damage + target.attributes.damage = target.attributes.damage - (1 * amount) tick_timer.add_event(10, function(tick) if tick <= 0 then - target.damage = old + target.attributes.damage = old message.notify("sharpen wore off on " .. target.name) return true end @@ -100,12 +118,15 @@ M.effects = { }, sharpen = { name = "sharpen", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - local old = target.damage - target.damage = target.damage + (1 * amount) + local old = target.attributes.damage + target.attributes.damage = target.attributes.damage + (1 * amount) tick_timer.add_event(10, function(tick) if tick <= 0 then - target.damage = old + target.attributes.damage = old message.notify("sharpen wore off on " .. target.name) return true end @@ -115,19 +136,25 @@ M.effects = { disruption = { name = "disruption", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - target.hit_rate = target.hit_rate - (1 * amount) + target.attributes.hit_rate = target.attributes.hit_rate - (1 * amount) end, }, still = { name = "still", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - local old_moves = target.moves - target.moves = moves.still + local old_moves = target.movement.moves + target.movement.moves = moves.still tick_timer.add_event(10, function(tick) if tick <= 0 then - target.moves = old_moves + target.movement.moves = old_moves message.notify("still wore off on " .. target.name) return true end @@ -137,13 +164,16 @@ M.effects = { confuse = { name = "confuse", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - local old_moves = target.moves + local old_moves = target.movement.moves local random_move_key = utils.random_key(moves) - target.moves = moves[random_move_key] + target.movement.moves = moves[random_move_key] tick_timer.add_event(10, function(tick) if tick <= 0 then - target.moves = old_moves + target.movement.moves = old_moves message.notify("confuse wore off on " .. target.name) return true end @@ -153,32 +183,44 @@ M.effects = { chaos = { name = "chaos", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - target.randomness = target.randomness + (0.5 * amount) + target.attributes.randomness = target.attributes.randomness + (0.5 * amount) end, }, blind = { name = "blind", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - target.vision = target.vision - (0.5 * amount) + target.attributes.vision = target.attributes.vision - (0.5 * amount) end, }, fear = { name = "fear", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) - target.scared = 0.2 * amount + target.attributes.scared = 0.2 * amount end, }, wizard_eyes = { name = "wizard eyes", + ---comment + ---@param target Entity + ---@param amount number cast = function(target, amount) -- TODO: should be used for identifying magic or hidden -- if item and has spell, then rename to effect - target.block_vision = false - target.seen = true + target.attributes.block_vision = false + target.movement.seen = true end, }, } diff --git a/lua/neohack/state.lua b/lua/neohack/state.lua index c649548..751104d 100644 --- a/lua/neohack/state.lua +++ b/lua/neohack/state.lua @@ -1,45 +1,10 @@ --- the overall game --- -local utils = require("neohack.utils") - -local default_attributes = { - ---@type integer how far player can see - view_distance = 10, - ---@type number player ability to dodge attacks - dodge_skill = 0.5, - ---@type number player ability to hit - weapon_skill = 0.5, - ---@type number player ability to search - search_skill = 0.5, - ---@type number player ability to sneak - sneak_skill = 0.5, - ---@type number player ability to fuse - fuse_skill = 0.5, - ---@type number player ability to eat - eat_skill = 0.1, - ---@type number player ability to deflect - deflect_skill = 0.01, -} - local M = { - --- the player state - player = { - ---@type boolean is player alive? - alive = true, - --- player health - ---@type number - health = 0, - attributes = default_attributes, - --- a stack of items, most recently picked up will be used - ---@type Entity[] - items = {}, - ---@type table slot char to equipped item - slots = {}, - --- list of dead bodies - ---@type Entity[] - bodies = {}, - }, + --- the player entity + ---@type Player + player = nil, --- the previous cursor position prev_cursor = { row = 1, col = 1 }, @@ -61,18 +26,22 @@ local M = { ---@type integer[] insert_enter_start = {}, - --- previous player corpse - corpse = { - ---@type integer - row = nil, - ---@type integer - col = nil, - ---@type Entity[] - items = {}, + --- list of kills + ---@type Entity[] + bodies = {}, + + entity_generator = { + ---@type function + new_floor = nil, + ---@type function + new_entity_from_char = nil, }, + + ---@type function + scan_area = nil, } -M.slots = { +M.slot_types = { head = "h", right_hand = "r", left_hand = "l", @@ -81,7 +50,7 @@ M.slots = { } M.slot_name = function(char) - for name, slot_char in pairs(M.slots) do + for name, slot_char in pairs(M.slot_types) do if slot_char == char then return name end @@ -90,15 +59,10 @@ M.slot_name = function(char) end --- Initialize the game state -M.init_state = function() - M.player = { - -- a stack of items, most recently picked up will be used - health = 10, - attributes = utils.table_deep_copy(default_attributes), - items = {}, - slots = {}, - bodies = {}, - } +---comment +---@param player Player +M.init_state = function(player) + M.player = player M.prev_cursor = { row = 1, col = 1 } M.turn_counter = 0 M.current_floor = 1 diff --git a/lua/neohack/utils.lua b/lua/neohack/utils.lua index 9d21abd..27d1638 100644 --- a/lua/neohack/utils.lua +++ b/lua/neohack/utils.lua @@ -1,7 +1,5 @@ local M = {} -local message = require("neohack.message") - M.table_deep_copy = function(orig) local orig_type = type(orig) local copy @@ -117,4 +115,43 @@ M.capitalize_first_letter = function(str) return str:gsub("^%l", string.upper) end +---comment +---@param t table +---@return function +M.sorted_pairs = function(t) + local keys = {} + for k in pairs(t) do + table.insert(keys, k) + end + table.sort(keys) + local i = 0 + return function() + i = i + 1 + if keys[i] then + return keys[i], t[keys[i]] + end + end +end + +--TODO: mixin components +M.mixin = function(target, ...) + local args = { ... } + for _, source in ipairs(args) do + for key, value in pairs(source) do + if type(value) == "function" then + target[key] = value + end + end + end + return setmetatable(target, { + __index = function(t, k) + for _, source in ipairs(args) do + if source[k] then + return source[k] + end + end + end, + }) +end + return M diff --git a/lua/neohack/view_buffer.lua b/lua/neohack/view_buffer.lua index 13e82e5..12594b8 100644 --- a/lua/neohack/view_buffer.lua +++ b/lua/neohack/view_buffer.lua @@ -1,3 +1,4 @@ +local constant_defs = require("neohack.constant_defs") --- all the interactions with the actual neovim buffer that is seen, ie the view --- @@ -5,7 +6,6 @@ local M = { AutoCmdGroup = "NeoHackGameTick", } -local defs = require("neohack.defs") local state = require("neohack.state") local namespace = vim.api.nvim_create_namespace("NeoHack") @@ -50,24 +50,6 @@ M.add_handler = function(buffer, event, func) }) end ---TODO: is this actually used now that maps are generated? it creates entities from the existing chars ---- Read buffer content and update the cells for a specific buffer ----@param bufnr integer ----@return Entity[][] -M.read_a_buf = function(bufnr) - local new_cells = {} -- prepare a new frame - local lines = vim.api.nvim_buf_get_lines(bufnr, 0, -1, false) - for row, value in ipairs(lines) do - local result = {} - for col = 1, #value do - local char = value:sub(col, col) - result[col] = defs.new_entity_from_char(char, row, col) - end - new_cells[row] = result - end - return new_cells -end - --- Read buffer content as a grid of chars ---@param bufnr integer ---@return string[][] @@ -94,12 +76,12 @@ M.write_buf = function(bufnr, cells) for row_index, row in ipairs(cells) do local value = {} for _, col in ipairs(row) do - if col.visible then - table.insert(value, col.char) - elseif col.seen then - table.insert(value, col.char) + if col.movement.visible then + table.insert(value, col.movement.char) + elseif col.movement.seen then + table.insert(value, col.movement.char) else - table.insert(value, defs.not_visible) + table.insert(value, constant_defs.not_visible) end end lines[row_index] = table.concat(value) diff --git a/tests/action_spec.lua b/tests/action_spec.lua index 747f538..20a023d 100644 --- a/tests/action_spec.lua +++ b/tests/action_spec.lua @@ -4,7 +4,6 @@ local message = require("neohack.message") ---@diagnostic disable-next-line: undefined-field local eq = assert.is.equal -local not_nil = match.is_not_nil actions.leader_key = " " actions.tick = function() diff --git a/tests/chat_spec.lua b/tests/chat_spec.lua index dfc7815..9b6aafc 100644 --- a/tests/chat_spec.lua +++ b/tests/chat_spec.lua @@ -2,10 +2,6 @@ local match = require("luassert.match") local message = require("neohack.message") local chat = require("neohack.chat") ----@diagnostic disable-next-line: undefined-field -local eq = assert.is.equal -local not_nil = match.is_not_nil - message.notify_func = print describe("parse_request", function() diff --git a/tests/components/combat_spec.lua b/tests/components/combat_spec.lua new file mode 100644 index 0000000..89be863 --- /dev/null +++ b/tests/components/combat_spec.lua @@ -0,0 +1,123 @@ +local Combat = require("neohack.components.combat") +local helpers = require("tests.helpers") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("combat", function() + helpers.setup_game() + local damage_default = 3 + local durability = 4 + local hit_rate = 0.5 + + local function new_combat(name, weapon) + return Combat.new(helpers.new_entity(name), damage_default, durability, hit_rate, weapon) + end + + describe("skills", function() + it("dodge_skill", function() + local weapon = helpers.bad_weapon + local combat = new_combat("one", weapon) + eq(0.5, combat:dodge_skill()) + end) + + it("deflect_skill", function() + local weapon = helpers.bad_weapon + local combat = new_combat("one", weapon) + eq(0.01, combat:deflect_skill()) + end) + + it("weapon_skill", function() + local weapon = helpers.bad_weapon + local combat = new_combat("one", weapon) + eq(0.5, combat:weapon_skill()) + end) + end) + + describe("try", function() + it("try_weapon success", function() + local weapon = helpers.good_weapon + + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + eq(true, one:try_weapon(two, weapon())) + end) + + it("try_weapon failure", function() + local weapon = helpers.bad_weapon + + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + eq(false, one:try_weapon(two, weapon())) + end) + + it("try_dodge success", function() + local weapon = helpers.bad_weapon + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + + eq(true, one:try_dodge(two, weapon())) + end) + + it("try_dodge failure", function() + local weapon = helpers.good_weapon + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + + eq(false, one:try_dodge(two, weapon())) + end) + + it("try_damage_dealt success", function() + local weapon = helpers.good_weapon + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + + local damage, success = one:try_damage_dealt(two, weapon()) + eq(true, success) + eq(2.99, damage) + end) + + it("try_damage_dealt failure", function() + local weapon = helpers.bad_weapon + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + + local damage, success = one:try_damage_dealt(two, weapon()) + eq(false, success) + eq(0, damage) + end) + end) + + it("apply durability", function() + local weapon = helpers.bad_weapon + local one = new_combat("one", weapon) + local a_weapon = weapon() + local damage = one:apply_durability(10, a_weapon) + eq(10, damage) + eq(1, a_weapon.attributes.durability) + + local damage2 = one:apply_durability(10, a_weapon) + eq(10, damage2) + eq(0, a_weapon.attributes.durability) + + local damage3 = one:apply_durability(10, a_weapon) + eq(1, damage3) + eq(-1, a_weapon.attributes.durability) + end) + + it("attack fail", function() + local weapon = helpers.bad_weapon + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + + eq(false, one:attack(two)) + end) + + it("attack success", function() + local weapon = helpers.good_weapon + local one = new_combat("one", weapon) + local two = new_combat("two", weapon) + + eq(true, one:attack(two)) + end) +end) diff --git a/tests/components/decision_spec.lua b/tests/components/decision_spec.lua new file mode 100644 index 0000000..3715f31 --- /dev/null +++ b/tests/components/decision_spec.lua @@ -0,0 +1,14 @@ +local helpers = require("tests.helpers") +local Decision = require("neohack.components.decision") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("decision", function() + helpers.setup_game() + + it("randomness", function() + local one = Decision.new(helpers.new_entity("one"), 0.5) + eq(0.5, one.parent.attributes.randomness) + end) +end) diff --git a/tests/components/eat_spec.lua b/tests/components/eat_spec.lua new file mode 100644 index 0000000..e578434 --- /dev/null +++ b/tests/components/eat_spec.lua @@ -0,0 +1,32 @@ +local helpers = require("tests.helpers") +local Eat = require("neohack.components.eat") +local defs = require("neohack.defs") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("sneak", function() + helpers.setup_game() + + describe("skills", function() + it("eat_skill", function() + local combat = Eat.new(helpers.new_entity("one")) + eq(0.1, combat:eat_skill()) + end) + end) + + describe("eat", function() + it("affects health", function() + local eater = Eat.new(helpers.new_entity("one")) + local one = defs.new_entity_from_char("+", 1, 1) + eq(2, one.attributes.health) + eq(10, eater.parent.attributes.health) + + eater.parent.inventory:pickup(one) + eq(false, eater:eat({ one.name })) + + eq(12, eater.parent.attributes.health) + --TODO: test other affects of eating + end) + end) +end) diff --git a/tests/components/fuse_spec.lua b/tests/components/fuse_spec.lua new file mode 100644 index 0000000..f9c2e32 --- /dev/null +++ b/tests/components/fuse_spec.lua @@ -0,0 +1,32 @@ +local helpers = require("tests.helpers") +local Fuse = require("neohack.components.fuse") +local defs = require("neohack.defs") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("fuse", function() + helpers.setup_game() + + describe("skills", function() + it("fuse_skill", function() + local combat = Fuse.new(helpers.new_entity("one")) + eq(0.5, combat:fuse_skill()) + end) + end) + + describe("try", function() + it("try_fuse success", function() + local fuser = Fuse.new(helpers.new_entity("one")) + local one = defs.new_entity_from_char("!", 1, 1) + local two = defs.new_entity_from_char("l", 1, 1) + + fuser.parent.inventory:pickup(one) + fuser.parent.inventory:pickup(two) + eq(2, #fuser.parent.inventory.items) + + fuser:fuse({ one.name, two.name }) + eq(1, #fuser.parent.inventory.items) + end) + end) +end) diff --git a/tests/components/health_spec.lua b/tests/components/health_spec.lua new file mode 100644 index 0000000..b69b621 --- /dev/null +++ b/tests/components/health_spec.lua @@ -0,0 +1,35 @@ +local Health = require("neohack.components.health") +local helpers = require("tests.helpers") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("health", function() + helpers.setup_game() + + it("is_dead", function() + local health = Health.new(helpers.new_entity("one"), 10) + + eq(false, health:is_dead()) + + health.parent.attributes.health = 0 + eq(true, health:is_dead()) + + health.parent.attributes.health = 10 + health.alive = false + eq(true, health:is_dead()) + end) + + it("check_dead", function() + local health = Health.new(helpers.new_entity("one"), 10) + + eq(false, health:check_dead()) + + health.parent.attributes.health = 0 + eq(true, health:check_dead()) + + health.parent.attributes.health = 10 + health.alive = false + eq(true, health:check_dead()) + end) +end) diff --git a/tests/components/inventory_spec.lua b/tests/components/inventory_spec.lua new file mode 100644 index 0000000..11254e1 --- /dev/null +++ b/tests/components/inventory_spec.lua @@ -0,0 +1,147 @@ +local Inventory = require("neohack.components.inventory") +local defs = require("neohack.defs") +local state = require("neohack.state") +local Entity = require("neohack.entity") +local generated_defs = require("neohack.generated_defs") +local attributes = require("neohack.attribute_getters") +local helpers = require("tests.helpers") +local Player = require("neohack.player") + +local match = require("luassert.match") +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +helpers.setup_game() + +describe("can_pickup", function() + local inventory = Inventory.new(helpers.new_entity("one")) + it("can", function() + eq(true, inventory:can_pickup(defs.new_entity_from_char("!", 1, 1))) + eq(true, inventory:can_pickup(defs.new_entity_from_char(" ", 1, 1))) + end) + + it("cannot", function() + eq(false, inventory:can_pickup(defs.new_entity_from_char("A", 1, 1))) + end) +end) + +describe("pickup", function() + it("get_inventory_item_with_index", function() + local inventory = Inventory.new(helpers.new_entity("one")) + local item = defs.new_entity_from_char("!", 1, 1) + inventory:pickup(item) + eq(item, inventory:get_items()[1]) + eq("1:long_sword\n", inventory:get_inventory_item_with_index()) + end) + + it("get_spell_items", function() + local inventory = Inventory.new(helpers.new_entity("one")) + state.current_floor = 1 + generated_defs.init() + + local item = Entity.new(defs.tome, 1, 1) + item.speak.spell = generated_defs.generate_spell() + inventory:pickup(item) + eq(item, inventory:get_items()[1]) + match.equal({}, inventory:get_spell_items()) + end) + + it("retrieve_item_char", function() + local inventory = Inventory.new(helpers.new_entity("one")) + local item = defs.new_entity_from_char("!", 1, 1) + inventory:pickup(item) + eq(item, inventory:get_items()[1]) + match.equal(item, inventory:retrieve_item_char("!")) + end) + + it("retrieve_items", function() + local inventory = Inventory.new(helpers.new_entity("one")) + local item = defs.new_entity_from_char("!", 1, 1) + inventory:pickup(item) + eq(item, inventory:get_items()[1]) + match.equal(item, inventory:retrieve_items({ "long_sword" })[1]) + end) +end) + +describe("slots", function() + it("get_weapon", function() + local inventory = Inventory.new(helpers.new_entity("one")) + eq(nil, inventory:get_weapon()) + end) + + it("wear", function() + local inventory = Inventory.new(helpers.new_entity("one")) + eq("body= feet= head= left_hand= right_hand= ", inventory:get_wearing()) + + inventory:wear("0", "head") + eq("body= feet= head= left_hand= right_hand= ", inventory:get_wearing()) + + local one = defs.new_entity_from_char("!", 1, 1) + eq("long_sword", one.name) + inventory:pickup_item(one) + inventory:wear("1", "right_hand") + eq("body= feet= head= left_hand= right_hand=long_sword ", inventory:get_wearing()) + eq("long_sword", inventory:get_weapon().name) + + local two = defs.new_entity_from_char("$", 1, 1) + eq("coin", two.name) + inventory:pickup_item(two) + inventory:wear("coin", "right_hand") + eq("body= feet= head= left_hand= right_hand=coin ", inventory:get_wearing()) + + inventory:wear("0", "right_hand") + eq("body= feet= head= left_hand= right_hand= ", inventory:get_wearing()) + end) +end) + +describe("attributes", function() + it("defaults", function() + local inventory = Inventory.new(helpers.new_entity("one")) + eq(0, inventory:wear_effect(state.slot_types.head, attributes.vision, 4)) + end) +end) + +describe("extract_all", function() + it("returns all", function() + local inventory = Inventory.new(helpers.new_entity("one")) + + local one = defs.new_entity_from_char("$", 1, 1) + local two = defs.new_entity_from_char("!", 1, 1) + local three = defs.new_entity_from_char("l", 1, 1) + inventory:pickup(one) + inventory:pickup(two) + inventory:pickup(three) + eq(3, #inventory.items) + + local extracted = inventory:extract_all() + eq(3, #extracted) + eq(0, #inventory.items) + end) +end) + +describe("pilfer", function() + it("pilfer", function() + local player = Player.new() + + local one = defs.new_entity_from_char("$", 1, 1) + local two = defs.new_entity_from_char("!", 1, 1) + local three = defs.new_entity_from_char("l", 1, 1) + + one.inventory:pickup(two) + one.inventory:pickup(three) + one.inventory:wear("1", "right_hand") + eq("body= feet= head= left_hand= right_hand=wooden_leg ", one.inventory:get_wearing()) + eq(1, #one.inventory.items) + eq("l", one.inventory.slots[state.slot_types.right_hand].movement.char) + + player.inventory:pickup(one) + eq(1, #player.inventory.items) + + player.inventory:pilfer("coin") + + eq(3, #player.inventory.items) + eq("coin", player.inventory.items[1].name) + eq("wooden_leg", player.inventory.items[2].name) + eq("long_sword", player.inventory.items[3].name) + end) +end) diff --git a/tests/components/movement_spec.lua b/tests/components/movement_spec.lua new file mode 100644 index 0000000..626b5f7 --- /dev/null +++ b/tests/components/movement_spec.lua @@ -0,0 +1,24 @@ +local helpers = require("tests.helpers") +local Movement = require("neohack.components.movement") +local moves = require("neohack.moves") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("movement", function() + helpers.setup_game() + + describe("attributes", function() + it("has defaults", function() + local one = Movement.new(helpers.new_entity("one"), "o", moves.four, 1, 2, false) + eq("o", one.char) + eq(moves.four, one.moves) + eq(1, one.row) + eq(2, one.col) + eq(true, one.visible) + eq(0.01, one.parent.attributes.scared) + eq(false, one.seen) + eq(false, one.parent.attributes.block_vision) + end) + end) +end) diff --git a/tests/components/sneak_spec.lua b/tests/components/sneak_spec.lua new file mode 100644 index 0000000..0e76dd1 --- /dev/null +++ b/tests/components/sneak_spec.lua @@ -0,0 +1,32 @@ +local helpers = require("tests.helpers") +local Sneak = require("neohack.components.sneak") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("sneak", function() + helpers.setup_game() + + describe("skills", function() + it("sneak_skill", function() + local combat = Sneak.new(helpers.new_entity("one")) + eq(0.5, combat:sneak_skill()) + end) + end) + + describe("try", function() + it("try_sneak success", function() + local one = Sneak.new(helpers.new_entity("one")) + local two = Sneak.new(helpers.new_entity("one")) + two.parent.attributes.vision = 0.01 + eq(true, one:try_sneak(two)) + end) + + it("try_sneak failure", function() + local one = Sneak.new(helpers.new_entity("one")) + local two = Sneak.new(helpers.new_entity("one")) + two.parent.attributes.vision = 1 + eq(false, one:try_sneak(two)) + end) + end) +end) diff --git a/tests/components/speak_spec.lua b/tests/components/speak_spec.lua new file mode 100644 index 0000000..b64ebfa --- /dev/null +++ b/tests/components/speak_spec.lua @@ -0,0 +1,41 @@ +local helpers = require("tests.helpers") +local Speak = require("neohack.components.speak") +local state = require("neohack.state") +local Def = require("neohack.def") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("speak", function() + helpers.setup_game() + state.scan_area = function() + local enemies = {} + local items = {} + return enemies, items + end + + it("say word", function() + local one = Speak.new(helpers.new_entity("one")) + one:say({ "hello" }) + end) + + it("say and heard", function() + state.scan_area = function() + return { + { type = Def.DefType.enemy, name = "enemy" }, + }, {} + end + local one = Speak.new(helpers.new_entity("one")) + one:say({ "hello" }) + end) + + it("cast", function() + state.scan_area = function() + return { + { type = Def.DefType.enemy, name = "enemy" }, + }, {} + end + local one = Speak.new(helpers.new_entity("one")) + one:say({ "hello" }) + end) +end) diff --git a/tests/components/vision_spec.lua b/tests/components/vision_spec.lua new file mode 100644 index 0000000..1321207 --- /dev/null +++ b/tests/components/vision_spec.lua @@ -0,0 +1,30 @@ +local helpers = require("tests.helpers") +local Vision = require("neohack.components.vision") +local defs = require("neohack.defs") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("vision", function() + helpers.setup_game() + + describe("skills", function() + it("search_skill", function() + local one = Vision.new(helpers.new_entity("one"), 1) + eq(0.5, one:search_skill()) + end) + end) + + describe("view_distance", function() + it("try_sneak success", function() + local one = Vision.new(helpers.new_entity("one"), 1) + eq(10, one:view_distance()) + + local two = defs.new_entity_from_char("!", 1, 1) + two.attributes.vision = 3 + one.parent.inventory:pickup(two) + one.parent.inventory:wear(two.name, "h") + eq(22, one:view_distance()) + end) + end) +end) diff --git a/tests/entity_spec.lua b/tests/entity_spec.lua new file mode 100644 index 0000000..c5d1bcf --- /dev/null +++ b/tests/entity_spec.lua @@ -0,0 +1,23 @@ +local helpers = require("tests.helpers") +local utils = require("neohack.utils") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +describe("entity", function() + helpers.setup_game() + + it("inspect", function() + eq( + "name=one char=o row=1 col=1 health=10 durability=10 damage=1 hit_rate=0.5 dodge_skill=0.5 weapon_skill=0.5 deflect_skill=0.01 sneak_skill=0.5 fuse_skill=0.5 eat_skill=0.1 view_distance=10 search_skill=0.5 vision=0.5 inscription= randomness=0.1 wearing: body= feet= head= left_hand= right_hand= \nitems:\n ", + helpers.new_entity("one"):inspect() + ) + end) + + it("returns all number attributes", function() + local attrs = helpers.new_entity("one"):get_attributes() + eq("number", type(attrs["health"])) + eq(10, attrs["health"]) + eq(22, #utils.keys(attrs)) + end) +end) diff --git a/tests/fuse_spec.lua b/tests/fuser_spec.lua similarity index 68% rename from tests/fuse_spec.lua rename to tests/fuser_spec.lua index be4369c..f906fe8 100644 --- a/tests/fuse_spec.lua +++ b/tests/fuser_spec.lua @@ -1,6 +1,6 @@ local match = require("luassert.match") local message = require("neohack.message") -local fuse = require("neohack.fuse") +local fuse = require("neohack.fuser") local defs = require("neohack.defs") ---@diagnostic disable-next-line: undefined-field @@ -13,29 +13,29 @@ describe("fuse", function() it("fails", function() local one = defs.new_entity_from_char("!", 1, 1) local two = defs.new_entity_from_char("l", 2, 2) - local result = fuse.fuse(one, two, -100, 0, 0) + local result = fuse.fuse_entities(one, two, -100, 0, 0) eq(nil, result) end) it("low", function() local one = defs.new_entity_from_char("!", 1, 1) local two = defs.new_entity_from_char("l", 2, 2) - local result = fuse.fuse(one, two, 0.01, 0, 0) + local result = fuse.fuse_entities(one, two, 0.01, 0, 0) not_nil(result) if result then - eq("F", result.char) - eq(3, result.damage) + eq("F", result.movement.char) + eq(3, result.attributes.damage) end end) it("high", function() local one = defs.new_entity_from_char("!", 1, 1) local two = defs.new_entity_from_char("l", 2, 2) - local result = fuse.fuse(one, two, 100, 0.001, 0.1) + local result = fuse.fuse_entities(one, two, 100, 0.001, 0.1) not_nil(result) if result then - eq("F", result.char) - eq(5, result.damage) + eq("F", result.movement.char) + eq(5, result.attributes.damage) end end) end) diff --git a/tests/generated_defs_spec.lua b/tests/generated_defs_spec.lua new file mode 100644 index 0000000..03fa9c4 --- /dev/null +++ b/tests/generated_defs_spec.lua @@ -0,0 +1,76 @@ +local match = require("luassert.match") +local generated_defs = require("neohack.generated_defs") +local helpers = require("tests.helpers") +local utils = require("neohack.utils") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal +local not_nil = match.is_not_nil + +describe("attributes", function() + helpers.setup_game() + it("example_entity", function() + local attrs = generated_defs.example_entity():get_attributes() + eq(23, #utils.keys(attrs)) + eq("number", type(attrs.damage)) + end) + + it("numeric_attributes", function() + local attrs = generated_defs.numeric_attributes() + eq(17, #utils.keys(attrs)) + end) +end) + +describe("random_word_starting_with", function() + helpers.setup_game() + + it("a word", function() + eq("abc", generated_defs.random_word_starting_with(generated_defs.enemy_list, "a")) + + eq("abc", generated_defs.random_word_starting_with(generated_defs.item_list, "a")) + end) +end) + +describe("random_letter", function() + helpers.setup_game() + + math.randomseed(12345) + + it("a letter", function() + eq("j", generated_defs.random_letter()) + end) +end) + +describe("allocate_portions", function() + helpers.setup_game() + + math.randomseed(12345) + + it("portions", function() + local portions = generated_defs.allocate_portions(10) + not_nil(portions.health) + not_nil(portions.damage) + not_nil(portions.durability) + not_nil(portions.hit_rate) + not_nil(portions.randomness) + not_nil(portions.vision) + end) +end) + +describe("generate_effect", function() + math.randomseed(12345) + helpers.setup_game() + + it("cast effect attribute", function() + ---@type Effect + local effect = generated_defs.generate_effect() + match.is_not_nil(effect.name) + + local entity = helpers.new_entity("one") + local attrs = entity:get_attributes() + match.equal(attrs, entity:get_attributes()) + effect.cast(entity, 1) + -- some attribute was affected by the spell + match.not_equal(attrs, entity:get_attributes()) + end) +end) diff --git a/tests/helpers.lua b/tests/helpers.lua new file mode 100644 index 0000000..bdb7dad --- /dev/null +++ b/tests/helpers.lua @@ -0,0 +1,116 @@ +local message = require("neohack.message") +local state = require("neohack.state") +local buffer = require("neohack.buffer") +local Health = require("neohack.components.health") +local Inventory = require("neohack.components.inventory") +local Combat = require("neohack.components.combat") +local Sneak = require("neohack.components.sneak") +local Fuse = require("neohack.components.fuse") +local Vision = require("neohack.components.vision") +local Eat = require("neohack.components.eat") +local Movement = require("neohack.components.movement") +local moves = require("neohack.moves") +local generated_defs = require("neohack.generated_defs") +local Entity = require("neohack.entity") + +local M = {} + +M.word_list = { + a = { "abc" }, + b = { "abc" }, + c = { "abc" }, + d = { "abc" }, + e = { "abc" }, + f = { "abc" }, + g = { "abc" }, + h = { "abc" }, + i = { "abc" }, + j = { "abc" }, + k = { "abc" }, + l = { "abc" }, + m = { "abc" }, + n = { "abc" }, + o = { "abc" }, + p = { "abc" }, + q = { "abc" }, + r = { "abc" }, + s = { "abc" }, + t = { "abc" }, + u = { "abc" }, + v = { "abc" }, + w = { "abc" }, + x = { "abc" }, + y = { "abc" }, + z = { "abc" }, +} + +M.setup_game = function() + message.notify_func = print + state.current_floor = 1 + state.current_bufnr = 1 + ---@diagnostic disable-next-line: missing-fields + buffer.buffers[1] = { + cells = { { + {}, + }, { + {}, + } }, + } + generated_defs.enemy_list = M.word_list + generated_defs.item_list = M.word_list +end + +M.good_weapon = function() + local w = M.new_entity("good_weapon") + w.attributes.damage = 3 + w.attributes.hit_rate = 1.5 + w.attributes.durability = 10 + return w +end + +M.bad_weapon = function() + local w = M.new_entity("bad_weapon") + w.attributes.damage = 3 + w.attributes.hit_rate = 0.01 + w.attributes.durability = 2 + return w +end + +-- ---comment +-- ---@param name any +-- ---@return Entity +-- M.entity = function(name) +-- local it = { +-- name = name, +-- } +-- it.movement = Movement.new(it, name[1], moves.four, 1, 1, false) +-- it.inventory = Inventory.new(it) +-- it.health = Health.new(it, 10) +-- it.combat = Combat.new(it, 2, 4, 0.5, M.good_weapon) +-- it.sneak = Sneak.new(it) +-- it.fuse = Fuse.new(it) +-- it.eat = Eat.new(it) +-- it.vision = Vision.new(it, 0.5) +-- return it +-- end + +---comment +---@return Entity +M.new_entity = function(name) + local def = { + name = name, + type = "enemy", + char = "o", + moves = nil, + block_vision = false, + health = 10, + damage = 1, + durability = 10, + hit_rate = 0.5, + vision = 0.5, + randomness = 0.1, + } + return Entity.new(def, 1, 1) +end + +return M diff --git a/tests/inventory_spec.lua b/tests/inventory_spec.lua deleted file mode 100644 index bdb8a84..0000000 --- a/tests/inventory_spec.lua +++ /dev/null @@ -1,73 +0,0 @@ -local inventory = require("neohack.inventory") -local message = require("neohack.message") -local defs = require("neohack.defs") -local state = require("neohack.state") -local Entity = require("neohack.entity") -local generated_defs = require("neohack.generated_defs") - -local match = require("luassert.match") ----@diagnostic disable-next-line: undefined-field -local eq = assert.is.equal -local not_nil = match.is_not_nil - -message.notify_func = print - -describe("can_pickup", function() - it("can", function() - eq(true, inventory.can_pickup(defs.new_entity_from_char("!", 1, 1))) - eq(true, inventory.can_pickup(defs.new_entity_from_char(" ", 1, 1))) - end) - - it("cannot", function() - eq(false, inventory.can_pickup(defs.new_entity_from_char("A", 1, 1))) - end) -end) - -describe("pickup", function() - it("get_inventory_item_with_index", function() - state.player.items = {} - local item = defs.new_entity_from_char("!", 1, 1) - inventory.pickup(item) - eq(item, state.player.items[1]) - eq("1:long_sword\n", inventory.get_inventory_item_with_index()) - end) - - it("get_spell_items", function() - state.player.items = {} - state.current_floor = 1 - generated_defs.init() - - local item = Entity.new(defs.tome, 1, 1) - item.inscription = generated_defs.generate_spell() - inventory.pickup(item) - eq(item, state.player.items[1]) - match.equal({}, inventory.get_spell_items()) - end) - - it("retrieve_item_char", function() - state.player.items = {} - local item = defs.new_entity_from_char("!", 1, 1) - inventory.pickup(item) - eq(item, state.player.items[1]) - match.equal(item, inventory.retrieve_item_char("!")) - end) - - it("retrieve_items", function() - state.player.items = {} - local item = defs.new_entity_from_char("!", 1, 1) - inventory.pickup(item) - eq(item, state.player.items[1]) - match.equal(item, inventory.retrieve_items({ "long_sword" })[1]) - end) -end) - -describe("pickup_player_corpse", function() - it("get_inventory_item_with_index", function() - state.player.items = {} - local item = defs.new_entity_from_char("!", 1, 1) - state.corpse.items = { item } - inventory.pickup_player_corpse() - eq(item, state.player.items[1]) - eq("1:long_sword\n", inventory.get_inventory_item_with_index()) - end) -end) diff --git a/tests/map_spec.lua b/tests/map_spec.lua index 6c52675..632181d 100644 --- a/tests/map_spec.lua +++ b/tests/map_spec.lua @@ -59,8 +59,8 @@ local function assertFound(expected, found) if found then eq(#expected, #found) for i, e in ipairs(expected) do - eq(e[1], found[i].row, e[1] .. " " .. e[2]) - eq(e[2], found[i].col, e[1] .. " " .. e[2]) + eq(e[1], found[i].movement.row, e[1] .. " " .. e[2]) + eq(e[2], found[i].movement.col, e[1] .. " " .. e[2]) end end end @@ -76,48 +76,48 @@ local function set_buffer(chars) } end -describe("find_deleted_positions", function() - it("finds deleted", function() - local old = { - { "a", "b", "c" }, - { "d", "e", "f" }, - { "g", "h", "i" }, - { "j", "k", "l" }, - { "m", "n", "o" }, - { "p", "q", "p" }, - } - - local new = { - { " ", "b", "c" }, - { "d", " ", "f" }, - { "g", "h", " " }, - { "l" }, - { "m" }, - { "p", "q" }, - } - - local result, _ = map.find_deleted_positions(toEntities(old), toEntities(new)) - - local deleted = "" - for _, entity in ipairs(result) do - deleted = deleted .. entity.char - end - -- eq("aeijkno", deleted) - -- TODO: this is not correct, it should be "aeijkno" - eq("aeijklnop", deleted) - eq(9, #result) - eq("entity a", result[1].name) - eq("entity e", result[2].name) - eq("entity i", result[3].name) - eq("entity j", result[4].name) - eq("entity k", result[5].name) - -- TODO: this is not correct, l should not be deleted - eq("entity l", result[6].name) - eq("entity n", result[7].name) - eq("entity o", result[8].name) - eq("entity p", result[9].name) - end) -end) +-- describe("find_deleted_positions", function() +-- it("finds deleted", function() +-- local old = { +-- { "a", "b", "c" }, +-- { "d", "e", "f" }, +-- { "g", "h", "i" }, +-- { "j", "k", "l" }, +-- { "m", "n", "o" }, +-- { "p", "q", "p" }, +-- } +-- +-- local new = { +-- { " ", "b", "c" }, +-- { "d", " ", "f" }, +-- { "g", "h", " " }, +-- { "l" }, +-- { "m" }, +-- { "p", "q" }, +-- } +-- +-- local result, _ = map.find_deleted_positions(toEntities(old), toEntities(new)) +-- +-- local deleted = "" +-- for _, entity in ipairs(result) do +-- deleted = deleted .. entity.movement.char +-- end +-- -- eq("aeijkno", deleted) +-- -- TODO: this is not correct, it should be "aeijkno" +-- eq("aeijklnop", deleted) +-- eq(9, #result) +-- eq("entity a", result[1].name) +-- eq("entity e", result[2].name) +-- eq("entity i", result[3].name) +-- eq("entity j", result[4].name) +-- eq("entity k", result[5].name) +-- -- TODO: this is not correct, l should not be deleted +-- eq("entity l", result[6].name) +-- eq("entity n", result[7].name) +-- eq("entity o", result[8].name) +-- eq("entity p", result[9].name) +-- end) +-- end) ---comment ---@param result Entity[][] @@ -126,7 +126,7 @@ local function as_chars(result) local chars = "" for _, entities in ipairs(result) do for _, entity in ipairs(entities) do - chars = chars .. entity.char + chars = chars .. entity.movement.char end chars = chars .. "\n" end diff --git a/tests/player_spec.lua b/tests/player_spec.lua new file mode 100644 index 0000000..e073335 --- /dev/null +++ b/tests/player_spec.lua @@ -0,0 +1,86 @@ +local Player = require("neohack.player") +local state = require("neohack.state") +local buffer = require("neohack.buffer") +local defs = require("neohack.defs") +local helpers = require("tests.helpers") +local constant_defs = require("neohack.constant_defs") +local attributes = require("neohack.attribute_getters") + +---@diagnostic disable-next-line: undefined-field +local eq = assert.is.equal + +helpers.setup_game() + +describe("health", function() + local player = Player.new() + it("is_dead", function() + eq(false, player.health:is_dead()) + + player.attributes.health = 0 + eq(true, player.health:is_dead()) + + player.attributes.health = 10 + player.health.alive = false + eq(true, player.health:is_dead()) + end) +end) + +describe("corpse", function() + it("drop_corpse", function() + local player = Player.new() + + local one = defs.new_entity_from_char("$", 1, 1) + local two = defs.new_entity_from_char("!", 1, 1) + player.inventory:pickup(one) + player.inventory:pickup(two) + eq(2, #player.inventory.items) + + player.inventory:wear("coin", "right_hand") + eq(1, #player.inventory.items) + eq("coin", player.inventory.slots["r"].name) + eq("body= feet= head= left_hand= right_hand=coin ", player.inventory:get_wearing()) + eq(false, player.health:check_dead()) + + player.attributes.health = 0 + eq(true, player.health:check_dead()) + player:died(1, 1) + + local player_corpse = buffer.buffers[1].cells[1][1] + eq(constant_defs.enemy_corpse, player_corpse.movement.char) + eq(2, #player_corpse.inventory.items) + + player.attributes.health = 10 + player.health.alive = false + eq(true, player.health:check_dead()) + end) +end) + +describe("inventory", function() + local player = Player.new() + it("get_inventory", function() + eq("wearing: body= feet= head= left_hand= right_hand= \nitems:\n", player.inventory:get_inventory()) + end) + + it("get_weapon", function() + eq(nil, player.inventory:get_weapon()) + end) +end) + +describe("inspect", function() + local player = Player.new() + it("defaults", function() + eq(0, player.inventory:wear_effect(state.slot_types.head, attributes.vision, 4)) + eq(10, player.vision:view_distance()) + eq(0.5, player.combat:dodge_skill()) + eq(0.5, player.combat:weapon_skill()) + eq(0.5, player.vision:search_skill()) + eq(0.5, player.sneak:sneak_skill()) + eq(0.5, player.fuse:fuse_skill()) + eq(0.1, player.eat:eat_skill()) + eq(0.01, player.combat:deflect_skill()) + eq( + "name=player char=@ row=0 col=0 health=10 durability=20 damage=1 hit_rate=0.5 dodge_skill=0.5 weapon_skill=0.5 deflect_skill=0.01 sneak_skill=0.5 fuse_skill=0.5 eat_skill=0.1 view_distance=10 search_skill=0.5 vision=0.5 inscription= randomness=0.01 wearing: body= feet= head= left_hand= right_hand= \nitems:\n turns:0", + player:inspect() + ) + end) +end)