feat: add Commands.parse and isVisibleHostile
This commit is contained in:
1 parent
2cc87fc08c
commit
63b15fb146
1 file changed
+161
@@ -0,0 +1,161 @@
|
||||
-- Text command parser and dispatcher for a human player, layered entirely on
|
||||
-- top of the granular Game:<action> methods choosePlayerAction (ai.lua's own
|
||||
-- autopilot) already drives - nothing here duplicates combat, targeting or
|
||||
-- turn bookkeeping, it only translates a typed word into the same call the
|
||||
-- autopilot would have made. Every other player-facing system in this engine
|
||||
-- is a mechanic; this is the one module whose whole job is the interface
|
||||
-- between a line of text and those mechanics, which is why it takes `game`
|
||||
-- as a plain argument rather than requiring game.lua itself (that require
|
||||
-- runs the other way, from game.lua's Game:manualStep).
|
||||
--
|
||||
-- Commands.apply(game, text) always returns (consumesTurn, message): a
|
||||
-- movement/action word that actually did something returns true and leaves
|
||||
-- Game:manualStep to spend the turn, exactly like choosePlayerAction would
|
||||
-- have; an unrecognized word, a bad argument, or an action with nothing to
|
||||
-- act on returns false and spends nothing, so a typo never costs a turn the
|
||||
-- way a wasted keystroke would in any other roguelike. Purely informational
|
||||
-- commands (inventory, look, status, help) always return false for the same
|
||||
-- reason - reading your own inventory is free.
|
||||
|
||||
local Spells = require("spells")
|
||||
local Identify = require("identify")
|
||||
local Factions = require("factions")
|
||||
local Examine = require("examine")
|
||||
local Skills = require("skills")
|
||||
|
||||
local Commands = {}
|
||||
|
||||
local DIRS = {
|
||||
north = { 0, -1 }, n = { 0, -1 },
|
||||
south = { 0, 1 }, s = { 0, 1 },
|
||||
east = { 1, 0 }, e = { 1, 0 },
|
||||
west = { -1, 0 }, w = { -1, 0 },
|
||||
northeast = { 1, -1 }, ne = { 1, -1 },
|
||||
northwest = { -1, -1 }, nw = { -1, -1 },
|
||||
southeast = { 1, 1 }, se = { 1, 1 },
|
||||
southwest = { -1, 1 }, sw = { -1, 1 },
|
||||
}
|
||||
|
||||
-- Splits `text` on whitespace into a lowercase verb plus its remaining
|
||||
-- tokens as args. Returns nil for anything with no verb at all (an empty or
|
||||
-- all-whitespace line), which Commands.apply treats the same as an unknown
|
||||
-- command - no turn spent either way.
|
||||
function Commands.parse(text)
|
||||
if type(text) ~= "string" then return nil end
|
||||
local tokens = {}
|
||||
for token in text:gmatch("%S+") do
|
||||
tokens[#tokens + 1] = token:lower()
|
||||
end
|
||||
if #tokens == 0 then return nil end
|
||||
local args = {}
|
||||
for i = 2, #tokens do args[#args + 1] = tokens[i] end
|
||||
return { verb = tokens[1], args = args }
|
||||
end
|
||||
|
||||
-- A monster the player can actually see right now: alive, kind "monster"
|
||||
-- (excludes the player's own companions/hirelings/thralls, which never
|
||||
-- carry that kind - see game.lua's isPlayerAlly), and standing on a tile
|
||||
-- game.visible already marks lit for the player this turn.
|
||||
local function isVisibleHostile(game, entity)
|
||||
return entity.kind == "monster" and entity:isAlive()
|
||||
and game.visible[game.level.map:index(entity.x, entity.y)] == true
|
||||
end
|
||||
|
||||
-- Up to `count` nearest visible hostiles, Chebyshev distance, ties broken by
|
||||
-- ascending id - the same tie rule Game:nearestAlly and companions.lua's own
|
||||
-- nearestHostile already use, so auto-targeting a ranged command picks
|
||||
-- exactly the monster the autopilot would have picked in the same spot.
|
||||
local function nearestHostiles(game, count)
|
||||
local player = game.player
|
||||
local results, excluded = {}, {}
|
||||
for _ = 1, count do
|
||||
local best, bestDist
|
||||
for _, entity in ipairs(game.level.entities) do
|
||||
if not excluded[entity] and isVisibleHostile(game, entity) then
|
||||
local dist = math.max(math.abs(entity.x - player.x), math.abs(entity.y - player.y))
|
||||
if not best or dist < bestDist or (dist == bestDist and entity.id < best.id) then
|
||||
best, bestDist = entity, dist
|
||||
end
|
||||
end
|
||||
end
|
||||
if not best then break end
|
||||
results[#results + 1] = best
|
||||
excluded[best] = true
|
||||
end
|
||||
return results
|
||||
end
|
||||
|
||||
local function nearestHostile(game)
|
||||
return nearestHostiles(game, 1)[1]
|
||||
end
|
||||
|
||||
-- Every visible hostile in game.level.entities's own order, unlike
|
||||
-- nearestHostiles above which sorts by distance - this is the order
|
||||
-- Handlers.look numbers its list in, so "examine monster <n>" always means
|
||||
-- the same monster the player just saw look print at that number.
|
||||
local function visibleHostilesInOrder(game)
|
||||
local list = {}
|
||||
for _, entity in ipairs(game.level.entities) do
|
||||
if isVisibleHostile(game, entity) then list[#list + 1] = entity end
|
||||
end
|
||||
return list
|
||||
end
|
||||
|
||||
-- Resolves an optional trailing target number - the exact 1-based index
|
||||
-- Handlers.look/examine already print next to each visible hostile - to an
|
||||
-- entity, falling back to the nearest visible hostile (the old, only
|
||||
-- behaviour) when the caller omitted it. Every targeted handler below stays
|
||||
-- correct for a player who never bothers naming a target, while letting a
|
||||
-- player who cares (a dangerous archer standing behind an expendable rat)
|
||||
-- reach past the default without any change to game.lua's own range/line-of-
|
||||
-- sight checks, which still reject an out-of-reach explicit target exactly
|
||||
-- like they already reject a too-far auto-picked one.
|
||||
-- Returns (target, explicit): explicit is true only when the caller actually
|
||||
-- passed a token for the slot, so callers can tell "you named a target that
|
||||
-- is not there" apart from "there is no target in sight at all" and keep
|
||||
-- their own existing wording for the latter.
|
||||
local function resolveTarget(game, indexText)
|
||||
if indexText == nil then return nearestHostile(game), false end
|
||||
local index = tonumber(indexText)
|
||||
return index and visibleHostilesInOrder(game)[index], true
|
||||
end
|
||||
|
||||
-- Commands.parse lowercases every token before a handler ever sees it, which
|
||||
-- is harmless for directions/spells/factions/slots (already all-lowercase
|
||||
-- keys) but not for Skills.active/Skills.passive, whose keys are camelCase
|
||||
-- (secondWind, poisonBlade, manaShield) - a player typing the skill's own
|
||||
-- name would otherwise never match it. Maps a lowercased word back to its
|
||||
-- real key by case-insensitive scan; returns the word unchanged when nothing
|
||||
-- matches, so an actually unknown skill still reaches Skills.use/Skills.learn
|
||||
-- and gets their own "no such skill" wording instead of being swallowed here.
|
||||
local function resolveSkillKey(word)
|
||||
if not word then return word end
|
||||
for key in pairs(Skills.active) do
|
||||
if key:lower() == word then return key end
|
||||
end
|
||||
for key in pairs(Skills.passive) do
|
||||
if key:lower() == word then return key end
|
||||
end
|
||||
return word
|
||||
end
|
||||
|
||||
local Handlers = {}
|
||||
|
||||
local function moveHandler(dx, dy)
|
||||
return function(game)
|
||||
if game:movePlayer(dx, dy) then return true, nil end
|
||||
return false, "you cannot go that way"
|
||||
end
|
||||
end
|
||||
|
||||
for name, dir in pairs(DIRS) do
|
||||
Handlers[name] = moveHandler(dir[1], dir[2])
|
||||
end
|
||||
|
||||
Handlers.attack = function(game, args)
|
||||
local dir = DIRS[args[1]]
|
||||
if not dir then return false, "attack which direction?" end
|
||||
if game:movePlayer(dir[1], dir[2]) then return true, nil end
|
||||
return false, "there is nothing to attack there"
|
||||
end
|
||||
|
||||
Reference in new issue
Block a user