-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathspritesheet.go
More file actions
557 lines (471 loc) · 19.1 KB
/
spritesheet.go
File metadata and controls
557 lines (471 loc) · 19.1 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
package pigo8
import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
"sync"
"time"
"github.com/hajimehoshi/ebiten/v2"
)
// --- Structs to match spritesheet.json ---
// FlagsData holds sprite flag information.
// Exported because it's part of the exported SpriteInfo struct.
type FlagsData struct { // Exported
Bitfield int `json:"bitfield"`
Individual []bool `json:"individual"`
}
// spriteData holds the raw data for a single sprite from JSON.
// Kept internal as it's only used during loading.
type spriteData struct { // Internal
ID int `json:"id"`
X int `json:"x"`
Y int `json:"y"`
Width int `json:"width"`
Height int `json:"height"`
Pixels [][]int `json:"pixels"`
Flags FlagsData `json:"flags"` // Uses exported FlagsData
Used bool `json:"used"`
}
// Validate validates the sprite data for consistency and correctness
func (s *spriteData) Validate() error {
// Validate dimensions
if s.Width <= 0 || s.Height <= 0 {
return fmt.Errorf("sprite %d has invalid dimensions: %dx%d (must be positive)", s.ID, s.Width, s.Height)
}
// Validate ID is non-negative
if s.ID < 0 {
return fmt.Errorf("sprite has invalid negative ID: %d", s.ID)
}
// Validate position is non-negative
if s.X < 0 || s.Y < 0 {
return fmt.Errorf("sprite %d has invalid negative position: (%d, %d)", s.ID, s.X, s.Y)
}
// Validate pixel data dimensions match declared dimensions
if len(s.Pixels) != s.Height {
return fmt.Errorf("sprite %d pixel data height mismatch: expected %d rows, got %d", s.ID, s.Height, len(s.Pixels))
}
for i, row := range s.Pixels {
if len(row) != s.Width {
return fmt.Errorf("sprite %d pixel data width mismatch at row %d: expected %d pixels, got %d", s.ID, i, s.Width, len(row))
}
// Validate color indices are within reasonable range (0-255 for extended palettes)
for j, colorIndex := range row {
if colorIndex < 0 || colorIndex > 255 {
return fmt.Errorf("sprite %d has invalid color index %d at position (%d, %d): must be 0-255", s.ID, colorIndex, j, i)
}
}
}
// Validate flags bitfield is within reasonable range (8 bits = 0-255)
if s.Flags.Bitfield < 0 || s.Flags.Bitfield > 255 {
return fmt.Errorf("sprite %d has invalid flags bitfield %d: must be 0-255", s.ID, s.Flags.Bitfield)
}
// Validate individual flags array length
if len(s.Flags.Individual) != 8 {
return fmt.Errorf("sprite %d has invalid flags individual array length %d: must be 8", s.ID, len(s.Flags.Individual))
}
return nil
}
// spriteSheet holds the overall structure of the JSON file.
// Kept internal.
type spriteSheet struct { // Internal
// Custom spritesheet dimensions (optional)
SpriteSheetColumns int `json:"SpriteSheetColumns,omitempty"`
SpriteSheetRows int `json:"SpriteSheetRows,omitempty"`
SpriteSheetWidth int `json:"SpriteSheetWidth,omitempty"`
SpriteSheetHeight int `json:"SpriteSheetHeight,omitempty"`
Sprites []spriteData `json:"sprites"`
}
// --- Sprite sheet dimensions ---
// Default sprite sheet dimensions (16x16 sprites)
var (
// spritesheetColumns is the number of sprite columns in the sprite sheet
// Default is 16 for standard PICO-8, 32 for custom palette
spritesheetColumns = 16
// spritesheetRows is the number of sprite rows in the sprite sheet
// Default is 16 for standard PICO-8, 24 for custom palette
spritesheetRows = 16
// spritesheetWidth is the pixel width of the sprite sheet (columns * 8)
spritesheetWidth = 128
// spritesheetHeight is the pixel height of the sprite sheet (rows * 8)
spritesheetHeight = 128
// SpriteIDMapping maps original sprite IDs to loaded sprite indices for optimization
// This allows empty and duplicate sprites to be mapped to existing sprites
// Exported so it can be used by sprite lookup functions
SpriteIDMapping map[int]int
spriteIDMappingMu sync.RWMutex // Protects SpriteIDMapping from concurrent access
)
// --- Target struct to hold processed sprite info ---
// spriteInfo holds the processed, ready-to-use sprite data.
// Exported for use in main.go.
type spriteInfo struct { // Exported
ID int
Image *ebiten.Image
Flags FlagsData
}
// --- Functions to load and process the spritesheet ---
// loadSpritesheetFromData processes sprite data provided as a byte slice.
// This allows users to load the spritesheet.json using go:embed or other methods
// in their own code (enabling build-time checks) and pass the data directly.
func loadSpritesheetFromData(data []byte) ([]spriteInfo, error) {
return loadSpritesheetFromDataInternal(data, true)
}
// loadSpritesheetFromDataForTest is a test-specific version that skips pixel cache updates
func loadSpritesheetFromDataForTest(data []byte) ([]spriteInfo, error) {
return loadSpritesheetFromDataInternal(data, false)
}
// validateAndUnmarshalSpritesheet validates and unmarshals the spritesheet data
func validateAndUnmarshalSpritesheet(data []byte) (*spriteSheet, error) {
// Basic check if data is empty
if len(data) == 0 {
return nil, fmt.Errorf("provided spritesheet data is empty")
}
// Unmarshal the JSON data
var sheet spriteSheet
err := json.Unmarshal(data, &sheet)
if err != nil {
// Return a clear error about unmarshalling
return nil, fmt.Errorf("error unmarshalling provided spritesheet data: %w", err)
}
// Add a check to see if sprites were loaded
if len(sheet.Sprites) == 0 {
// Log warning here as it's about content, not file loading
log.Printf(
"Warning: No sprites found after unmarshalling spritesheet data. Check JSON format and tags.",
)
}
return &sheet, nil
}
// updateSpritesheetDimensions updates global spritesheet dimensions from the sheet data
func updateSpritesheetDimensions(sheet *spriteSheet) {
// Check for custom spritesheet dimensions in the JSON file
if sheet.SpriteSheetColumns > 0 && sheet.SpriteSheetRows > 0 {
// Update the global sprite sheet dimensions
spritesheetColumns = sheet.SpriteSheetColumns
spritesheetRows = sheet.SpriteSheetRows
// If width and height are specified, use them directly
if sheet.SpriteSheetWidth > 0 && sheet.SpriteSheetHeight > 0 {
spritesheetWidth = sheet.SpriteSheetWidth
spritesheetHeight = sheet.SpriteSheetHeight
} else {
// Otherwise calculate them from columns and rows (assuming 8x8 sprites)
spritesheetWidth = spritesheetColumns * 8
spritesheetHeight = spritesheetRows * 8
}
log.Printf("Custom spritesheet dimensions detected: %dx%d sprites (%dx%d pixels)",
spritesheetColumns, spritesheetRows, spritesheetWidth, spritesheetHeight)
}
}
// validateSpritePixelData validates that the first sprite has pixel data
func validateSpritePixelData(sheet *spriteSheet) {
// Check if pixel data is present for the first sprite (if any)
if len(sheet.Sprites) > 0 && len(sheet.Sprites[0].Pixels) == 0 {
log.Printf(
"Warning: First sprite has empty pixel data after unmarshalling. Check JSON tags, especially for 'pixels'.",
)
}
}
// isEmptySprite checks if a sprite is empty (no pixel data or all pixels are 0)
func isEmptySprite(spriteData spriteData) bool {
// Check if pixel data is empty for this specific sprite
if len(spriteData.Pixels) == 0 ||
(len(spriteData.Pixels) > 0 && len(spriteData.Pixels[0]) == 0) {
return true
}
// Check if sprite is empty (all pixels are 0)
return isSpriteEmpty(spriteData)
}
// createSpriteInfo creates a spriteInfo from spriteData
func createSpriteInfo(spriteData spriteData, updatePixelCache bool) (spriteInfo, error) {
// Create a new Ebiten image for the sprite
img := ebiten.NewImage(spriteData.Width, spriteData.Height)
// Create pixel buffer for batch operations
pixels := make([]byte, spriteData.Width*spriteData.Height*4)
// Iterate over pixels and set colors based on the palette
for y, row := range spriteData.Pixels {
for x, colorIndex := range row {
// Use Pico8Palette (defined in screen.go, same package)
if colorIndex >= 0 && colorIndex < len(pico8Palette) {
// PICO-8 color 0 is often transparent
if colorIndex != 0 {
offset := (y*spriteData.Width + 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
}
} else {
log.Printf("Warning: Sprite %d has out-of-range color index %d at (%d, %d) - using transparent pixel", spriteData.ID, colorIndex, x, y)
}
}
}
// Upload all pixels to GPU in one operation
img.WritePixels(pixels)
// Create the SpriteInfo struct
info := spriteInfo{
ID: spriteData.ID,
Image: img,
Flags: spriteData.Flags,
}
// Initialize sprite pixel cache for batch reading operations
initSpritePixelCache(spriteData.ID, img)
if updatePixelCache {
updateSpritePixelCache(spriteData.ID, img)
}
return info, nil
}
// loadSpritesheetFromDataInternal is the internal implementation
func loadSpritesheetFromDataInternal(data []byte, updatePixelCache bool) ([]spriteInfo, error) {
sheet, err := validateAndUnmarshalSpritesheet(data)
if err != nil {
return nil, err
}
// Return empty slice if no sprites found
if len(sheet.Sprites) == 0 {
return []spriteInfo{}, nil
}
updateSpritesheetDimensions(sheet)
validateSpritePixelData(sheet)
return processSprites(sheet, updatePixelCache)
}
// processSprites handles the main sprite processing logic
func processSprites(sheet *spriteSheet, updatePixelCache bool) ([]spriteInfo, error) {
var loadedSprites []spriteInfo
localSpriteIDMapping := make(map[int]int)
spriteHashes := make(map[uint64]int) // hash -> first sprite index with this content (uint64 for performance)
// Clear the global sprite hash table to ensure it's in sync with the local spriteHashes map.
// This prevents bugs where duplicates are detected against stale entries from previous batches,
// which would cause incorrect isDuplicate=false returns when the hash isn't in spriteHashes.
spriteHashTable.Clear()
uniqueLoaded := 0
duplicatesMapped := 0
emptiesMappedTo0 := 0
// First pass: load unique sprites and build hash map
for _, spriteData := range sheet.Sprites {
if !spriteData.Used {
continue // Skip unused sprites
}
// Validate sprite data with timing
validationStart := time.Now()
if err := spriteData.Validate(); err != nil {
metricsCollector.RecordValidationTime(time.Since(validationStart))
recordValidationError()
if isStrictValidationEnabled() {
metricsCollector.RecordStrictValidation()
return nil, fmt.Errorf("strict validation failed for sprite %d: %w", spriteData.ID, err)
}
log.Printf("Warning: Skipping invalid sprite: %v", err)
continue
}
metricsCollector.RecordValidationTime(time.Since(validationStart))
// Handle empty sprites
if isEmptySprite(spriteData) {
localSpriteIDMapping[spriteData.ID] = 0
emptiesMappedTo0++
continue
}
// Handle duplicate detection for sprites without flags (only if optimization is enabled)
if isOptimizationEnabled() && spriteData.Flags.Bitfield == 0 {
existingIndex, isDuplicate, hash := checkForDuplicateWithCollisionDetection(spriteData, spriteHashes)
if isDuplicate {
localSpriteIDMapping[spriteData.ID] = existingIndex
duplicatesMapped++
recordSpriteSkipped()
continue
}
// Store hash for future duplicate detection
// IMPORTANT: Use the hash returned by checkForDuplicateWithCollisionDetection
// (which may be collision-adjusted) to avoid overwriting entries on hash collisions
spriteHashes[hash] = len(loadedSprites)
}
// Load unique sprite
info, err := createSpriteInfo(spriteData, updatePixelCache)
if err != nil {
return nil, fmt.Errorf("failed to create sprite %d: %w", spriteData.ID, err)
}
loadedSprites = append(loadedSprites, info)
localSpriteIDMapping[spriteData.ID] = len(loadedSprites) - 1
uniqueLoaded++
recordSpriteLoaded()
}
// Post-process sprites and finalize
return finalizeSprites(loadedSprites, localSpriteIDMapping, uniqueLoaded, duplicatesMapped, emptiesMappedTo0, updatePixelCache, sheet)
}
// fillMissingIDs fills in missing sprite IDs up to sheet capacity
func fillMissingIDs(localSpriteIDMapping map[int]int) {
if spritesheetRows > 0 && spritesheetColumns > 0 {
maxSpriteID := spritesheetRows * spritesheetColumns
for id := 0; id < maxSpriteID; id++ {
if _, exists := localSpriteIDMapping[id]; !exists {
// Map missing IDs to sprite 0 (transparent)
localSpriteIDMapping[id] = 0
}
}
}
}
// ensureTransparentSprite ensures sprite 0 exists as a transparent sprite
func ensureTransparentSprite(loadedSprites []spriteInfo, localSpriteIDMapping map[int]int, updatePixelCache bool) ([]spriteInfo, map[int]int, int) {
uniqueLoaded := 0
// Ensure sprite 0 exists as a transparent sprite, but only if we have sprites to work with
// or if we're in production mode (updatePixelCache = true)
if updatePixelCache && (len(loadedSprites) == 0 || (len(loadedSprites) > 0 && loadedSprites[0].ID != 0)) {
// Create a transparent sprite 0
transparentSprite := createTransparentSprite()
// Insert at the beginning
loadedSprites = append([]spriteInfo{transparentSprite}, loadedSprites...)
// Update all mappings to account for the shift
for id, index := range localSpriteIDMapping {
localSpriteIDMapping[id] = index + 1
}
// Map sprite 0 to the new transparent sprite
localSpriteIDMapping[0] = 0
uniqueLoaded++
} else if len(loadedSprites) > 0 && loadedSprites[0].ID != 0 {
// In test mode, only create transparent sprite if we have other sprites but no sprite 0
transparentSprite := createTransparentSprite()
// Insert at the beginning
loadedSprites = append([]spriteInfo{transparentSprite}, loadedSprites...)
// Update all mappings to account for the shift
for id, index := range localSpriteIDMapping {
localSpriteIDMapping[id] = index + 1
}
// Map sprite 0 to the new transparent sprite
localSpriteIDMapping[0] = 0
uniqueLoaded++
}
return loadedSprites, localSpriteIDMapping, uniqueLoaded
}
// finalizeSprites completes sprite processing with post-processing steps
func finalizeSprites(loadedSprites []spriteInfo, localSpriteIDMapping map[int]int, uniqueLoaded, duplicatesMapped, emptiesMappedTo0 int, updatePixelCache bool, sheet *spriteSheet) ([]spriteInfo, error) {
fillMissingIDs(localSpriteIDMapping)
var additionalUnique int
loadedSprites, localSpriteIDMapping, additionalUnique = ensureTransparentSprite(loadedSprites, localSpriteIDMapping, updatePixelCache)
uniqueLoaded += additionalUnique
// Update global sprite ID mapping (thread-safe)
spriteIDMappingMu.Lock()
SpriteIDMapping = localSpriteIDMapping
spriteIDMappingMu.Unlock()
// Log optimization statistics
logOptimizationStats(uniqueLoaded, duplicatesMapped, emptiesMappedTo0)
if len(loadedSprites) == 0 && len(sheet.Sprites) > 0 {
log.Printf(
"Warning: No 'used' sprites were processed. Check the 'used' field in your spritesheet data.",
)
}
return loadedSprites, nil
}
// logOptimizationStats logs sprite optimization statistics
func logOptimizationStats(uniqueLoaded, duplicatesMapped, emptiesMappedTo0 int) {
theoreticalTotal := spritesheetRows * spritesheetColumns
if theoreticalTotal > 0 {
reductionPercent := float64(theoreticalTotal-uniqueLoaded) / float64(theoreticalTotal) * 100
log.Printf("Sprite optimization: %d unique loaded, %d duplicates mapped, %d empties mapped to 0 (%.1f%% reduction from theoretical %d)",
uniqueLoaded, duplicatesMapped, emptiesMappedTo0, reductionPercent, theoreticalTotal)
} else {
log.Printf("Sprite optimization: %d unique loaded, %d duplicates mapped, %d empties mapped to 0",
uniqueLoaded, duplicatesMapped, emptiesMappedTo0)
}
}
// loadSpritesheet tries to load spritesheet.json from the current directory, then from common locations,
// then from custom embedded resources, and finally falls back to default embedded resources.
func loadSpritesheet() ([]spriteInfo, error) {
return loadSpritesheetInternal(true)
}
// loadSpritesheetForTest is a test-specific version that skips pixel cache updates
func loadSpritesheetForTest() ([]spriteInfo, error) {
return loadSpritesheetInternal(false)
}
// loadSpritesheetInternal is the internal implementation
func loadSpritesheetInternal(updatePixelCache bool) ([]spriteInfo, error) {
const spritesheetFilename = "spritesheet.json"
// First try to load from the file system
data, err := os.ReadFile(spritesheetFilename)
if err != nil {
// Check common alternative locations
commonLocations := []string{
filepath.Join("assets", spritesheetFilename),
filepath.Join("resources", spritesheetFilename),
filepath.Join("data", spritesheetFilename),
filepath.Join("static", spritesheetFilename),
}
for _, location := range commonLocations {
data, err = os.ReadFile(location)
if err == nil {
log.Printf("Loaded spritesheet from %s", location)
break
}
}
// If still not found, try embedded resources
if err != nil {
log.Printf("Spritesheet file not found in common locations, trying embedded resources")
embeddedData, embErr := tryLoadEmbeddedSpritesheet()
if embErr != nil {
return nil, fmt.Errorf("failed to load embedded spritesheet: %w", embErr)
}
data = embeddedData
}
} else {
log.Printf("Using spritesheet file from current directory: %s", spritesheetFilename)
}
// Log memory after reading file
logMemory("after reading spritesheet file", false)
// Process the spritesheet data
sprites, err := loadSpritesheetFromDataInternal(data, updatePixelCache)
if err != nil {
return nil, fmt.Errorf("error processing spritesheet data: %w", err)
}
// Log when spritesheet is loaded
fileSize := float64(len(data)) / 1024
log.Printf("Spritesheet: %d sprites (%.1f KB)", len(sprites), fileSize)
return sprites, nil
}
// isSpriteEmpty checks if a sprite has all pixels set to 0 (transparent)
func isSpriteEmpty(sprite spriteData) bool {
for _, row := range sprite.Pixels {
for _, pixel := range row {
if pixel != 0 {
return false
}
}
}
return true
}
// createTransparentSprite creates a transparent sprite with ID 0
func createTransparentSprite() spriteInfo {
// Create an 8x8 transparent image
img := ebiten.NewImage(8, 8)
// No need to set pixels, they default to transparent
return spriteInfo{
ID: 0,
Image: img,
Flags: FlagsData{
Bitfield: 0,
Individual: make([]bool, 8),
},
}
}
// LoadSpritesheet loads sprite data from a specific JSON file and updates the
// engine's active spritesheet (currentSprites).
// This function is intended to be called by user code (e.g., an editor) to reload
// the spritesheet at runtime.
func LoadSpritesheet(filename string) error {
data, err := os.ReadFile(filename)
if err != nil {
return fmt.Errorf("error reading spritesheet file %s: %w", filename, err)
}
newSprites, err := loadSpritesheetFromData(data)
if err != nil {
return fmt.Errorf("error processing spritesheet data from %s: %w", filename, err)
}
// Update the package-level currentSprites variable (defined in engine.go)
// Thread-safe write to prevent race with sprite lookups
currentSpritesMu.Lock()
currentSprites = newSprites
currentSpritesMu.Unlock()
// Invalidate sprite ID index so it gets rebuilt with the new sprites
InvalidateSpriteIDIndex()
log.Printf("Successfully loaded and updated spritesheet from %s. %d sprites processed.", filename, len(newSprites))
return nil
}