Files
inkwell/pncdsl/core.engine.go
T
2026-05-25 19:55:18 +02:00

370 lines
9.4 KiB
Go

package pncdsl
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// engine is the ebiten.Game adapter. It owns the per-frame Update/Draw flow
// and delegates everything stateful to *Game.
type engine struct {
g *Game
}
func (e *engine) Layout(outsideWidth, outsideHeight int) (int, int) {
return e.g.Width, e.g.Height
}
func (e *engine) Update() error {
g := e.g
dt := 1.0 / 60.0
g.input.poll()
g.UI.tick(dt)
g.transition.update(dt)
if g.transition.active && g.transition.out {
// during fade-out, freeze input
return nil
}
if g.UI.endCard != "" {
return nil
}
if g.scriptRunner != nil {
ctx := g.scriptCtx
ctx.DT = dt
// keep Scene reference fresh in case the script changed it
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
ctx.Scene = &s
}
s := g.scriptRunner.Tick(ctx)
if s != StatusRunning {
g.scriptRunner = nil
g.scriptCtx = nil
}
g.tickCharacters(dt)
return nil
}
// dialog click handling
if g.dialog != nil {
if g.input.LeftClicked() {
g.input.ConsumeLeft()
ctx := g.makeCtx()
picked := g.dialog.handleClick(ctx, g.input.Point())
if picked != nil {
// record once-tracking
if picked.Once {
g.State.NoteTalked(g.dialog.node.Name + ":" + picked.Text)
}
// run the choice's actions as a synthetic script
if len(picked.Actions) > 0 {
g.queueAction(Seq(picked.Actions...), "choice")
}
}
}
g.tickCharacters(dt)
return nil
}
// free interaction
e.handleFreeInput()
g.tickCharacters(dt)
return nil
}
func (e *engine) handleFreeInput() {
g := e.g
g.UI.rebuildVerbButtons()
mp := g.input.Point()
// hover label resolution
g.UI.hoverLabel = ""
if mp.Y < uiPanelY-4 {
if h := e.hotspotAt(mp); h != nil {
if h.Label != "" {
g.UI.hoverLabel = h.Label
} else {
g.UI.hoverLabel = h.Name
}
}
} else {
// hovering UI: show item description when over an inv slot
if hit := g.UI.hitTest(mp); len(hit) > 4 && hit[:4] == "inv:" {
name := hit[4:]
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
g.UI.hoverLabel = it.Description
} else {
g.UI.hoverLabel = name
}
}
}
if g.input.RightClicked() {
g.input.ConsumeRight()
// right click: deselect item / reset verb
if g.Inventory.Selected() != "" {
g.Inventory.Select("")
} else {
g.selectedVerb = "look"
}
}
if !g.input.LeftClicked() {
return
}
// UI hits first
if hit := g.UI.hitTest(mp); hit != "" {
g.input.ConsumeLeft()
switch hit[:4] {
case "verb":
g.selectedVerb = hit[5:]
case "inv:":
name := hit[4:]
if g.selectedVerb == "look" {
if it, ok := g.ItemManager.Get(name); ok && it.Description != "" {
g.queueAction(Say("player", it.Description), "inv-look")
return
}
}
if g.Inventory.Selected() == name {
g.Inventory.Select("")
} else {
g.Inventory.Select(name)
}
}
return
}
// game-world click
g.input.ConsumeLeft()
h := e.hotspotAt(mp)
if h == nil {
return
}
sel := g.Inventory.Selected()
if sel != "" {
// use-with: first check hotspot's OnUseWith, then item's
if h.OnUseWith != nil {
if a, ok := h.OnUseWith[sel]; ok && a != nil {
g.queueAction(a, "useWith hotspot")
g.Inventory.Select("")
return
}
}
if it, ok := g.ItemManager.Get(sel); ok && it.OnUseWith != nil {
if a, ok := it.OnUseWith[h.Name]; ok && a != nil {
g.queueAction(a, "useWith item")
g.Inventory.Select("")
return
}
}
g.UI.FlashLine("Nem ehhez.")
g.Inventory.Select("")
return
}
a := h.handler(g.selectedVerb)
if a == nil {
if v, ok := g.VerbManager.Get(g.selectedVerb); ok && v.Default != nil {
a = v.Default
}
}
if a == nil {
g.UI.FlashLine("Semmi említésre méltó.")
return
}
g.queueAction(a, "hotspot "+g.selectedVerb+" "+h.Name)
}
func (e *engine) hotspotAt(p Point) *Hotspot {
g := e.g
if g.currentScene == "" {
return nil
}
s := g.SceneManager.MustGet(g.currentScene)
for i := range s.Hotspots {
h := &s.Hotspots[i]
if h.Area != nil && h.Area.Contains(p) {
return h
}
}
return nil
}
func (e *engine) Draw(screen *ebiten.Image) {
g := e.g
// scene background
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
img := g.loaded.image(g.AssetManager, s.Background)
op := &ebiten.DrawImageOptions{}
bw, bh := img.Bounds().Dx(), img.Bounds().Dy()
if bw > 0 && bh > 0 {
op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh))
}
screen.DrawImage(img, op)
// hotspot debug outlines
if DebugLog {
for _, h := range s.Hotspots {
if r, ok := h.Area.(Rectangle); ok {
vector.StrokeRect(screen, float32(r.X), float32(r.Y), float32(r.W), float32(r.H), 1, color.RGBA{255, 255, 0, 200}, false)
}
}
}
} else {
screen.Fill(color.RGBA{0, 0, 0, 255})
}
// characters (y-sorted)
for _, c := range sortedChars(g) {
drawCharacter(screen, g, c)
}
// speech bubble
g.UI.speech.draw(screen, g)
// dialog box (if active)
if g.dialog != nil {
g.dialog.draw(screen, g)
} else {
g.UI.drawHUD(screen)
}
// transition overlay
g.transition.draw(screen, g.Width, g.Height)
// end card
if g.UI.endCard != "" {
vector.DrawFilledRect(screen, 0, 0, float32(g.Width), float32(g.Height), color.RGBA{0, 0, 0, 230}, false)
w := textWidth(g.UI.endCard)
drawText(screen, g.UI.endCard, (g.Width-w)/2, g.Height/2-8, color.White)
}
// cursor on top
drawCursor(screen, g)
}
func sortedChars(g *Game) []*runtimeChar {
out := make([]*runtimeChar, 0, len(g.chars))
for _, c := range g.chars {
out = append(out, c)
}
// insertion sort by Y (small N)
for i := 1; i < len(out); i++ {
for j := i; j > 0 && out[j].pos.Y < out[j-1].pos.Y; j-- {
out[j], out[j-1] = out[j-1], out[j]
}
}
return out
}
func drawCharacter(dst *ebiten.Image, g *Game, c *runtimeChar) {
w := c.def.W
h := c.def.H
if w == 0 {
w = 24
}
if h == 0 {
h = 56
}
// Try the real sprite first. Only fall through to the stylized
// placeholder when no actual art is on disk.
if c.def.Sprite != "" && !g.loaded.isPlaceholder(c.def.Sprite) {
img := g.loaded.image(g.AssetManager, c.def.Sprite)
sw, sh := img.Bounds().Dx(), img.Bounds().Dy()
if sw > 0 && sh > 0 {
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(w/float64(sw), h/float64(sh))
op.GeoM.Translate(c.pos.X-w/2, c.pos.Y-h)
dst.DrawImage(img, op)
return
}
}
// Touch the asset cache so isPlaceholder is populated for later frames.
if c.def.Sprite != "" {
_ = g.loaded.image(g.AssetManager, c.def.Sprite)
}
x := c.pos.X - w/2
y := c.pos.Y - h
bodyCol := color.RGBA{200, 200, 200, 255}
if rgba, ok := c.def.SpeechColor.(color.RGBA); ok {
bodyCol = rgba
}
if w > h {
drawQuadrupedPlaceholder(dst, x, y, w, h, bodyCol)
} else {
drawHumanoidPlaceholder(dst, x, y, w, h, bodyCol)
}
}
func drawHumanoidPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.RGBA) {
pants := color.RGBA{40, 50, 90, 255}
skin := color.RGBA{245, 200, 155, 255}
outline := color.RGBA{20, 20, 30, 255}
headR := h * 0.14
headCY := y + headR + 1
// pants / legs — two narrow strips
pantsTop := y + h*0.62
pantsH := h - (pantsTop - y) - 1
legW := w * 0.36
vector.DrawFilledRect(dst, float32(x+w*0.08), float32(pantsTop), float32(legW), float32(pantsH), pants, false)
vector.DrawFilledRect(dst, float32(x+w*0.56), float32(pantsTop), float32(legW), float32(pantsH), pants, false)
// torso — full-width rect, shirt color
torsoTop := headCY + headR
torsoH := pantsTop - torsoTop
vector.DrawFilledRect(dst, float32(x), float32(torsoTop), float32(w), float32(torsoH), body, false)
vector.StrokeRect(dst, float32(x), float32(torsoTop), float32(w), float32(torsoH), 1, outline, false)
// head — circle in skin tone
vector.DrawFilledCircle(dst, float32(x+w/2), float32(headCY), float32(headR), skin, true)
// feet — short dark blobs at the very bottom
footH := 2.0
vector.DrawFilledRect(dst, float32(x+w*0.05), float32(y+h-footH), float32(legW+2), float32(footH), outline, false)
vector.DrawFilledRect(dst, float32(x+w*0.55), float32(y+h-footH), float32(legW+2), float32(footH), outline, false)
}
func drawQuadrupedPlaceholder(dst *ebiten.Image, x, y, w, h float64, body color.RGBA) {
outline := color.RGBA{20, 20, 30, 255}
dark := color.RGBA{
R: body.R / 2, G: body.G / 2, B: body.B / 2, A: 255,
}
// body
bodyY := y + h*0.30
bodyH := h * 0.65
vector.DrawFilledRect(dst, float32(x), float32(bodyY), float32(w*0.78), float32(bodyH), body, false)
// head — circle on the right
headR := h * 0.30
headCX := x + w - headR
headCY := y + h*0.50
vector.DrawFilledCircle(dst, float32(headCX), float32(headCY), float32(headR), body, true)
// ears — two triangles approximated as small rects
earW := w * 0.07
earH := h * 0.30
vector.DrawFilledRect(dst, float32(headCX-headR*0.7), float32(y), float32(earW), float32(earH), body, false)
vector.DrawFilledRect(dst, float32(headCX+headR*0.4), float32(y), float32(earW), float32(earH), body, false)
// eye — dark dot
vector.DrawFilledCircle(dst, float32(headCX+headR*0.25), float32(headCY-1), 1.2, outline, true)
// tail — small dark line stub on the left
vector.DrawFilledRect(dst, float32(x-w*0.04), float32(bodyY+1), float32(w*0.08), 2, dark, false)
// legs — two short bars at the bottom
legW := w * 0.07
vector.DrawFilledRect(dst, float32(x+w*0.10), float32(y+h-3), float32(legW), 3, dark, false)
vector.DrawFilledRect(dst, float32(x+w*0.55), float32(y+h-3), float32(legW), 3, dark, false)
}