ui: basic map scroller

This commit is contained in:
Ilia Denisov
2026-03-06 23:29:06 +02:00
committed by GitHub
parent 29d188969b
commit 1de621c743
68 changed files with 9861 additions and 118 deletions
+265
View File
@@ -0,0 +1,265 @@
package world
import "errors"
var (
errInvalidCanvasSize = errors.New("incremental: invalid canvas size")
)
// IncrementalMode describes how the renderer should update the backing image.
type IncrementalMode int
const (
// IncrementalNoOp means no visual change is needed (dx=0 and dy=0).
IncrementalNoOp IncrementalMode = iota
// IncrementalShift means the backing image can be shifted and only dirty rects must be redrawn.
IncrementalShift
// IncrementalFullRedraw means the change is too large/unsafe for shifting and needs a full redraw.
IncrementalFullRedraw
)
// RectPx is an integer rectangle in canvas pixel coordinates.
// Semantics are half-open: [X, X+W) x [Y, Y+H).
type RectPx struct {
X, Y int
W, H int
}
// IncrementalPolicy is a placeholder for future incremental tuning.
// It is intentionally not used in C2; we only fix geometry-based thresholding now.
type IncrementalPolicy struct {
// CoalesceUpdates indicates "latest wins" behavior (drop intermediate updates).
// This will be implemented later; kept here as a placeholder to lock the API shape.
CoalesceUpdates bool
// AllowShiftOnly allows a temporary mode where the backing image is shifted
// but dirty rects are not redrawn immediately under overload.
AllowShiftOnly bool
// RenderBudgetMs can be used later to compare dtRender against a budget and decide degradation.
RenderBudgetMs int
// MaxCatchUpAreaPx limits how many pixels of deferred dirty regions we redraw per frame.
// 0 means "no limit".
MaxCatchUpAreaPx int
}
// IncrementalPlan is the output of pure incremental planning.
// It does not perform any drawing. It only describes what should happen.
type IncrementalPlan struct {
Mode IncrementalMode
// Shift to apply to the backing image in canvas pixels.
// Positive dx shifts the existing image to the right (exposing a dirty strip on the left).
// Positive dy shifts the existing image down (exposing a dirty strip on the top).
DxPx int
DyPx int
// Dirty rects to redraw after shifting (in canvas pixel coordinates).
// Rects may overlap; overlapping is allowed and simplifies planning.
Dirty []RectPx
}
// PlanIncrementalPan computes whether the renderer can update by shifting the backing image
// and redrawing only exposed strips, or must fall back to a full redraw.
//
// Threshold rule (per-axis):
// - If abs(dxPx) > marginXPx/2 => full redraw
// - If abs(dyPx) > marginYPx/2 => full redraw
//
// Additional safety rules:
// - If abs(dxPx) >= canvasW or abs(dyPx) >= canvasH => full redraw
//
// Returned dirty rects follow the chosen shift direction:
//
// dxPx > 0 => dirty strip on the left (width=dxPx)
// dxPx < 0 => dirty strip on the right (width=-dxPx)
// dyPx > 0 => dirty strip on the top (height=dyPx)
// dyPx < 0 => dirty strip on the bottom(height=-dyPx)
func PlanIncrementalPan(
canvasW, canvasH int,
marginXPx, marginYPx int,
dxPx, dyPx int,
) (IncrementalPlan, error) {
if canvasW <= 0 || canvasH <= 0 {
return IncrementalPlan{}, errInvalidCanvasSize
}
if marginXPx < 0 || marginYPx < 0 {
return IncrementalPlan{}, errors.New("incremental: invalid margins")
}
// No movement => no work.
if dxPx == 0 && dyPx == 0 {
return IncrementalPlan{Mode: IncrementalNoOp, DxPx: 0, DyPx: 0, Dirty: nil}, nil
}
adx := abs(dxPx)
ady := abs(dyPx)
// Too large shift cant be represented as "shift + stripes".
if adx >= canvasW || ady >= canvasH {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
// Thresholds: per axis, independently.
// Using integer division: margin/2 truncates down, which is fine and deterministic.
thrX := marginXPx / 2
thrY := marginYPx / 2
if (thrX > 0 && adx > thrX) || (thrY > 0 && ady > thrY) {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
// If margin is 0, thr is 0, and any non-zero delta should force full redraw
// (because we have no buffer area to shift into).
if marginXPx == 0 && dxPx != 0 {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
if marginYPx == 0 && dyPx != 0 {
return IncrementalPlan{Mode: IncrementalFullRedraw}, nil
}
dirty := make([]RectPx, 0, 2)
// Horizontal exposed strip with 1px overdraw to avoid seams.
if dxPx > 0 {
// Image moved right => left strip is exposed.
w := min(dxPx+1, canvasW) // overdraw 1px into already-valid area
dirty = append(dirty, RectPx{X: 0, Y: 0, W: w, H: canvasH})
} else if dxPx < 0 {
// Image moved left => right strip is exposed.
w := min((-dxPx)+1, canvasW)
dirty = append(dirty, RectPx{X: canvasW - w, Y: 0, W: w, H: canvasH})
}
// Vertical exposed strip with 1px overdraw to avoid seams.
if dyPx > 0 {
// Image moved down => top strip is exposed.
h := min(dyPx+1, canvasH)
dirty = append(dirty, RectPx{X: 0, Y: 0, W: canvasW, H: h})
} else if dyPx < 0 {
// Image moved up => bottom strip is exposed.
h := min((-dyPx)+1, canvasH)
dirty = append(dirty, RectPx{X: 0, Y: canvasH - h, W: canvasW, H: h})
}
// Filter out any zero/negative rects defensively.
out := dirty[:0]
for _, r := range dirty {
if r.W <= 0 || r.H <= 0 {
continue
}
out = append(out, r)
}
return IncrementalPlan{
Mode: IncrementalShift,
DxPx: dxPx,
DyPx: dyPx,
Dirty: out,
}, nil
}
func shiftAndClipRectPx(r RectPx, dx, dy, canvasW, canvasH int) (RectPx, bool) {
n := RectPx{X: r.X + dx, Y: r.Y + dy, W: r.W, H: r.H}
inter, ok := intersectRectPx(n, RectPx{X: 0, Y: 0, W: canvasW, H: canvasH})
return inter, ok
}
// planRestrictedToDirtyRects returns a new plan that contains only tile draw entries
// whose clip rectangles intersect any dirty rect. Each intersected area becomes its own
// TileDrawPlan entry with the clip replaced by the intersection.
//
// This makes drawing functions naturally render only the dirty areas.
func planRestrictedToDirtyRects(plan RenderPlan, dirty []RectPx) RenderPlan {
if len(dirty) == 0 {
return RenderPlan{
CanvasWidthPx: plan.CanvasWidthPx,
CanvasHeightPx: plan.CanvasHeightPx,
ZoomFp: plan.ZoomFp,
WorldRect: plan.WorldRect,
Tiles: nil,
}
}
outTiles := make([]TileDrawPlan, 0)
for _, td := range plan.Tiles {
if td.ClipW <= 0 || td.ClipH <= 0 {
continue
}
tileClip := RectPx{X: td.ClipX, Y: td.ClipY, W: td.ClipW, H: td.ClipH}
for _, dr := range dirty {
if isEmptyRectPx(dr) {
continue
}
inter, ok := intersectRectPx(tileClip, dr)
if !ok {
continue
}
outTiles = append(outTiles, TileDrawPlan{
Tile: td.Tile,
ClipX: inter.X,
ClipY: inter.Y,
ClipW: inter.W,
ClipH: inter.H,
Candidates: td.Candidates,
})
}
}
return RenderPlan{
CanvasWidthPx: plan.CanvasWidthPx,
CanvasHeightPx: plan.CanvasHeightPx,
ZoomFp: plan.ZoomFp,
WorldRect: plan.WorldRect,
Tiles: outTiles,
}
}
// takeCatchUpRects selects a subset of pending rects whose total area does not exceed maxAreaPx.
// It returns (selected, remaining). If maxAreaPx <= 0, it selects all.
func takeCatchUpRects(pending []RectPx, maxAreaPx int) (selected []RectPx, remaining []RectPx) {
if len(pending) == 0 {
return nil, nil
}
if maxAreaPx <= 0 {
// No limit.
all := append([]RectPx(nil), pending...)
return all, nil
}
selected = make([]RectPx, 0, len(pending))
remaining = make([]RectPx, 0)
used := 0
for _, r := range pending {
if r.W <= 0 || r.H <= 0 {
continue
}
area := r.W * r.H
if area <= 0 {
continue
}
// If we cannot fit the whole rect, we stop (simple, deterministic).
// (We do not split rectangles here to keep logic simple.)
if used+area > maxAreaPx {
remaining = append(remaining, r)
continue
}
selected = append(selected, r)
used += area
}
// Also keep any rects we skipped due to invalid size (none) and those that didn't fit.
// Note: remaining preserves original order among non-selected entries.
return selected, remaining
}