Files
galaxy-game/client/world/drawer.go
T
2026-03-07 00:29:06 +03:00

327 lines
8.6 KiB
Go

package world
import (
"image"
"image/color"
"github.com/fogleman/gg"
)
// PrimitiveDrawer is a low-level drawing backend used by the world renderer.
//
// The renderer is responsible for all torus logic, viewport/margin logic,
// coordinate projection, and primitive duplication. This interface only accepts
// final canvas pixel coordinates and exposes the minimum drawing operations
// needed to build and render paths.
//
// AddPoint, AddLine, and AddCircle append geometry to the current path.
// They do not render by themselves. The caller must finalize the path by
// calling Stroke or Fill.
//
// Save and Restore are intended for temporary local state changes such as
// clipping, colors, line width, or dash settings. After Restore, the outer
// drawing state must be visible again.
type PrimitiveDrawer interface {
// Save stores the current drawing state.
Save()
// Restore restores the most recently saved drawing state.
Restore()
// ResetClip clears the current clipping region completely.
ResetClip()
// ClipRect intersects the current clipping region with the given rectangle
// in canvas pixel coordinates.
ClipRect(x, y, w, h float64)
// SetStrokeColor sets the color used by Stroke.
SetStrokeColor(c color.Color)
// SetFillColor sets the color used by Fill.
SetFillColor(c color.Color)
// SetLineWidth sets the line width used by Stroke.
SetLineWidth(width float64)
// SetDash sets the dash pattern used by Stroke.
// Passing no values clears the current dash pattern.
SetDash(dashes ...float64)
// SetDashOffset sets the dash phase used by Stroke.
SetDashOffset(offset float64)
// AddPoint appends a point marker centered at (x, y) with radius r
// to the current path in canvas pixel coordinates.
AddPoint(x, y, r float64)
// AddLine appends a line segment to the current path in canvas pixel coordinates.
AddLine(x1, y1, x2, y2 float64)
// AddCircle appends a circle to the current path in canvas pixel coordinates.
AddCircle(cx, cy, r float64)
// Stroke renders the current path using the current stroke state.
Stroke()
// Fill renders the current path using the current fill state.
Fill()
// CopyShift shifts the current backing image by (dx, dy) in canvas pixels.
// dx > 0 shifts the image to the right, dy > 0 shifts the image down.
// Newly exposed areas are cleared to transparent.
CopyShift(dx, dy int)
// ClearAll clears the entire backing canvas to transparent.
// Must NOT affect the current clip state.
ClearAll()
// ClearRect clears a pixel rectangle to transparent.
// Must NOT affect the current clip state.
ClearRect(x, y, w, h int)
}
// ggClipRect stores one clip rectangle in canvas pixel coordinates.
// GGDrawer replays these rectangles on Restore because gg.Context Push/Pop
// do not restore clip masks the way this package expects.
type ggClipRect struct {
x, y float64
w, h float64
}
// GGDrawer is a PrimitiveDrawer implementation backed by gg.Context.
//
// It intentionally does not perform any world logic. It only forwards already
// projected canvas coordinates to gg while additionally maintaining a clip stack
// compatible with this package's Save/Restore contract.
type GGDrawer struct {
DC *gg.Context
clips []ggClipRect
clipStack [][]ggClipRect
// scratch is a reusable buffer for CopyShift to avoid allocations.
scratch *image.RGBA
}
// Save stores the current gg state and the current logical clip stack.
func (d *GGDrawer) Save() {
d.DC.Push()
snapshot := append([]ggClipRect(nil), d.clips...)
d.clipStack = append(d.clipStack, snapshot)
}
// Restore restores the previous gg state and rebuilds the outer clip state.
//
// gg.Context.Pop restores most state from the stack, but its clip mask handling
// does not match this package's expected Save/Restore semantics. To preserve the
// contract, GGDrawer explicitly resets the clip and replays the previously saved
// clip rectangles after Pop.
func (d *GGDrawer) Restore() {
if len(d.clipStack) == 0 {
panic("GGDrawer: Restore without matching Save")
}
snapshot := d.clipStack[len(d.clipStack)-1]
d.clipStack = d.clipStack[:len(d.clipStack)-1]
d.DC.Pop()
d.clips = append([]ggClipRect(nil), snapshot...)
d.DC.ResetClip()
for _, clip := range d.clips {
d.DC.DrawRectangle(clip.x, clip.y, clip.w, clip.h)
d.DC.Clip()
}
}
// ResetClip clears the current clipping region and the logical clip stack
// for the active state frame.
func (d *GGDrawer) ResetClip() {
d.DC.ResetClip()
d.clips = nil
}
// ClipRect intersects the current clipping region with the given rectangle
// and records it so the clip can be reconstructed after Restore.
func (d *GGDrawer) ClipRect(x, y, w, h float64) {
d.DC.DrawRectangle(x, y, w, h)
d.DC.Clip()
d.clips = append(d.clips, ggClipRect{x: x, y: y, w: w, h: h})
}
// SetStrokeColor sets the stroke color by installing a solid stroke pattern.
func (d *GGDrawer) SetStrokeColor(c color.Color) {
d.DC.SetStrokeStyle(gg.NewSolidPattern(c))
}
// SetFillColor sets the fill color by installing a solid fill pattern.
func (d *GGDrawer) SetFillColor(c color.Color) {
d.DC.SetFillStyle(gg.NewSolidPattern(c))
}
// SetLineWidth sets the line width used for stroking.
func (d *GGDrawer) SetLineWidth(width float64) {
d.DC.SetLineWidth(width)
}
// SetDash sets the dash pattern used for stroking.
func (d *GGDrawer) SetDash(dashes ...float64) {
d.DC.SetDash(dashes...)
}
// SetDashOffset sets the dash phase used for stroking.
func (d *GGDrawer) SetDashOffset(offset float64) {
d.DC.SetDashOffset(offset)
}
// AddPoint appends a point marker to the current path.
func (d *GGDrawer) AddPoint(x, y, r float64) {
d.DC.DrawPoint(x, y, r)
}
// AddLine appends a line segment to the current path.
func (d *GGDrawer) AddLine(x1, y1, x2, y2 float64) {
d.DC.DrawLine(x1, y1, x2, y2)
}
// AddCircle appends a circle to the current path.
func (d *GGDrawer) AddCircle(cx, cy, r float64) {
d.DC.DrawCircle(cx, cy, r)
}
// Stroke renders the current path using the current stroke state.
func (d *GGDrawer) Stroke() {
d.DC.Stroke()
}
// Fill renders the current path using the current fill state.
func (d *GGDrawer) Fill() {
d.DC.Fill()
}
// CopyShift shifts the backing RGBA image by (dx, dy) pixels.
// It clears newly exposed areas to transparent.
func (d *GGDrawer) CopyShift(dx, dy int) {
if dx == 0 && dy == 0 {
return
}
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.CopyShift: backing image is not *image.RGBA")
}
b := img.Bounds()
w := b.Dx()
h := b.Dy()
if w <= 0 || h <= 0 {
return
}
adx := abs(dx)
ady := abs(dy)
if adx >= w || ady >= h {
// Everything shifts out of bounds => just clear.
for i := range img.Pix {
img.Pix[i] = 0
}
return
}
// Prepare scratch with the same bounds.
if d.scratch == nil || d.scratch.Bounds().Dx() != w || d.scratch.Bounds().Dy() != h {
d.scratch = image.NewRGBA(b)
} else {
// Clear scratch to transparent.
for i := range d.scratch.Pix {
d.scratch.Pix[i] = 0
}
}
// Compute source/destination rectangles.
dstX0 := 0
dstY0 := 0
srcX0 := 0
srcY0 := 0
if dx > 0 {
dstX0 = dx
} else {
srcX0 = -dx
}
if dy > 0 {
dstY0 = dy
} else {
srcY0 = -dy
}
copyW := w - max(dstX0, srcX0)
copyH := h - max(dstY0, srcY0)
if copyW <= 0 || copyH <= 0 {
for i := range img.Pix {
img.Pix[i] = 0
}
return
}
// Copy row-by-row (RGBA, 4 bytes per pixel).
for row := 0; row < copyH; row++ {
srcY := srcY0 + row
dstY := dstY0 + row
srcOff := srcY*img.Stride + srcX0*4
dstOff := dstY*d.scratch.Stride + dstX0*4
n := copyW * 4
copy(d.scratch.Pix[dstOff:dstOff+n], img.Pix[srcOff:srcOff+n])
}
// Swap buffers by copying scratch into img.
// (We keep img pointer stable for gg.Context.)
copy(img.Pix, d.scratch.Pix)
}
// ClearAll clears the whole RGBA backing image to transparent.
// It does not touch gg clip state.
func (d *GGDrawer) ClearAll() {
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearAll: backing image is not *image.RGBA")
}
for i := range img.Pix {
img.Pix[i] = 0
}
}
// ClearRect clears a region to transparent. It does not touch gg clip state.
// Rectangle is clamped to the image bounds.
func (d *GGDrawer) ClearRect(x, y, w, h int) {
if w <= 0 || h <= 0 {
return
}
img, ok := d.DC.Image().(*image.RGBA)
if !ok || img == nil {
panic("GGDrawer.ClearRect: backing image is not *image.RGBA")
}
b := img.Bounds()
x0 := max(x, b.Min.X)
y0 := max(y, b.Min.Y)
x1 := min(x+w, b.Max.X)
y1 := min(y+h, b.Max.Y)
if x0 >= x1 || y0 >= y1 {
return
}
// Zero rows.
for yy := y0; yy < y1; yy++ {
off := yy*img.Stride + x0*4
n := (x1 - x0) * 4
for i := 0; i < n; i++ {
img.Pix[off+i] = 0
}
}
}