ui: basic map scroller
This commit is contained in:
@@ -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 can’t 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
|
||||
}
|
||||
Reference in New Issue
Block a user