323 lines
8.9 KiB
Go
323 lines
8.9 KiB
Go
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)
|
||
}
|