-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathsprite_render.go
More file actions
806 lines (692 loc) · 26.4 KB
/
sprite_render.go
File metadata and controls
806 lines (692 loc) · 26.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
package pigo8
import (
_ "embed"
"log"
"math"
"sync"
"github.com/hajimehoshi/ebiten/v2"
)
// ===== Transparency Shader =====
// Kage shader for PICO-8 style color-key transparency.
// Treats black (color 0) as transparent, eliminating pixel copying overhead.
//go:embed transparency.kage
var transparencyShaderSrc []byte
var (
// transparencyShader is the compiled shader for color-key transparency
transparencyShader *ebiten.Shader
// shaderInitOnce ensures shader is compiled only once
shaderInitOnce sync.Once
// shaderInitError stores any error from shader compilation
shaderInitError error
)
// initTransparencyShader compiles the transparency shader (called once via sync.Once)
func initTransparencyShader() {
shaderInitOnce.Do(func() {
transparencyShader, shaderInitError = ebiten.NewShader(transparencyShaderSrc)
if shaderInitError != nil {
log.Printf("Warning: Failed to compile transparency shader: %v. Falling back to pixel copying.", shaderInitError)
}
})
}
// getTransparencyShader returns the compiled shader, initializing if needed
func getTransparencyShader() *ebiten.Shader {
initTransparencyShader()
return transparencyShader
}
// ===== Optimization 3: Reusable DrawImageOptions =====
// Single reusable options struct - reset per draw call.
// No mutex needed since Draw is single-threaded in Ebiten.
// This is more efficient than a mutex-based pool for single-threaded rendering.
var reusableDrawOpts ebiten.DrawImageOptions
// Reusable DrawRectShaderOptions for shader-based rendering.
// Note: Images is a [4]*Image fixed-size array (not a slice), so Images[0] is always
// a valid access even on a zero-initialized struct - no initialization needed.
var reusableShaderOpts ebiten.DrawRectShaderOptions
// ===== Optimization 4: Sprite ID Map =====
// Build sprite ID -> index map once when sprites load
var (
spriteIDToIndex map[int]int // Sprite ID -> slice index
indexedSprites []spriteInfo // The sprites snapshot this index was built from
spriteIDIndexBuilt bool
spriteIDIndexMu sync.RWMutex
)
// ===== Sspr Source Image Cache =====
// Cache for Sspr() source images to prevent memory leaks from creating
// new ebiten.Image on every call.
// ssprCacheKey uniquely identifies an Sspr source region
type ssprCacheKey struct {
sx, sy, sw, sh int
}
// ssprCache caches Sspr source images to prevent memory leaks
// Size is configurable via Settings.SsprCacheSize
var ssprCache *LRUCache[ssprCacheKey, *ebiten.Image]
var ssprCacheOnce sync.Once
// getSsprCache returns the Sspr cache, initializing it if needed
func getSsprCache() *LRUCache[ssprCacheKey, *ebiten.Image] {
ssprCacheOnce.Do(func() {
ssprCache = NewLRUCache[ssprCacheKey, *ebiten.Image](configuredSsprCacheSize)
})
return ssprCache
}
// ClearSsprCache clears the Sspr source image cache (useful for memory management)
func ClearSsprCache() {
if ssprCache != nil {
ssprCache.Clear()
}
}
// buildSpriteIDIndexLocked builds the sprite ID to index map.
// Caller must hold spriteIDIndexMu write lock.
// Caller must provide sprites snapshot to avoid lock ordering issues.
// The sprites snapshot is stored alongside the index to ensure consistency.
func buildSpriteIDIndexLocked(sprites []spriteInfo) {
spriteIDToIndex = make(map[int]int, len(sprites))
for i := range sprites {
spriteIDToIndex[sprites[i].ID] = i
}
// Store the sprites snapshot that this index was built from
// This ensures indices always match the sprite array they reference
indexedSprites = sprites
spriteIDIndexBuilt = true
}
// maxSpriteIndexBuildRetries is the maximum number of retry attempts for building
// the sprite ID index. This prevents infinite loops in pathological cases.
const maxSpriteIndexBuildRetries = 100
// ensureSpriteIDIndexBuilt ensures the index is built, rebuilding if necessary.
// Returns the sprites snapshot that the index was built from, ensuring consistency.
// Returns with the read lock held - caller must call spriteIDIndexMu.RUnlock().
// Lock ordering: currentSpritesMu -> spriteIDIndexMu (prevents deadlock with ReloadSprites)
//
// SAFETY: Includes max-retry counter to prevent infinite loops in edge cases.
func ensureSpriteIDIndexBuilt() []spriteInfo {
retries := 0
for {
// SAFETY: Prevent infinite loop in pathological cases
retries++
if retries > maxSpriteIndexBuildRetries {
log.Printf("ERROR: ensureSpriteIDIndexBuilt exceeded max retries (%d). Returning nil.", maxSpriteIndexBuildRetries)
// Return empty slice and hold a fake read lock that will be released by caller
spriteIDIndexMu.RLock()
return nil
}
spriteIDIndexMu.RLock()
if spriteIDIndexBuilt && spriteIDToIndex != nil && indexedSprites != nil {
// Index is valid, return the sprites it was built from
// This ensures the indices match the sprite array
return indexedSprites
}
// Need to rebuild - release read lock first
spriteIDIndexMu.RUnlock()
// IMPORTANT: Acquire currentSpritesMu BEFORE spriteIDIndexMu to prevent deadlock.
// Lock ordering must be: currentSpritesMu -> spriteIDIndexMu
// This matches ReloadSprites() which does: currentSpritesMu.Lock() -> InvalidateSpriteIDIndex()
currentSpritesMu.RLock()
sprites := currentSprites
currentSpritesMu.RUnlock()
spriteIDIndexMu.Lock()
// Double-check after acquiring write lock (another thread may have built it)
if !spriteIDIndexBuilt || spriteIDToIndex == nil {
buildSpriteIDIndexLocked(sprites)
}
spriteIDIndexMu.Unlock()
// Loop back to re-verify with read lock.
// This handles the case where another thread invalidated the index
// between our Unlock() and RLock() calls.
}
}
// InvalidateSpriteIDIndex invalidates the sprite ID index (call when sprites change)
func InvalidateSpriteIDIndex() {
spriteIDIndexMu.Lock()
defer spriteIDIndexMu.Unlock()
spriteIDIndexBuilt = false
spriteIDToIndex = nil
indexedSprites = nil
}
// Multi-tier buffer pool for sprite pixel data.
func getPixelBuffer(size int) []byte {
// Use the global multi-tier buffer pool for efficient allocation
return GetBufferPool().Get(size)
}
func putPixelBuffer(buf []byte) {
// Return buffer to the multi-tier pool
GetBufferPool().Put(buf)
}
// Spr draws a potentially fractional rectangular region of sprites,
// using the internal `currentScreen` and `currentSprites` variables.
//
// The x and y coordinates can be any integer or float type (e.g., int, float64)
// due to the use of generics [X Number, Y Number]. They are converted internally
// to float64 for drawing calculations.
//
// screen: REMOVED (uses internal currentScreen)
// sprites: REMOVED (uses internal currentSprites)
// spriteNumber: The index (int) for the top-left sprite of the block.
// x: Screen X coordinate (any Number type) for the top-left corner.
// y: Screen Y coordinate (any Number type) for the top-left corner.
// options...: Optional parameters (w, h, flipX, flipY)
// - w (float64 or int): Width multiplier (default 1.0). Handled via interface{}.
// - h (float64 or int): Height multiplier (default 1.0). Handled via interface{}.
// - flipX (bool): Flip horizontally (default false). Handled via interface{}.
// - flipY (bool): Flip vertically (default false). Handled via interface{}.
//
// Usage:
//
// Spr(spriteNumber, x, y)
// Spr(spriteNumber, x, y, w, h)
// Spr(spriteNumber, x, y, w, h, flipX)
// Spr(spriteNumber, x, y, w, h, flipX, flipY)
//
// Example:
//
// var ix, iy int = 10, 20
// var fx, fy float64 = 30.5, 20.0
//
// // Draw sprite 1 at (10, 20) using int coordinates
// Spr(1, ix, iy)
//
// // Draw sprite 1 at (30.5, 20.0) using float64 coordinates
// Spr(1, fx, fy)
//
// // Draw sprite 1 at (10, 20.0) using mixed int/float64 coordinates
// Spr(1, ix, fy)
//
// // Draw sprite 1 and the left half of sprite 2 (w=1.5)
// Spr(1, 50, 20, 1.5, 1.0)
//
// // Draw a 1.5w x 1.5h block starting at sprite 0
// Spr(0, 70, 20, 1.5, 1.5)
//
// // Draw the same 1.5 x 1.5 block, flipped horizontally
// Spr(0, 90, 20, 1.5, 1.5, true)
//
// // Draw sprite 0 using a float sprite number (truncated to 0)
// Spr(0.7, 110, 20)
//
// // Explicitly specify generic types if needed (rarely necessary)
// Spr[int, float64](1, 10, 20.5)
//
// // Explicitly specify all generic types
// Spr[float64, int, float64](1.2, 10, 20.5) // spriteNumber becomes 1
func Spr[SN Number, X Number, Y Number](spriteNumber SN, x X, y Y, options ...any) {
// Convert generic spriteNumber, x, y to required types
spriteNumInt := int(spriteNumber) // Cast sprite number to int
fx := float64(x)
fy := float64(y)
// Apply camera offset before using coordinates for drawing
screenFx, screenFy := applyCameraOffset(fx, fy)
// Use internal package variables set by engine.Draw
if currentScreen == nil {
log.Println("Warning: Spr() called before screen was ready.")
return
}
// --- Lazy Loading Logic ---
if currentSprites == nil {
loaded, err := loadSpritesheet() // Call the loading function from spritesheet.go
if err != nil {
log.Fatalf("Fatal: Failed to load required spritesheet for Spr(): %v", err)
}
currentSprites = loaded // Store successfully loaded sprites
}
// Find the sprite by ID or index
spriteInfo := findSpriteByID(spriteNumInt)
if spriteInfo == nil {
// No sprite found with this ID or at this index
debugSpriteNotFound(spriteNumInt, fx, fy)
return
}
// Record sprite rendered (frame-level, minimal overhead)
recordSpriteRendered()
// Parse optional arguments
scaleW, scaleH, flipX, flipY := parseSprOptions(options)
// Get sprite dimensions
tileImage := spriteInfo.Image
spriteWidth := float64(tileImage.Bounds().Dx())
spriteHeight := float64(tileImage.Bounds().Dy())
// Calculate final dimensions
destWidth := spriteWidth * scaleW
destHeight := spriteHeight * scaleH
// Try shader-based transparency (more efficient - no pixel copying)
shader := getTransparencyShader()
if shader != nil {
drawSpriteWithShader(currentScreen, tileImage, screenFx, screenFy, destWidth, destHeight, scaleW, scaleH, flipX, flipY)
MarkShadowBufferDirtyFromSprite() // Mark for lazy Pget() sync
return
}
// Fallback: Create a transparent version of the sprite (pixel copying)
tempImage := createTransparentSpriteImage(tileImage)
// Setup drawing options (reuses single struct, no pool needed)
opts := setupDrawOptions(screenFx, screenFy, destWidth, destHeight, scaleW, scaleH, flipX, flipY)
// Draw the sprite
currentScreen.DrawImage(tempImage, opts)
MarkShadowBufferDirtyFromSprite() // Mark for lazy Pget() sync
}
// drawSpriteWithShader draws a sprite using the transparency shader.
// This is more efficient than creating transparent sprite copies via pixel manipulation.
// The shader treats black (color 0) as transparent directly on the GPU.
func drawSpriteWithShader(dst, src *ebiten.Image, fx, fy, destWidth, destHeight, scaleW, scaleH float64, flipX, flipY bool) {
// Reset shader options
reusableShaderOpts.GeoM.Reset()
reusableShaderOpts.ColorScale.Reset()
// Apply scaling
if scaleW != 1.0 || scaleH != 1.0 {
reusableShaderOpts.GeoM.Scale(scaleW, scaleH)
}
// Apply flipping if needed
if flipX {
reusableShaderOpts.GeoM.Scale(-1, 1)
reusableShaderOpts.GeoM.Translate(destWidth, 0)
}
if flipY {
reusableShaderOpts.GeoM.Scale(1, -1)
reusableShaderOpts.GeoM.Translate(0, destHeight)
}
// Apply final position
reusableShaderOpts.GeoM.Translate(fx, fy)
// Set the source image for the shader
reusableShaderOpts.Images[0] = src
// Draw using the transparency shader
bounds := src.Bounds()
dst.DrawRectShader(bounds.Dx(), bounds.Dy(), transparencyShader, &reusableShaderOpts)
}
// findSpriteByID finds a sprite by its ID using O(1) map lookup
func findSpriteByID(spriteNumInt int) *spriteInfo {
// Ensure index is built and get the sprites snapshot it was built from.
// This guarantees consistency: the indices in spriteIDToIndex always
// correspond to the sprites array we use, preventing stale snapshot bugs.
sprites := ensureSpriteIDIndexBuilt()
defer spriteIDIndexMu.RUnlock()
// Handle sprite ID 0 as transparent sentinel
if spriteNumInt == 0 {
if idx, ok := spriteIDToIndex[0]; ok {
if idx >= 0 && idx < len(sprites) {
return &sprites[idx]
}
}
return nil // Safe no-op if sprite 0 not present
}
// Use sprite ID mapping if available (for deduplication)
// Thread-safe read access to SpriteIDMapping
spriteIDMappingMu.RLock()
mapping := SpriteIDMapping
spriteIDMappingMu.RUnlock()
if mapping != nil {
if mappedIndex, exists := mapping[spriteNumInt]; exists {
// Handle mapping to 0 (transparent)
if mappedIndex == 0 {
if idx, ok := spriteIDToIndex[0]; ok {
if idx >= 0 && idx < len(sprites) {
return &sprites[idx]
}
}
return nil
}
// Return the mapped sprite by index
if mappedIndex >= 0 && mappedIndex < len(sprites) {
return &sprites[mappedIndex]
}
}
return nil // Not found in mapping
}
// O(1) lookup via spriteIDToIndex map
if idx, ok := spriteIDToIndex[spriteNumInt]; ok {
if idx >= 0 && idx < len(sprites) {
return &sprites[idx]
}
}
// Fallback: try array index directly (for backward compatibility)
if spriteNumInt >= 0 && spriteNumInt < len(sprites) {
return &sprites[spriteNumInt]
}
return nil
}
// parseSprOptions parses the optional arguments for the Spr function
func parseSprOptions(options []any) (scaleW float64, scaleH float64, flipX bool, flipY bool) {
// Default values
scaleW = 1.0
scaleH = 1.0
flipX = false
flipY = false
// Process optional width multiplier (arg 1)
if len(options) > 0 && options[0] != nil {
switch val := options[0].(type) {
case int:
scaleW = float64(val)
case float64:
scaleW = val
default:
log.Printf("Warning: Spr() optional arg 1: expected float64 or int (width multiplier), got %T (%v)", options[0], options[0])
}
}
// Process optional height multiplier (arg 2)
if len(options) > 1 && options[1] != nil {
switch val := options[1].(type) {
case int:
scaleH = float64(val)
case float64:
scaleH = val
default:
log.Printf("Warning: Spr() optional arg 2: expected float64 or int (height multiplier), got %T (%v)", options[1], options[1])
}
}
// Process optional flipX (arg 3)
if len(options) > 2 && options[2] != nil {
switch val := options[2].(type) {
case bool:
flipX = val
default:
log.Printf("Warning: Spr() optional arg 3: expected bool (flipX), got %T (%v)", options[2], options[2])
}
}
// Process optional flipY (arg 4)
if len(options) > 3 && options[3] != nil {
switch val := options[3].(type) {
case bool:
flipY = val
default:
log.Printf("Warning: Spr() optional arg 4: expected bool (flipY), got %T (%v)", options[3], options[3])
}
}
// Warn if too many arguments
if len(options) > 4 {
log.Printf("Warning: Spr() called with too many arguments (%d), expected max 6 (num, x, y, w, h, fx, fy).", len(options)+3)
}
return scaleW, scaleH, flipX, flipY
}
// createTransparentSpriteImage creates a transparent version of a sprite, with caching
func createTransparentSpriteImage(tileImage *ebiten.Image) *ebiten.Image {
// Ensure caches are initialized before use
ensureCachesInitialized()
// Check cache first
if cached, exists := spriteImageCache.Get(tileImage); exists {
recordCacheHit()
return cached
}
recordCacheMiss()
// Create new transparent image
width := tileImage.Bounds().Dx()
height := tileImage.Bounds().Dy()
tempImage := ebiten.NewImage(width, height)
// Get pixel buffers from pool (Optimization 7)
size := width * height * 4
sourcePixels := getPixelBuffer(size)
defer putPixelBuffer(sourcePixels)
destPixels := getPixelBuffer(size)
defer putPixelBuffer(destPixels) // Return to pool - WritePixels copies data
// Read source pixels
tileImage.ReadPixels(sourcePixels)
// Process pixels in memory (much faster than individual At()/Set() calls)
for i := 0; i < len(sourcePixels); i += 4 {
r, g, b, a := sourcePixels[i], sourcePixels[i+1], sourcePixels[i+2], sourcePixels[i+3]
// Check if this pixel should be transparent (color 0 or fully transparent)
// Using threshold to match shader behavior (< 0.01 ~= 2/255, > 0.99 ~= 253/255)
// This handles edge cases like compressed sprites with slight color drift
if a == 0 || (r <= 2 && g <= 2 && b <= 2 && a >= 253) {
// Skip setting transparent pixels - leave as 0
continue
}
// Copy pixel to destination
destPixels[i] = r // Red
destPixels[i+1] = g // Green
destPixels[i+2] = b // Blue
destPixels[i+3] = a // Alpha
}
// Upload all pixels to GPU in one operation (copies data, buffer can be reused)
tempImage.WritePixels(destPixels)
// Cache the result
spriteImageCache.Put(tileImage, tempImage)
return tempImage
}
// setupDrawOptions configures the reusable drawing options for a sprite.
// Resets and reuses a single struct since Draw is single-threaded in Ebiten.
// This avoids both allocation overhead and mutex contention from pooling.
func setupDrawOptions(fx, fy, destWidth, destHeight, scaleW, scaleH float64, flipX, flipY bool) *ebiten.DrawImageOptions {
// Reset to clean state (reusing the same struct)
reusableDrawOpts.GeoM.Reset()
reusableDrawOpts.ColorScale.Reset()
// Apply scaling
if scaleW != 1.0 || scaleH != 1.0 {
reusableDrawOpts.GeoM.Scale(scaleW, scaleH)
}
// Apply flipping if needed
if flipX {
// For X flip: Scale by -1 on X axis, then translate to compensate
reusableDrawOpts.GeoM.Scale(-1, 1)
reusableDrawOpts.GeoM.Translate(destWidth, 0)
}
if flipY {
// For Y flip: Scale by -1 on Y axis, then translate to compensate
reusableDrawOpts.GeoM.Scale(1, -1)
reusableDrawOpts.GeoM.Translate(0, destHeight)
}
// Apply final position
reusableDrawOpts.GeoM.Translate(fx, fy)
// Ensure nearest-neighbor filtering for pixel-perfect rendering
reusableDrawOpts.Filter = ebiten.FilterNearest
return &reusableDrawOpts
}
// getSpriteImage returns the *ebiten.Image for a given sprite ID.
// It first tries to find a sprite with a matching ID.
// If not found, it tries to use the spriteID as an index into the spritesheet.
// Returns nil if the sprite cannot be found.
func getSpriteImage(spriteID int) *ebiten.Image {
allSprites := getCurrentSprites() // Get sprites from engine
if allSprites == nil {
// This can happen if sprites haven't been loaded yet.
// Attempt to load them, similar to Spr/Sspr.
loaded, err := loadSpritesheet()
if err != nil {
log.Printf("Warning: GetSpriteImage failed to load spritesheet: %v", err)
return nil
}
currentSprites = loaded // Store for future calls within this package
allSprites = currentSprites
if allSprites == nil { // Still nil after attempt
log.Println("Warning: GetSpriteImage called when currentSprites is nil and load failed")
return nil
}
}
// Use the same logic as findSpriteByID for consistency
foundSpriteInfo := findSpriteByID(spriteID)
if foundSpriteInfo != nil && foundSpriteInfo.Image != nil {
return foundSpriteInfo.Image
}
// Optionally, log if a sprite is truly not found, but be mindful of performance if called often.
// log.Printf("Debug: GetSpriteImage could not find sprite with ID or index: %d", spriteID)
return nil
}
// parseSsprOptions parses the optional arguments for the Sspr function
func parseSsprOptions(options []any, sourceWidth, sourceHeight int) (destWidth, destHeight float64, flipX, flipY bool) {
// Default values
destWidth = float64(sourceWidth)
destHeight = float64(sourceHeight)
flipX = false
flipY = false
// Helper function for logging argument errors
argError := func(pos int, expected string, val interface{}) {
log.Printf("Warning: Sspr() optional arg %d: expected %s, got %T (%v)", pos+1, expected, val, val)
}
// Process optional dw parameter
if len(options) >= 1 && options[0] != nil {
dwVal, ok := options[0].(float64)
if !ok {
if dwInt, intOk := options[0].(int); intOk {
dwVal = float64(dwInt)
ok = true
}
}
if !ok {
argError(0, "float64 or int (destination width)", options[0])
} else {
destWidth = dwVal
}
}
// Process optional dh parameter
if len(options) >= 2 && options[1] != nil {
dhVal, ok := options[1].(float64)
if !ok {
if dhInt, intOk := options[1].(int); intOk {
dhVal = float64(dhInt)
ok = true
}
}
if !ok {
argError(1, "float64 or int (destination height)", options[1])
} else {
destHeight = dhVal
}
}
// Process optional flip_x parameter
if len(options) >= 3 && options[2] != nil {
flipXVal, ok := options[2].(bool)
if !ok {
argError(2, "bool (flip_x)", options[2])
} else {
flipX = flipXVal
}
}
// Process optional flip_y parameter
if len(options) >= 4 && options[3] != nil {
flipYVal, ok := options[3].(bool)
if !ok {
argError(3, "bool (flip_y)", options[3])
} else {
flipY = flipYVal
}
}
if len(options) > 4 {
log.Printf("Warning: Sspr() called with too many arguments (%d), expected max 10 (sx, sy, sw, sh, dx, dy, dw, dh, flip_x, flip_y).", len(options)+6)
}
return destWidth, destHeight, flipX, flipY
}
// createSpriteSourceImage creates a temporary image from the specified region of the spritesheet
func createSpriteSourceImage(sourceX, sourceY, sourceWidth, sourceHeight int) *ebiten.Image {
// Create a temporary image for the source region with transparency
sourceImage := ebiten.NewImage(sourceWidth, sourceHeight)
// Clear the image with transparent color
sourceImage.Fill(colorRGBATransparent)
// Get pixel buffer from pool (Optimization 7)
size := sourceWidth * sourceHeight * 4
pixels := getPixelBuffer(size)
defer putPixelBuffer(pixels) // Return to pool - WritePixels copies data
// Process all pixels in batch
for y := 0; y < sourceHeight; y++ {
for x := 0; x < sourceWidth; x++ {
// Get the color at this position on the spritesheet
colorIndex := Sget(sourceX+x, sourceY+y)
// Skip transparent pixels based on the palette transparency settings
if colorIndex >= 0 && colorIndex < len(paletteTransparency) && paletteTransparency[colorIndex] {
// Skip this pixel, leaving it transparent
continue
}
if colorIndex >= 0 && colorIndex < len(pico8Palette) {
// Set the pixel in the buffer
offset := (y*sourceWidth + x) * 4
r, g, b, a := pico8Palette[colorIndex].RGBA()
pixels[offset] = uint8(r >> 8) // Red
pixels[offset+1] = uint8(g >> 8) // Green
pixels[offset+2] = uint8(b >> 8) // Blue
pixels[offset+3] = uint8(a >> 8) // Alpha
}
}
}
// Upload all pixels to GPU in one operation (copies data, buffer can be reused)
sourceImage.WritePixels(pixels)
return sourceImage
}
// Sspr draws a sprite from the spritesheet with custom dimensions and optional stretching and flipping.
// Mimics PICO-8's sspr(sx, sy, sw, sh, dx, dy, [dw, dh], [flip_x], [flip_y]) function.
//
// sx: sprite sheet x position (in pixels)
// sy: sprite sheet y position (in pixels)
// sw: sprite width (in pixels)
// sh: sprite height (in pixels)
// dx: how far from the left of the screen to draw the sprite
// dy: how far from the top of the screen to draw the sprite
// dw: (optional) how many pixels wide to draw the sprite (default same as sw)
// dh: (optional) how many pixels tall to draw the sprite (default same as sh)
// flip_x: (optional) boolean, if true draw the sprite flipped horizontally (default false)
// flip_y: (optional) boolean, if true draw the sprite flipped vertically (default false)
//
// Example:
//
// // Draw a 16x16 sprite from position (8,8) on the spritesheet to position (10,20) on the screen
// Sspr(8, 8, 16, 16, 10, 20)
//
// // Draw a 6x5 sprite from position (8,8) on the spritesheet to position (10,20) on the screen
// Sspr(8, 8, 6, 5, 10, 20)
//
// // Draw a 16x16 sprite from the spritesheet, stretched to 32x32 on the screen
// Sspr(8, 8, 16, 16, 10, 20, 32, 32)
//
// // Draw a 16x16 sprite, flipped horizontally
// Sspr(8, 8, 16, 16, 10, 20, 16, 16, true, false)
func Sspr[SX Number, SY Number, SW Number, SH Number, DX Number, DY Number](sx SX, sy SY, sw SW, sh SH, dx DX, dy DY, options ...any) {
// Convert generic types to required types
sourceX := int(sx) // Source X on spritesheet
sourceY := int(sy) // Source Y on spritesheet
sourceWidth := int(sw) // Source width on spritesheet
sourceHeight := int(sh) // Source height on spritesheet
destX := float64(dx)
destY := float64(dy)
// Use internal package variables set by engine.Draw
if currentScreen == nil {
log.Println("Warning: Sspr() called before screen was ready.")
return
}
// --- Lazy Loading Logic ---
if currentSprites == nil {
loaded, err := loadSpritesheet()
if err != nil {
log.Printf("Warning: Failed to load spritesheet for Sspr(): %v", err)
return
}
currentSprites = loaded
}
// Parse optional arguments
destWidth, destHeight, flipX, flipY := parseSsprOptions(options, sourceWidth, sourceHeight)
// Validate source rectangle is within spritesheet bounds
if !validateSpriteSheetBounds(sourceX, sourceY, sourceWidth, sourceHeight) {
log.Printf("Warning: Sspr() source rectangle (%d,%d,%d,%d) is outside spritesheet bounds (0,0,%d,%d)",
sourceX, sourceY, sourceWidth, sourceHeight, spritesheetWidth, spritesheetHeight)
// Continue anyway, Ebiten will handle clipping
}
// Clamp dimensions to be non-negative
destWidth = math.Max(0, destWidth)
destHeight = math.Max(0, destHeight)
if destWidth == 0 || destHeight == 0 {
return // Don't draw if scaled to zero size
}
// Check Sspr cache first to prevent memory leaks
cacheKey := ssprCacheKey{sx: sourceX, sy: sourceY, sw: sourceWidth, sh: sourceHeight}
cache := getSsprCache()
sourceImage, cached := cache.Get(cacheKey)
if !cached {
// Create a new image for the source region and cache it
sourceImage = createSpriteSourceImage(sourceX, sourceY, sourceWidth, sourceHeight)
cache.Put(cacheKey, sourceImage)
}
// Reset and reuse the single draw options struct (no pool needed)
reusableDrawOpts.GeoM.Reset()
reusableDrawOpts.ColorScale.Reset()
// Apply camera offset to the intended top-left drawing position (dx, dy)
screenDrawX, screenDrawY := applyCameraOffset(destX, destY)
// Apply scaling to match the destination dimensions
scaleX := destWidth / float64(sourceWidth)
scaleY := destHeight / float64(sourceHeight)
// Temporary variables for final translation, considering flips
finalTranslateX := screenDrawX
finalTranslateY := screenDrawY
// Apply flip transformations if needed
if flipX {
scaleX *= -1.0
finalTranslateX += destWidth // Adjust translation for horizontal flip
}
if flipY {
scaleY *= -1.0
finalTranslateY += destHeight // Adjust translation for vertical flip
}
reusableDrawOpts.GeoM.Scale(scaleX, scaleY)
reusableDrawOpts.GeoM.Translate(finalTranslateX, finalTranslateY) // Use camera-adjusted and flip-adjusted coordinates
// Draw the image to the screen
currentScreen.DrawImage(sourceImage, &reusableDrawOpts)
MarkShadowBufferDirtyFromSprite() // Mark for lazy Pget() sync
}