Files
pncdsl/core.engine.go
2026-05-25 22:27:53 +02:00

323 lines
8.9 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package pncdsl
import (
"image/color"
"github.com/hajimehoshi/ebiten/v2"
"github.com/hajimehoshi/ebiten/v2/vector"
)
// engine is the ebiten.Game adapter. Each frame it polls input, advances
// the active script (if any), then lets every registered Widget tick
// (reverse registration order, so the top widget claims input first).
// Whatever survives is offered to the scene as a hotspot click.
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.transition.update(dt)
if g.transition.active && g.transition.out {
return nil
}
// Active script consumes the frame.
if g.scriptRunner != nil {
ctx := g.scriptCtx
ctx.DT = dt
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
ctx.Scene = &s
}
st := g.scriptRunner.Tick(ctx)
if st != StatusRunning {
g.scriptRunner = nil
g.scriptCtx = nil
}
g.tickCharacters(dt)
return nil
}
uictx := &UICtx{Game: g, DT: dt}
// Clear per-frame transient state that widgets / scene-resolution write.
g.SetHoverLabel("")
// Scene triggers run before widgets/input — a firing trigger queues
// an action that consumes the next frame's script slot, so this
// frame's UI work still happens, but the player's clicks won't
// race a cutscene that's about to start.
g.tickTriggers()
// Top-down input: the widget drawn last (= registered last) gets the
// click first, then the next-to-last, etc. A widget signals "I took it"
// via g.Input.ConsumeLeft / ConsumeRight.
for _, w := range reversedWidgets(g.UIManager) {
w.Tick(uictx)
}
// Anything still unconsumed is a scene-world interaction.
e.handleSceneInput()
g.tickCharacters(dt)
return nil
}
// handleSceneInput is the legacy "click on hotspot with active verb" flow,
// running only when no widget has claimed the input.
func (e *engine) handleSceneInput() {
g := e.g
mp := g.Input.Point()
// hover label resolution: pointer over a hotspot in the current scene
if h := e.hotspotAt(mp); h != nil {
if h.Label != "" {
g.SetHoverLabel(h.Label)
} else {
g.SetHoverLabel(h.Name)
}
}
if g.Input.RightClicked() {
g.Input.ConsumeRight()
if g.Inventory.Selected() != "" {
g.Inventory.Select("")
} else {
g.SetSelectedVerb("look")
}
}
if !g.Input.LeftClicked() {
return
}
g.Input.ConsumeLeft()
h := e.hotspotAt(mp)
if h == nil {
return
}
sel := g.Inventory.Selected()
targetLabel := h.Label
if targetLabel == "" {
targetLabel = h.Name
}
if sel != "" {
if h.OnUseWith != nil {
if a, ok := h.OnUseWith[sel]; ok && a != nil {
g.LogAction("> " + verbLabel(g, "use") + " " + sel + " ezen: " + targetLabel)
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.LogAction("> " + verbLabel(g, "use") + " " + sel + " ezen: " + targetLabel)
g.queueAction(a, "useWith item")
g.Inventory.Select("")
return
}
}
g.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.FlashLine("Semmi említésre méltó.")
return
}
g.LogAction("> " + verbLabel(g, g.SelectedVerb()) + " " + targetLabel)
g.queueAction(a, "hotspot "+g.SelectedVerb()+" "+h.Name)
}
func verbLabel(g *Game, name string) string {
if v, ok := g.VerbManager.Get(name); ok {
return v.Label
}
return 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
th := g.Theme()
// optional backdrop (helps when the background image doesn't fill the screen)
if th.SceneBackdrop != nil {
screen.Fill(th.SceneBackdrop)
}
// scene background
if g.currentScene != "" {
s := g.SceneManager.MustGet(g.currentScene)
img := g.loaded.image(g.AssetManager, s.Background)
bw, bh := img.Bounds().Dx(), img.Bounds().Dy()
if bw > 0 && bh > 0 {
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(float64(g.Width)/float64(bw), float64(g.Height)/float64(bh))
screen.DrawImage(img, op)
}
} else {
screen.Fill(color.RGBA{0, 0, 0, 255})
}
// characters (y-sorted)
for _, c := range sortedChars(g) {
drawCharacter(screen, g, c)
}
// widgets in registration order
uictx := &UICtx{Game: g, DT: 1.0 / 60.0}
for _, w := range orderedWidgets(g.UIManager) {
w.Draw(screen, uictx)
}
// transition overlay on top of everything except cursor (cursor is the
// last widget, so it has already drawn above the transition? no — the
// fade should cover the cursor too; draw it after the widgets).
g.transition.draw(screen, g.Width, g.Height)
}
func sortedChars(g *Game) []*runtimeChar {
out := make([]*runtimeChar, 0, len(g.chars))
for _, c := range g.chars {
out = append(out, c)
}
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
}
if c.def.Sprite != "" && !g.loaded.isPlaceholder(c.def.Sprite) {
img := g.loaded.image(g.AssetManager, c.def.Sprite)
// Active animation clip → blit only the current frame's sub-rect
// from the sprite sheet, scaled into the character's W×H box.
if r, ok := c.currentFrameRect(); ok {
ib := img.Bounds()
clamped := r.Intersect(ib)
fw, fh := clamped.Dx(), clamped.Dy()
if fw > 0 && fh > 0 {
sub := img.SubImage(clamped).(*ebiten.Image)
op := &ebiten.DrawImageOptions{}
op.GeoM.Scale(w/float64(fw), h/float64(fh))
op.GeoM.Translate(c.pos.X-w/2, c.pos.Y-h)
dst.DrawImage(sub, op)
return
}
}
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
}
}
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
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)
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)
vector.DrawFilledCircle(dst, float32(x+w/2), float32(headCY), float32(headR), skin, true)
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}
bodyY := y + h*0.30
bodyH := h * 0.65
vector.DrawFilledRect(dst, float32(x), float32(bodyY), float32(w*0.78), float32(bodyH), body, false)
headR := h * 0.30
headCX := x + w - headR
headCY := y + h*0.50
vector.DrawFilledCircle(dst, float32(headCX), float32(headCY), float32(headR), body, true)
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)
vector.DrawFilledCircle(dst, float32(headCX+headR*0.25), float32(headCY-1), 1.2, outline, true)
vector.DrawFilledRect(dst, float32(x-w*0.04), float32(bodyY+1), float32(w*0.08), 2, dark, false)
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)
}