feat: lib-core.crafting-display v0.1.0 — Recipe Panel-Widget
UI layer for recipe-based crafting. Reads recipes from
lib-core.crafting, renders the known-subset as a vertical row-list
inside a panel managed by lib-core.panel, and dispatches right-click
context-menu actions registered by the consuming module.
Surface:
create(container_entity, opts) -> widget_def
register_action(widget, label, callback)
unregister_action(widget, label)
set_icon_resolver(widget, fn)
set_label_resolver(widget, fn)
set_summary_resolver(widget, fn)
Row-build is rebuilt per render frame from crafting.list_recipes():
- Visibility gate: recipe.is_known(ctx); errors hide row + emit [WARN]
- Availability gate: crafting.can_craft(id, container, ctx).ok
drives row colour (full vs dim text_color).
ctx comes from opts.ctx_factory() — default returns {}; override to
feed actor/skill/faction state into both predicates. label_resolver
defaults to recipe.name or recipe.id; summary_resolver joins input
template-ids with " + " and suffixes "xN" for count>1.
Right-click hit-tests row-rects captured during render, then opens
the menu via panel.show_context_menu(x, y, actions). Action
callbacks receive (recipe_id, {container, close_menu, refresh}).
Registration order is preserved (array-based, not pairs-based) so
context-menu rendering matches registration sequence.
Depends on lib-core.crafting 0.1.0, lib-core.panel 0.1.1,
lib-core.inventory-list 0.1.0, lib-core.composition 0.3.0. The last
two are not called directly but are required as direct deps so
modules consuming this widget satisfy crafting's transitive needs
(engine resolver is per-module non-transitive).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
276
init.lua
Normal file
276
init.lua
Normal file
@@ -0,0 +1,276 @@
|
||||
-- =====================================================================
|
||||
-- lib-core.crafting-display v0.1.0 — Recipe Panel-Widget
|
||||
--
|
||||
-- Sits on top of lib-core.panel and reads lib-core.crafting +
|
||||
-- lib-core.inventory-list + lib-core.composition. Provides a ready-to-
|
||||
-- register panel widget that lists recipes known to the actor and shows
|
||||
-- per-row availability based on container contents.
|
||||
--
|
||||
-- Right-clicking a row opens a context-menu populated from actions
|
||||
-- registered via M.register_action.
|
||||
--
|
||||
-- Public API:
|
||||
-- display.create(container_entity, opts) -> widget_def
|
||||
-- display.register_action(widget_def, label, callback)
|
||||
-- display.unregister_action(widget_def, label)
|
||||
-- display.set_icon_resolver(widget_def, fn)
|
||||
-- display.set_label_resolver(widget_def, fn)
|
||||
-- display.set_summary_resolver(widget_def, fn)
|
||||
--
|
||||
-- widget_def conforms to the panel widget contract:
|
||||
-- widget_def.render(theme, x, y, w, h)
|
||||
-- widget_def.handle_input(input_state, theme, x, y, w, h)
|
||||
-- widget_def.title (string)
|
||||
--
|
||||
-- DEFERRED (v0.1 non-goals):
|
||||
-- - Row scrolling
|
||||
-- - Custom row layouts (icon column, summary column, etc.)
|
||||
-- - Tooltip / hover-detail
|
||||
-- - Stack-count display
|
||||
-- =====================================================================
|
||||
|
||||
local crafting = require("lib-core.crafting")
|
||||
local panel = require("lib-core.panel")
|
||||
local inventory = require("lib-core.inventory-list")
|
||||
local composition = require("lib-core.composition")
|
||||
|
||||
-- inventory + composition are required for transitive completeness (engine
|
||||
-- resolver is per-module non-transitive). The lib does not call into them
|
||||
-- directly; crafting handles container access internally.
|
||||
local _ = inventory
|
||||
local _ = composition
|
||||
|
||||
local M = {}
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Default resolvers
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local function default_label(recipe)
|
||||
return recipe.name or recipe.id
|
||||
end
|
||||
|
||||
local function default_summary(recipe)
|
||||
local parts = {}
|
||||
for _, inp in ipairs(recipe.inputs) do
|
||||
if inp.count == 1 then
|
||||
parts[#parts + 1] = inp.template
|
||||
else
|
||||
parts[#parts + 1] = inp.template .. "\xc3\x97" .. inp.count -- UTF-8 "×"
|
||||
end
|
||||
end
|
||||
return table.concat(parts, " + ")
|
||||
end
|
||||
|
||||
local function default_icon(_recipe)
|
||||
return nil -- v0.1: module must override icon_resolver for sprites
|
||||
end
|
||||
|
||||
local function default_ctx_factory()
|
||||
return {}
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Internal: build row-list per frame
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
local function build_rows(widget)
|
||||
local ctx = widget._ctx_factory()
|
||||
if type(ctx) ~= "table" then ctx = {} end
|
||||
local out = {}
|
||||
for _, recipe in ipairs(crafting.list_recipes()) do
|
||||
local known_ok, known = pcall(recipe.is_known, ctx)
|
||||
if not known_ok then
|
||||
if engine and engine.print then
|
||||
engine.print(string.format(
|
||||
"[WARN] crafting-display: is_known('%s') errored: %s",
|
||||
recipe.id, tostring(known)))
|
||||
end
|
||||
elseif known == true then
|
||||
local match = crafting.can_craft(recipe.id, widget._container, ctx)
|
||||
out[#out + 1] = {
|
||||
recipe = recipe,
|
||||
available = match.ok == true,
|
||||
}
|
||||
end
|
||||
end
|
||||
return out
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Public API: create
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
--- M.create(container_entity, opts) -> widget_def
|
||||
--- Creates a crafting-recipe widget bound to `container_entity` (used as
|
||||
--- the input source for can_craft availability checks).
|
||||
--- opts = {
|
||||
--- title = string, default "Crafting"
|
||||
--- widget_id = string, default "crafting"
|
||||
--- pause_on_open = bool, default false
|
||||
--- ctx_factory = function() -> table, default returns {}
|
||||
--- icon_resolver = function(recipe) -> any|nil
|
||||
--- label_resolver = function(recipe) -> string
|
||||
--- summary_resolver = function(recipe) -> string
|
||||
--- }
|
||||
--- Loud-error if container_entity is nil.
|
||||
function M.create(container_entity, opts)
|
||||
if container_entity == nil then
|
||||
error("crafting-display.create: container must not be nil", 2)
|
||||
end
|
||||
opts = opts or {}
|
||||
local widget = {
|
||||
_container = container_entity,
|
||||
_opts = opts,
|
||||
_actions = {},
|
||||
_ctx_factory = opts.ctx_factory or default_ctx_factory,
|
||||
_icon_resolver = opts.icon_resolver or default_icon,
|
||||
_label_resolver = opts.label_resolver or default_label,
|
||||
_summary_resolver = opts.summary_resolver or default_summary,
|
||||
title = opts.title or "Crafting",
|
||||
widget_id = opts.widget_id or "crafting",
|
||||
pause_on_open = opts.pause_on_open == true,
|
||||
}
|
||||
function widget.render(theme, x, y, w, h)
|
||||
M._render_widget(widget, theme, x, y, w, h)
|
||||
end
|
||||
function widget.handle_input(input_state, theme, x, y, w, h)
|
||||
return M._handle_input_widget(widget, input_state, theme, x, y, w, h)
|
||||
end
|
||||
return widget
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Public API: actions
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
--- M.register_action(widget, label, callback)
|
||||
--- Registers a context-menu action shown on right-click of any row.
|
||||
--- callback(recipe_id, context) where context = { container, close_menu, refresh }.
|
||||
--- Loud-error on duplicate label or non-function callback.
|
||||
function M.register_action(widget, label, callback)
|
||||
if type(label) ~= "string" or label == "" then
|
||||
error("crafting-display.register_action: label must be non-empty string", 2)
|
||||
end
|
||||
if type(callback) ~= "function" then
|
||||
error("crafting-display.register_action: callback must be function", 2)
|
||||
end
|
||||
for _, a in ipairs(widget._actions) do
|
||||
if a.label == label then
|
||||
error(string.format(
|
||||
"crafting-display.register_action: duplicate label '%s'", label), 2)
|
||||
end
|
||||
end
|
||||
widget._actions[#widget._actions + 1] = { label = label, callback = callback }
|
||||
end
|
||||
|
||||
--- M.unregister_action(widget, label)
|
||||
--- Removes a previously-registered context-menu action.
|
||||
--- Idempotent: no error if `label` was never registered.
|
||||
function M.unregister_action(widget, label)
|
||||
for i, a in ipairs(widget._actions) do
|
||||
if a.label == label then
|
||||
table.remove(widget._actions, i)
|
||||
return
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Public API: resolver overrides
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
function M.set_icon_resolver(widget, fn) widget._icon_resolver = fn end
|
||||
function M.set_label_resolver(widget, fn) widget._label_resolver = fn end
|
||||
function M.set_summary_resolver(widget, fn) widget._summary_resolver = fn end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Render
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
function M._render_widget(widget, theme, x, y, w, h)
|
||||
local rows = build_rows(widget)
|
||||
local row_h = (theme and theme.row_height) or 28
|
||||
local pad = (theme and theme.padding) or 8
|
||||
local txt_col_full = (theme and theme.text_color) or {1.0, 1.0, 1.0, 1.0}
|
||||
local txt_col_dim = (theme and theme.text_color_dim) or {0.6, 0.6, 0.6, 1.0}
|
||||
|
||||
local cy = y + pad
|
||||
widget._render_rows = {}
|
||||
for i, row in ipairs(rows) do
|
||||
local color = row.available and txt_col_full or txt_col_dim
|
||||
local label = widget._label_resolver(row.recipe)
|
||||
local summary = widget._summary_resolver(row.recipe)
|
||||
if engine and engine.render and engine.render.draw_text then
|
||||
engine.render.draw_text(label, x + pad, cy, color)
|
||||
engine.render.draw_text(summary, x + w - pad - 100, cy, color)
|
||||
end
|
||||
widget._render_rows[i] = {
|
||||
recipe_id = row.recipe.id,
|
||||
x = x, y = cy, w = w, h = row_h,
|
||||
}
|
||||
cy = cy + row_h
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Input
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
function M._handle_input_widget(widget, input_state, _theme, _x, _y, _w, _h)
|
||||
if not widget._render_rows then return false end
|
||||
local mx, my = input_state.mouse_x, input_state.mouse_y
|
||||
if not (mx and my) then return false end
|
||||
for _, r in ipairs(widget._render_rows) do
|
||||
if mx >= r.x and mx <= r.x + r.w and my >= r.y and my <= r.y + r.h then
|
||||
if input_state.right_clicked then
|
||||
M._invoke_context_menu(widget, r.recipe_id, mx, my)
|
||||
return true
|
||||
end
|
||||
end
|
||||
end
|
||||
return false
|
||||
end
|
||||
|
||||
function M._invoke_context_menu(widget, recipe_id, mx, my)
|
||||
local entries = {}
|
||||
for _, a in ipairs(widget._actions) do
|
||||
local cb = a.callback -- capture for closure
|
||||
entries[#entries + 1] = {
|
||||
label = a.label,
|
||||
callback = function(menu_ctx)
|
||||
cb(recipe_id, {
|
||||
container = widget._container,
|
||||
close_menu = menu_ctx.close_menu,
|
||||
refresh = function() end, -- v0.1: free (next frame re-reads)
|
||||
})
|
||||
end,
|
||||
}
|
||||
end
|
||||
if #entries == 0 then return end
|
||||
if panel.show_context_menu then
|
||||
panel.show_context_menu(mx or 0, my or 0, entries)
|
||||
end
|
||||
end
|
||||
|
||||
-- ---------------------------------------------------------------------
|
||||
-- Test-backdoors
|
||||
-- ---------------------------------------------------------------------
|
||||
|
||||
function M._test_get_rows(widget)
|
||||
return build_rows(widget)
|
||||
end
|
||||
|
||||
function M._test_resolve_icon(widget, recipe)
|
||||
return widget._icon_resolver(recipe)
|
||||
end
|
||||
|
||||
function M._test_resolve_label(widget, recipe)
|
||||
return widget._label_resolver(recipe)
|
||||
end
|
||||
|
||||
function M._test_resolve_summary(widget, recipe)
|
||||
return widget._summary_resolver(recipe)
|
||||
end
|
||||
|
||||
return M
|
||||
Reference in New Issue
Block a user