clone

fifteen puzzle games for the terminal, with daily puzzles, weekly ladders and xp, built on bubble tea

tech debt

Dami committed 95c0b27
commit
95c0b27c37a2ed11097e7db330163ee8963fcfd0
parent
df406aaeeecdbeb12576827cd4baeb79eb2d62be
author
Dami <dami.etoile@gmail.com>
85 files changed, +1591 -2909
Aapp/handle_daily.go51+
Aapp/handle_game.go73++
Aapp/handle_help.go16+
Aapp/handle_menu.go116+++
Aapp/handle_seed.go71++
Aapp/handle_stats.go72++
Aapp/handle_theme.go67++
Mapp/update.go393----------
Rfillomino/Export.go → fillomino/export.go0
Rfillomino/Gamemode.go → fillomino/gamemode.go5+-
Rfillomino/Model.go → fillomino/model.go0
Rfillomino/PrintAdapter.go → fillomino/print_adapter.go0
Mgame/gamer.go10+-
Rhashiwokakero/Export.go → hashiwokakero/export.go0
Rhashiwokakero/Gamemode.go → hashiwokakero/gamemode.go5+-
Rhashiwokakero/Model.go → hashiwokakero/model.go0
Rhashiwokakero/PrintAdapter.go → hashiwokakero/print_adapter.go0
Rhitori/Export.go → hitori/export.go0
Rhitori/Gamemode.go → hitori/gamemode.go5+-
Rhitori/Model.go → hitori/model.go0
Rhitori/PrintAdapter.go → hitori/print_adapter.go0
Rlightsout/Export.go → lightsout/export.go0
Rlightsout/Gamemode.go → lightsout/gamemode.go5+-
Rlightsout/Model.go → lightsout/model.go0
Rnonogram/Export.go → nonogram/export.go0
Rnonogram/Gamemode.go → nonogram/gamemode.go5+-
Rnonogram/Model.go → nonogram/model.go0
Rnonogram/PrintAdapter.go → nonogram/print_adapter.go0
Rnurikabe/Export.go → nurikabe/export.go0
Rnurikabe/Gamemode.go → nurikabe/gamemode.go5+-
Rnurikabe/Model.go → nurikabe/model.go0
Rnurikabe/PrintAdapter.go → nurikabe/print_adapter.go0
Dpdfexport/cover_art.go1236------------------------------
Dpdfexport/cover_art_test.go204-----
Apdfexport/parse_fillomino.go50+
Apdfexport/parse_hashi.go50+
Apdfexport/parse_helpers.go68++
Apdfexport/parse_hitori.go103+++
Apdfexport/parse_nonogram.go129+++
Apdfexport/parse_nurikabe.go74++
Apdfexport/parse_rippleeffect.go40+
Apdfexport/parse_shikaku.go54+
Apdfexport/parse_sudoku.go69++
Apdfexport/parse_takuzu.go71++
Apdfexport/parse_takuzuplus.go94++
Apdfexport/parse_wordsearch.go84++
Dpdfexport/printdata.go809--------------------
Mpdfexport/render_cover.go47+-
Mpdfexport/render_cover_test.go21+
Mpdfexport/render_title.go288+++----
Apdfexport/render_title_test.go51+
Mregistry/registry.go16+-
Rrippleeffect/Export.go → rippleeffect/export.go0
Rrippleeffect/Gamemode.go → rippleeffect/gamemode.go5+-
Rrippleeffect/Model.go → rippleeffect/model.go0
Rrippleeffect/PrintAdapter.go → rippleeffect/print_adapter.go0
Msession/session.go3+-
Rshikaku/Export.go → shikaku/export.go0
Rshikaku/Gamemode.go → shikaku/gamemode.go5+-
Rshikaku/Model.go → shikaku/model.go0
Rshikaku/PrintAdapter.go → shikaku/print_adapter.go0
Rspellpuzzle/Export.go → spellpuzzle/export.go0
Rspellpuzzle/Gamemode.go → spellpuzzle/gamemode.go5+-
Rspellpuzzle/Model.go → spellpuzzle/model.go0
Rspellpuzzle/PrintAdapter.go → spellpuzzle/print_adapter.go0
Rsudoku/Export.go → sudoku/export.go0
Rsudoku/Gamemode.go → sudoku/gamemode.go5+-
Rsudoku/Model.go → sudoku/model.go0
Rsudoku/PrintAdapter.go → sudoku/print_adapter.go0
Rsudokurgb/Export.go → sudokurgb/export.go0
Rsudokurgb/Gamemode.go → sudokurgb/gamemode.go5+-
Rsudokurgb/Model.go → sudokurgb/model.go0
Rsudokurgb/PrintAdapter.go → sudokurgb/print_adapter.go0
Rtakuzu/Export.go → takuzu/export.go0
Rtakuzu/Gamemode.go → takuzu/gamemode.go5+-
Rtakuzu/Model.go → takuzu/model.go0
Rtakuzu/PrintAdapter.go → takuzu/print_adapter.go0
Rtakuzuplus/Export.go → takuzuplus/export.go0
Rtakuzuplus/Gamemode.go → takuzuplus/gamemode.go5+-
Rtakuzuplus/Model.go → takuzuplus/model.go0
Rtakuzuplus/PrintAdapter.go → takuzuplus/print_adapter.go0
Rwordsearch/Export.go → wordsearch/export.go0
Rwordsearch/Gamemode.go → wordsearch/gamemode.go5+-
Rwordsearch/Model.go → wordsearch/model.go0
Rwordsearch/PrintAdapter.go → wordsearch/print_adapter.go0
A app/handle_daily.go +51 -0
A app/handle_game.go +73 -0
@@ -0,0 +1,73 @@
1+package app2+3+import (4+ "log"5+6+ "github.com/FelineStateMachine/puzzletea/registry"7+ sessionflow "github.com/FelineStateMachine/puzzletea/session"8+ "github.com/FelineStateMachine/puzzletea/store"9+ "github.com/FelineStateMachine/puzzletea/ui"10+11+ tea "charm.land/bubbletea/v2"12+)13+14+func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {15+ entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())16+ if !ok {17+ return m, nil18+ }19+ m.nav.selectedCategory = entry20+ m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")21+ m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))22+ m.state = modeSelectView23+ return m, nil24+}25+26+func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {27+ item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())28+ mode, ok := item.(registry.ModeEntry)29+ if !ok {30+ return m, nil31+ }32+ m.nav.selectedModeTitle = mode.Definition.Title33+ ctx, jobID := m.beginSpawnContext()34+ m.session.spawn = &spawnRequest{35+ source: spawnSourceNormal,36+ name: sessionflow.GenerateUniqueName(m.store),37+ gameType: m.nav.selectedCategory.Definition.Name,38+ modeTitle: m.nav.selectedModeTitle,39+ returnState: modeSelectView,40+ exitState: mainMenuView,41+ }42+ m.state = generatingView43+ return m, tea.Batch(m.spinner.Tick, spawnCmd(mode.Spawner, ctx, jobID))44+}45+46+func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {47+ idx := m.nav.continueTable.Cursor()48+ if idx < 0 || idx >= len(m.nav.continueGames) {49+ return m, nil50+ }51+ rec := m.nav.continueGames[idx]52+ m, _ = m.importAndActivateRecord(rec)53+ return m, nil54+}55+56+func (m model) persistCompletionIfSolved() model {57+ if m.session.game == nil || m.session.activeGameID == 0 ||58+ m.session.completionSaved || !m.session.game.IsSolved() {59+ return m60+ }61+62+ m.session.completionSaved = true63+ saveData, err := m.session.game.GetSave()64+ if err == nil {65+ if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {66+ log.Printf("failed to save completion state: %v", err)67+ }68+ }69+ if err := m.store.UpdateStatus(m.session.activeGameID, store.StatusCompleted); err != nil {70+ log.Printf("failed to mark game completed: %v", err)71+ }72+ return m73+}
A app/handle_help.go +16 -0
A app/handle_menu.go +116 -0
@@ -0,0 +1,116 @@
1+package app2+3+import (4+ "time"5+6+ "github.com/FelineStateMachine/puzzletea/theme"7+ "github.com/FelineStateMachine/puzzletea/ui"8+9+ tea "charm.land/bubbletea/v2"10+)11+12+func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {13+ item := m.nav.mainMenu.Selected()14+ switch item.Title() {15+ case "Play":16+ m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))17+ m.state = playMenuView18+ case "Stats":19+ return m.handleStatsEnter()20+ case "Options":21+ m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)22+ m.state = optionsMenuView23+ case "Quit":24+ return m, tea.Quit25+ }26+ return m, nil27+}28+29+func (m model) currentWeeklyMenuIndex() int {30+ if m.store == nil {31+ return 132+ }33+34+ year, week := time.Now().ISOWeek()35+ highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)36+ if err != nil {37+ return 138+ }39+ if highestCompleted >= weeklyEntryCount {40+ return weeklyEntryCount41+ }42+ if highestCompleted < 1 {43+ return 144+ }45+ return highestCompleted + 146+}47+48+func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {49+ item := m.nav.playMenu.Selected()50+ switch item.Title() {51+ case "Create":52+ m.state = gameSelectView53+ m = m.updateCategoryDetailViewport()54+ case "Continue":55+ m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)56+ m.state = continueView57+ case "Daily":58+ return m.handleDailyPuzzle()59+ case "Weekly":60+ return m.enterWeeklyView()61+ case "Seeded":62+ return m.enterSeedInputView()63+ }64+ return m, nil65+}66+67+func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {68+ item := m.nav.optionsMenu.Selected()69+ switch item.Title() {70+ case "Theme":71+ return m.handleThemeEnter()72+ case "Guides":73+ m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play")74+ listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)75+ m.nav.helpSelectList.SetSize(listWidth, listHeight)76+ m.state = helpSelectView77+ }78+ return m, nil79+}80+81+func (m model) handleEscape() (tea.Model, tea.Cmd) {82+ switch m.state {83+ case playMenuView, optionsMenuView, statsView:84+ m.state = mainMenuView85+ case seedInputView, gameSelectView, continueView, weeklyView:86+ m.state = playMenuView87+ case generatingView:88+ returnState := m.activeSpawnReturnState()89+ m.cancelActiveSpawn()90+ m.state = returnState91+ case modeSelectView:92+ m.state = gameSelectView93+ m = m.updateCategoryDetailViewport()94+ case helpDetailView:95+ m.state = helpSelectView96+ case helpSelectView, themeSelectView:97+ if m.state == themeSelectView {98+ _ = theme.Apply(m.theme.previous)99+ }100+ m.state = optionsMenuView101+ }102+ return m, nil103+}104+105+func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {106+ keyMsg, ok := msg.(tea.KeyPressMsg)107+ if !ok {108+ return109+ }110+ switch keyMsg.String() {111+ case "up", "k":112+ menu.CursorUp()113+ case "down", "j":114+ menu.CursorDown()115+ }116+}
A app/handle_seed.go +71 -0
@@ -0,0 +1,71 @@
1+package app2+3+import (4+ "log"5+6+ "github.com/FelineStateMachine/puzzletea/game"7+ "github.com/FelineStateMachine/puzzletea/registry"8+ "github.com/FelineStateMachine/puzzletea/resolve"9+ sessionflow "github.com/FelineStateMachine/puzzletea/session"10+11+ tea "charm.land/bubbletea/v2"12+)13+14+func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {15+ seed := sessionflow.NormalizeSeed(m.nav.seedInput.Value())16+ if seed == "" {17+ return m, nil18+ }19+20+ selectedMode := m.currentSeedMode()21+ name := sessionflow.SeededName(seed)22+ if selectedMode.key != "" {23+ name = sessionflow.SeededNameForGame(seed, selectedMode.gameType)24+ }25+26+ rec, err := m.store.GetDailyGame(name)27+ if err != nil {28+ log.Printf("failed to check seeded game: %v", err)29+ return m, nil30+ }31+ if rec != nil {32+ var resumed bool33+ m, resumed = m.importAndActivateRecord(*rec)34+ if resumed {35+ if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {36+ log.Printf("%v", err)37+ }38+ }39+ return m, nil40+ }41+42+ var spawner game.SeededSpawner43+ var gameType string44+ modeTitle := ""45+ if selectedMode.key == "" {46+ spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries())47+ if err != nil {48+ log.Printf("failed to select seeded mode: %v", err)49+ return m, nil50+ }51+ } else {52+ spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries())53+ if err != nil {54+ log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err)55+ return m, nil56+ }57+ }58+59+ rng := resolve.RNGFromString(seed)60+ ctx, jobID := m.beginSpawnContext()61+ m.session.spawn = &spawnRequest{62+ source: spawnSourceSeed,63+ name: name,64+ gameType: gameType,65+ modeTitle: modeTitle,66+ returnState: playMenuView,67+ exitState: mainMenuView,68+ }69+ m.state = generatingView70+ return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))71+}
A app/handle_stats.go +72 -0
@@ -0,0 +1,72 @@
1+package app2+3+import (4+ "log"5+ "time"6+7+ "github.com/FelineStateMachine/puzzletea/daily"8+ "github.com/FelineStateMachine/puzzletea/registry"9+ "github.com/FelineStateMachine/puzzletea/stats"10+ "github.com/FelineStateMachine/puzzletea/ui"11+12+ "charm.land/bubbles/v2/viewport"13+ tea "charm.land/bubbletea/v2"14+)15+16+func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {17+ catStats, err := m.store.GetCategoryStats()18+ if err != nil {19+ log.Printf("failed to get category stats: %v", err)20+ return m, nil21+ }22+ modeStats, err := m.store.GetModeStats()23+ if err != nil {24+ log.Printf("failed to get mode stats: %v", err)25+ return m, nil26+ }27+ streakDates, err := m.store.GetDailyStreakDates()28+ if err != nil {29+ log.Printf("failed to get daily streak dates: %v", err)30+ return m, nil31+ }32+ weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()33+ if err != nil {34+ log.Printf("failed to get weekly gauntlet completions: %v", err)35+ return m, nil36+ }37+ now := time.Now()38+ currentYear, currentWeek := now.ISOWeek()39+ thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)40+ if err != nil {41+ log.Printf("failed to get current weekly progress: %v", err)42+ return m, nil43+ }44+ currentDaily := false45+ rec, err := m.store.GetDailyGame(daily.Name(now))46+ if err != nil {47+ log.Printf("failed to check current daily: %v", err)48+ } else {49+ currentDaily = rec != nil50+ }51+52+ weights := stats.WeightsFromDefinitions(registry.Definitions())53+ m.stats.cards = stats.BuildCards(weights, catStats, modeStats)54+ m.stats.profile = stats.BuildProfileBanner(55+ catStats,56+ modeStats,57+ weights,58+ streakDates,59+ currentDaily,60+ weekliesCompleted,61+ thisWeekHighestIndex,62+ )63+64+ statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)65+ m.stats.viewport = viewport.New(66+ viewport.WithWidth(statsWidth),67+ viewport.WithHeight(statsHeight),68+ )69+ m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))70+ m.state = statsView71+ return m, nil72+}
A app/handle_theme.go +67 -0
@@ -0,0 +1,67 @@
1+package app2+3+import (4+ "log"5+6+ "github.com/FelineStateMachine/puzzletea/config"7+ "github.com/FelineStateMachine/puzzletea/theme"8+ "github.com/FelineStateMachine/puzzletea/ui"9+10+ "charm.land/bubbles/v2/list"11+ tea "charm.land/bubbletea/v2"12+)13+14+func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {15+ m.theme.previous = m.cfg.Theme16+17+ names := theme.ThemeNames()18+ items := make([]list.Item, len(names))19+ for i, n := range names {20+ desc := "dark theme"21+ if n == theme.DefaultThemeName {22+ desc = "built-in earth-tone palette"23+ } else if t := theme.LookupTheme(n); t != nil && !t.Meta.IsDark {24+ desc = "light theme"25+ }26+ items[i] = ui.MenuItem{ItemTitle: n, Desc: desc}27+ }28+29+ const maxVisibleItems = 830+ listH := min(m.height, maxVisibleItems*3)31+ listW := min(m.width, theme.MaxNameLen+4)32+33+ m.theme.list = ui.InitThemeList(items, listW, listH)34+ for i, item := range items {35+ if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.theme.previous {36+ m.theme.list.Select(i)37+ break38+ }39+ }40+ if m.theme.previous == "" {41+ m.theme.list.Select(0)42+ }43+44+ m.state = themeSelectView45+ return m, nil46+}47+48+func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {49+ item, ok := m.theme.list.SelectedItem().(ui.MenuItem)50+ if !ok {51+ return m, nil52+ }53+54+ themeName := item.ItemTitle55+ if themeName == theme.DefaultThemeName {56+ themeName = ""57+ }58+59+ _ = theme.Apply(item.ItemTitle)60+ m.cfg.Theme = themeName61+ if err := m.cfg.Save(config.DefaultPath()); err != nil {62+ log.Printf("failed to save config: %v", err)63+ }64+65+ m.state = mainMenuView66+ return m, nil67+}
M app/update.go +0 -393
@@ -2,14 +2,8 @@ package app
22 33 import (44 "log"5- "time"65 7- "github.com/FelineStateMachine/puzzletea/config"8- "github.com/FelineStateMachine/puzzletea/daily"96 "github.com/FelineStateMachine/puzzletea/game"10- "github.com/FelineStateMachine/puzzletea/registry"11- "github.com/FelineStateMachine/puzzletea/resolve"12- sessionflow "github.com/FelineStateMachine/puzzletea/session"137 "github.com/FelineStateMachine/puzzletea/stats"148 "github.com/FelineStateMachine/puzzletea/store"159 "github.com/FelineStateMachine/puzzletea/theme"
@@ -396,38 +390,6 @@ func (m model) activeSpawnReturnState() viewState {
396390 return m.session.spawn.returnState397391 }398392 399-func (m model) persistCompletionIfSolved() model {400- if m.session.game == nil || m.session.activeGameID == 0 ||401- m.session.completionSaved || !m.session.game.IsSolved() {402- return m403- }404-405- m.session.completionSaved = true406- saveData, err := m.session.game.GetSave()407- if err == nil {408- if err := m.store.UpdateSaveState(m.session.activeGameID, string(saveData)); err != nil {409- log.Printf("failed to save completion state: %v", err)410- }411- }412- if err := m.store.UpdateStatus(m.session.activeGameID, store.StatusCompleted); err != nil {413- log.Printf("failed to mark game completed: %v", err)414- }415- return m416-}417-418-func updateMainMenuCursor(msg tea.Msg, menu *ui.MainMenu) {419- keyMsg, ok := msg.(tea.KeyPressMsg)420- if !ok {421- return422- }423- switch keyMsg.String() {424- case "up", "k":425- menu.CursorUp()426- case "down", "j":427- menu.CursorDown()428- }429-}430-431393 func (m model) handleEnter() (tea.Model, tea.Cmd) {432394 switch m.state {433395 case mainMenuView:
@@ -454,360 +416,5 @@ func (m model) handleEnter() (tea.Model, tea.Cmd) {
454416 return m, nil455417 }456418 457-func (m model) handleMainMenuEnter() (tea.Model, tea.Cmd) {458- item := m.nav.mainMenu.Selected()459- switch item.Title() {460- case "Play":461- m.nav.playMenu = ui.NewMainMenu(buildPlayMenuItems(time.Now(), m.currentWeeklyMenuIndex()))462- m.state = playMenuView463- case "Stats":464- return m.handleStatsEnter()465- case "Options":466- m.nav.optionsMenu = ui.NewMainMenu(optionsMenuItems)467- m.state = optionsMenuView468- case "Quit":469- return m, tea.Quit470- }471- return m, nil472-}473-474-func (m model) currentWeeklyMenuIndex() int {475- if m.store == nil {476- return 1477- }478-479- year, week := time.Now().ISOWeek()480- highestCompleted, err := m.store.GetCurrentWeeklyHighestCompletedIndex(year, week)481- if err != nil {482- return 1483- }484- if highestCompleted >= weeklyEntryCount {485- return weeklyEntryCount486- }487- if highestCompleted < 1 {488- return 1489- }490- return highestCompleted + 1491-}492-493-func (m model) handlePlayMenuEnter() (tea.Model, tea.Cmd) {494- item := m.nav.playMenu.Selected()495- switch item.Title() {496- case "Create":497- m.state = gameSelectView498- m = m.updateCategoryDetailViewport()499- case "Continue":500- m.nav.continueTable, m.nav.continueGames = ui.InitContinueTable(m.store, m.height)501- m.state = continueView502- case "Daily":503- return m.handleDailyPuzzle()504- case "Weekly":505- return m.enterWeeklyView()506- case "Seeded":507- return m.enterSeedInputView()508- }509- return m, nil510-}511-512-func (m model) handleOptionsMenuEnter() (tea.Model, tea.Cmd) {513- item := m.nav.optionsMenu.Selected()514- switch item.Title() {515- case "Theme":516- return m.handleThemeEnter()517- case "Guides":518- m.nav.helpSelectList = ui.InitList(gameCategoryItems, "How to Play")519- listWidth, listHeight := helpSelectListSize(m.width, m.height, m.nav.helpSelectList)520- m.nav.helpSelectList.SetSize(listWidth, listHeight)521- m.state = helpSelectView522- }523- return m, nil524-}525-526-func (m model) handleSeedConfirm() (tea.Model, tea.Cmd) {527- seed := sessionflow.NormalizeSeed(m.nav.seedInput.Value())528- if seed == "" {529- return m, nil530- }531-532- selectedMode := m.currentSeedMode()533- name := sessionflow.SeededName(seed)534- if selectedMode.key != "" {535- name = sessionflow.SeededNameForGame(seed, selectedMode.gameType)536- }537-538- rec, err := m.store.GetDailyGame(name)539- if err != nil {540- log.Printf("failed to check seeded game: %v", err)541- return m, nil542- }543- if rec != nil {544- var resumed bool545- m, resumed = m.importAndActivateRecord(*rec)546- if resumed {547- if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {548- log.Printf("%v", err)549- }550- }551- return m, nil552- }553-554- var spawner game.SeededSpawner555- var gameType string556- modeTitle := ""557- if selectedMode.key == "" {558- spawner, gameType, modeTitle, err = resolve.SeededMode(seed, registry.Entries())559- if err != nil {560- log.Printf("failed to select seeded mode: %v", err)561- return m, nil562- }563- } else {564- spawner, gameType, modeTitle, err = resolve.SeededModeForGame(seed, selectedMode.gameType, registry.Entries())565- if err != nil {566- log.Printf("failed to select seeded mode for %s: %v", selectedMode.gameType, err)567- return m, nil568- }569- }570-571- rng := resolve.RNGFromString(seed)572- ctx, jobID := m.beginSpawnContext()573- m.session.spawn = &spawnRequest{574- source: spawnSourceSeed,575- name: name,576- gameType: gameType,577- modeTitle: modeTitle,578- returnState: playMenuView,579- exitState: mainMenuView,580- }581- m.state = generatingView582- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))583-}584-585-func (m model) handleDailyPuzzle() (tea.Model, tea.Cmd) {586- today := time.Now()587- name := daily.Name(today)588-589- rec, err := m.store.GetDailyGame(name)590- if err != nil {591- log.Printf("failed to check daily game: %v", err)592- return m, nil593- }594- if rec != nil {595- var resumed bool596- m, resumed = m.importAndActivateRecord(*rec)597- if resumed {598- if err := sessionflow.ResumeAbandonedDeterministicRecord(m.store, rec); err != nil {599- log.Printf("%v", err)600- }601- }602- return m, nil603- }604-605- spawner, gameType, modeTitle := daily.Mode(today)606- if spawner == nil {607- log.Printf("no daily mode available for %s", today.Format("2006-01-02"))608- return m, nil609- }610419 611- rng := daily.RNG(today)612- ctx, jobID := m.beginSpawnContext()613- m.session.spawn = &spawnRequest{614- source: spawnSourceDaily,615- name: name,616- gameType: gameType,617- modeTitle: modeTitle,618- returnState: playMenuView,619- exitState: mainMenuView,620- }621- m.state = generatingView622- return m, tea.Batch(m.spinner.Tick, spawnSeededCmd(spawner, rng, ctx, jobID))623-}624420 625-func (m model) handleGameSelectEnter() (tea.Model, tea.Cmd) {626- entry, ok := selectedCategoryEntry(m.nav.gameSelectList.SelectedItem())627- if !ok {628- return m, nil629- }630- m.nav.selectedCategory = entry631- m.nav.modeSelectList = ui.InitList(buildModeDisplayItems(entry), entry.Definition.Name+" - Select Mode")632- m.nav.modeSelectList.SetSize(min(m.width, 64), min(m.height, ui.ListHeight(m.nav.modeSelectList)))633- m.state = modeSelectView634- return m, nil635-}636-637-func (m model) handleModeSelectEnter() (tea.Model, tea.Cmd) {638- item := unwrapModeDisplayItem(m.nav.modeSelectList.SelectedItem())639- mode, ok := item.(registry.ModeEntry)640- if !ok {641- return m, nil642- }643- m.nav.selectedModeTitle = mode.Definition.Title644- ctx, jobID := m.beginSpawnContext()645- m.session.spawn = &spawnRequest{646- source: spawnSourceNormal,647- name: sessionflow.GenerateUniqueName(m.store),648- gameType: m.nav.selectedCategory.Definition.Name,649- modeTitle: m.nav.selectedModeTitle,650- returnState: modeSelectView,651- exitState: mainMenuView,652- }653- m.state = generatingView654- return m, tea.Batch(m.spinner.Tick, spawnCmd(mode.Spawner, ctx, jobID))655-}656-657-func (m model) handleContinueEnter() (tea.Model, tea.Cmd) {658- idx := m.nav.continueTable.Cursor()659- if idx < 0 || idx >= len(m.nav.continueGames) {660- return m, nil661- }662- rec := m.nav.continueGames[idx]663- m, _ = m.importAndActivateRecord(rec)664- return m, nil665-}666-667-func (m model) handleHelpSelectEnter() (tea.Model, tea.Cmd) {668- entry, ok := selectedCategoryEntry(m.nav.helpSelectList.SelectedItem())669- if !ok {670- return m, nil671- }672- m.help.category = entry673- m = m.updateHelpDetailViewport()674- m.state = helpDetailView675- return m, nil676-}677-678-func (m model) handleEscape() (tea.Model, tea.Cmd) {679- switch m.state {680- case playMenuView, optionsMenuView, statsView:681- m.state = mainMenuView682- case seedInputView, gameSelectView, continueView, weeklyView:683- m.state = playMenuView684- case generatingView:685- returnState := m.activeSpawnReturnState()686- m.cancelActiveSpawn()687- m.state = returnState688- case modeSelectView:689- m.state = gameSelectView690- m = m.updateCategoryDetailViewport()691- case helpDetailView:692- m.state = helpSelectView693- case helpSelectView, themeSelectView:694- if m.state == themeSelectView {695- _ = theme.Apply(m.theme.previous)696- }697- m.state = optionsMenuView698- }699- return m, nil700-}701-702-func (m model) handleThemeEnter() (tea.Model, tea.Cmd) {703- m.theme.previous = m.cfg.Theme704-705- names := theme.ThemeNames()706- items := make([]list.Item, len(names))707- for i, n := range names {708- desc := "dark theme"709- if n == theme.DefaultThemeName {710- desc = "built-in earth-tone palette"711- } else if t := theme.LookupTheme(n); t != nil && !t.Meta.IsDark {712- desc = "light theme"713- }714- items[i] = ui.MenuItem{ItemTitle: n, Desc: desc}715- }716-717- const maxVisibleItems = 8718- listH := min(m.height, maxVisibleItems*3)719- listW := min(m.width, theme.MaxNameLen+4)720-721- m.theme.list = ui.InitThemeList(items, listW, listH)722- for i, item := range items {723- if mi, ok := item.(ui.MenuItem); ok && mi.ItemTitle == m.theme.previous {724- m.theme.list.Select(i)725- break726- }727- }728- if m.theme.previous == "" {729- m.theme.list.Select(0)730- }731-732- m.state = themeSelectView733- return m, nil734-}735-736-func (m model) handleThemeConfirm() (tea.Model, tea.Cmd) {737- item, ok := m.theme.list.SelectedItem().(ui.MenuItem)738- if !ok {739- return m, nil740- }741-742- themeName := item.ItemTitle743- if themeName == theme.DefaultThemeName {744- themeName = ""745- }746-747- _ = theme.Apply(item.ItemTitle)748- m.cfg.Theme = themeName749- if err := m.cfg.Save(config.DefaultPath()); err != nil {750- log.Printf("failed to save config: %v", err)751- }752-753- m.state = mainMenuView754- return m, nil755-}756-757-func (m model) handleStatsEnter() (tea.Model, tea.Cmd) {758- catStats, err := m.store.GetCategoryStats()759- if err != nil {760- log.Printf("failed to get category stats: %v", err)761- return m, nil762- }763- modeStats, err := m.store.GetModeStats()764- if err != nil {765- log.Printf("failed to get mode stats: %v", err)766- return m, nil767- }768- streakDates, err := m.store.GetDailyStreakDates()769- if err != nil {770- log.Printf("failed to get daily streak dates: %v", err)771- return m, nil772- }773- weekliesCompleted, err := m.store.GetCompletedWeeklyGauntlets()774- if err != nil {775- log.Printf("failed to get weekly gauntlet completions: %v", err)776- return m, nil777- }778- now := time.Now()779- currentYear, currentWeek := now.ISOWeek()780- thisWeekHighestIndex, err := m.store.GetCurrentWeeklyHighestCompletedIndex(currentYear, currentWeek)781- if err != nil {782- log.Printf("failed to get current weekly progress: %v", err)783- return m, nil784- }785- currentDaily := false786- rec, err := m.store.GetDailyGame(daily.Name(now))787- if err != nil {788- log.Printf("failed to check current daily: %v", err)789- } else {790- currentDaily = rec != nil791- }792-793- weights := stats.WeightsFromDefinitions(registry.Definitions())794- m.stats.cards = stats.BuildCards(weights, catStats, modeStats)795- m.stats.profile = stats.BuildProfileBanner(796- catStats,797- modeStats,798- weights,799- streakDates,800- currentDaily,801- weekliesCompleted,802- thisWeekHighestIndex,803- )804-805- statsWidth, statsHeight := statsViewportSize(m.width, m.height, m.stats.cards)806- m.stats.viewport = viewport.New(807- viewport.WithWidth(statsWidth),808- viewport.WithHeight(statsHeight),809- )810- m.stats.viewport.SetContent(ui.RenderStatsCardGrid(m.stats.cards, statsWidth))811- m.state = statsView812- return m, nil813-}
R fillomino/Export.go → fillomino/export.go +0 -0

No content changes.

R fillomino/Gamemode.go → fillomino/gamemode.go +2 -3
R fillomino/Model.go → fillomino/model.go +0 -0

No content changes.

R fillomino/PrintAdapter.go → fillomino/print_adapter.go +0 -0

No content changes.

M game/gamer.go +7 -3
R hashiwokakero/Export.go → hashiwokakero/export.go +0 -0

No content changes.

R hashiwokakero/Gamemode.go → hashiwokakero/gamemode.go +2 -3
R hashiwokakero/Model.go → hashiwokakero/model.go +0 -0

No content changes.

R hashiwokakero/PrintAdapter.go → hashiwokakero/print_adapter.go +0 -0

No content changes.

R hitori/Export.go → hitori/export.go +0 -0

No content changes.

R hitori/Gamemode.go → hitori/gamemode.go +2 -3
R hitori/Model.go → hitori/model.go +0 -0

No content changes.

R hitori/PrintAdapter.go → hitori/print_adapter.go +0 -0

No content changes.

R lightsout/Export.go → lightsout/export.go +0 -0

No content changes.

R lightsout/Gamemode.go → lightsout/gamemode.go +2 -3
R lightsout/Model.go → lightsout/model.go +0 -0

No content changes.

R nonogram/Export.go → nonogram/export.go +0 -0

No content changes.

R nonogram/Gamemode.go → nonogram/gamemode.go +2 -3
R nonogram/Model.go → nonogram/model.go +0 -0

No content changes.

R nonogram/PrintAdapter.go → nonogram/print_adapter.go +0 -0

No content changes.

R nurikabe/Export.go → nurikabe/export.go +0 -0

No content changes.

R nurikabe/Gamemode.go → nurikabe/gamemode.go +2 -3
R nurikabe/Model.go → nurikabe/model.go +0 -0

No content changes.

R nurikabe/PrintAdapter.go → nurikabe/print_adapter.go +0 -0

No content changes.

D pdfexport/cover_art.go +0 -1236
@@ -1,1236 +0,0 @@
1-package pdfexport2-3-import (4- "bytes"5- "encoding/binary"6- "fmt"7- "hash/fnv"8- "image"9- "image/color"10- "image/png"11- "math"12- "math/rand"13- "strings"14- "time"15-16- "codeberg.org/go-pdf/fpdf"17-)18-19-type coverVec2 struct {20- x float6421- y float6422-}23-24-type coverGlow struct {25- center coverVec226- spread float6427- strength float6428- col color.RGBA29-}30-31-type coverFieldProfile struct {32- freqX float6433- freqY float6434- freqMixX float6435- freqMixY float6436- freqCurlX float6437- freqCurlY float6438- ampX float6439- ampY float6440- ampMix float6441- ampCurl float6442- phaseX float6443- phaseY float6444- phaseMix float6445- phaseCurl float6446- swirl float6447- shear float6448- pinch float6449- pivot coverVec250-}51-52-type coverFlowLayer struct {53- count int54- steps int55- step float6456- alpha uint857- radius float6458- drift float6459- phase float6460- waveXFreq float6461- waveYFreq float6462- waveXAmp float6463- waveYAmp float6464- a color.RGBA65- b color.RGBA66-}67-68-type coverLatticeProfile struct {69- enabled bool70- layout int71- cols int72- rows int73- neighbors int74- jitterX float6475- jitterY float6476- center coverVec277- radialWarp float6478- edge color.RGBA79- nodeOuter color.RGBA80- nodeInner color.RGBA81- nodeSize float6482- coreSize float6483-}84-85-type coverOrbitProfile struct {86- enabled bool87- center coverVec288- count int89- radiusStart float6490- radiusStep float6491- arcCoverage float6492- segmentsMin int93- segmentsJitter int94- eccentricity float6495- wobble float6496- dotSize float6497- colorA color.RGBA98- colorB color.RGBA99- alphaBase uint8100- alphaStep uint8101-}102-103-type coverMarkProfile struct {104- enabled bool105- count int106- gridW int107- gridH int108- jitter float64109- sizeMin float64110- sizeMax float64111- alphaBase uint8112- alphaRange uint8113- colorA color.RGBA114- colorB color.RGBA115- cutout color.RGBA116-}117-118-type coverPalette struct {119- bgTop color.RGBA120- bgMid color.RGBA121- bgBottom color.RGBA122- accentA color.RGBA123- accentB color.RGBA124- ink color.RGBA125- nodeOuter color.RGBA126- nodeInner color.RGBA127- markCutout color.RGBA128- grainWarm color.RGBA129- grainCool color.RGBA130-}131-132-type coverPaletteFamily struct {133- name string134- bgShift [3]float64135- accentShift [2]float64136- satBias float64137- valueBias float64138-}139-140-type coverArchetype uint8141-142-const (143- coverArchetypeConstellation coverArchetype = iota144- coverArchetypeVortex145- coverArchetypeBands146- coverArchetypeDriftField147- coverArchetypeRadialMesh148- coverArchetypeSparseGlyph149- coverArchetypeCount150-)151-152-type coverModifier uint8153-154-const (155- coverModifierDenseFlow coverModifier = iota156- coverModifierHighOrbit157- coverModifierQuietLattice158- coverModifierMicroMarks159- coverModifierGrainHeavy160- coverModifierNegativeSpace161-)162-163-var coverPaletteFamilies = []coverPaletteFamily{164- {165- name: "tropical",166- bgShift: [3]float64{0.10, 0.02, -0.14},167- accentShift: [2]float64{0.33, 0.52},168- satBias: 0.08,169- valueBias: 0.03,170- },171- {172- name: "sunset",173- bgShift: [3]float64{0.05, -0.05, -0.17},174- accentShift: [2]float64{0.19, 0.38},175- satBias: 0.10,176- valueBias: 0.04,177- },178- {179- name: "electric-mineral",180- bgShift: [3]float64{-0.16, -0.08, 0.01},181- accentShift: [2]float64{0.36, -0.28},182- satBias: 0.13,183- valueBias: -0.02,184- },185- {186- name: "aurora",187- bgShift: [3]float64{0.22, 0.10, -0.09},188- accentShift: [2]float64{0.45, 0.62},189- satBias: 0.11,190- valueBias: 0.02,191- },192- {193- name: "ember",194- bgShift: [3]float64{0.00, -0.11, -0.22},195- accentShift: [2]float64{-0.36, 0.11},196- satBias: 0.09,197- valueBias: -0.03,198- },199- {200- name: "citrus-marine",201- bgShift: [3]float64{0.14, 0.03, -0.13},202- accentShift: [2]float64{0.30, -0.22},203- satBias: 0.12,204- valueBias: 0.01,205- },206-}207-208-type coverArtDirection struct {209- motif int210- top color.RGBA211- mid color.RGBA212- bottom color.RGBA213- verticalCurve float64214- glows []coverGlow215- field coverFieldProfile216- flowLayers []coverFlowLayer217- lattice coverLatticeProfile218- orbit coverOrbitProfile219- marks coverMarkProfile220- grainCount int221- grainWarm color.RGBA222- grainCool color.RGBA223-}224-225-func drawCoverArtworkImage(226- pdf *fpdf.Fpdf,227- scene rectMM,228- seedText string,229- variant string,230- base RGB,231-) {232- seedText = strings.TrimSpace(seedText)233- if seedText == "" {234- seedText = "puzzletea" + time.Now().String()235- }236- if strings.TrimSpace(variant) == "" {237- variant = "front"238- }239- imageName := fmt.Sprintf(240- "puzzletea-cover-artwork-%016x",241- coverSeedHash(seedText+"|"+variant),242- )243- artPNG := renderCoverArtworkPNG(seedText, variant, base)244- options := fpdf.ImageOptions{245- ImageType: "PNG",246- ReadDpi: true,247- }248- pdf.RegisterImageOptionsReader(imageName, options, bytes.NewReader(artPNG))249- pdf.ImageOptions(imageName, scene.x, scene.y, scene.w, scene.h, false, options, 0, "")250-}251-252-func renderCoverArtworkPNG(seedText, variant string, base RGB) []byte {253- const (254- width = 1200255- height = 1400256- )257- seed := coverSeedHash(seedText + "|" + variant)258- direction := newCoverArtDirection(seed, base)259- img := image.NewRGBA(image.Rect(0, 0, width, height))260-261- paintCoverBackground(img, width, height, direction)262- drawCoverFlowTrails(img, width, height, direction, coverStreamRNG(seed, "flow"))263- drawCoverPuzzleLattice(img, width, height, direction, coverStreamRNG(seed, "lattice"))264- drawCoverOrbitBands(img, width, height, direction, coverStreamRNG(seed, "orbit"))265- drawCoverSeedMarks(img, width, height, direction, coverStreamRNG(seed, "marks"))266- drawCoverFilmGrain(img, width, height, direction, coverStreamRNG(seed, "grain"))267-268- var out bytes.Buffer269- if err := png.Encode(&out, img); err != nil {270- return []byte{}271- }272- return out.Bytes()273-}274-275-func newCoverArtDirection(seed uint64, base RGB) coverArtDirection {276- paletteRNG := coverStreamRNG(seed, "palette")277- structureRNG := coverStreamRNG(seed, "structure")278- palette := newCoverPalette(base, paletteRNG)279- primary, modifiers := pickCoverComposition(structureRNG)280-281- glows := make([]coverGlow, 0, 5)282- anchorColor := palette.accentA283- if structureRNG.Intn(2) == 0 {284- anchorColor = palette.accentB285- }286- glows = append(glows, coverGlow{287- center: coverVec2{288- x: 0.12 + structureRNG.Float64()*0.76,289- y: 0.10 + structureRNG.Float64()*0.78,290- },291- spread: 0.24 + structureRNG.Float64()*0.30,292- strength: 0.46 + structureRNG.Float64()*0.28,293- col: jitterRGBA(anchorColor, structureRNG, 26),294- })295-296- glowCount := 2 + structureRNG.Intn(3)297- for i := range glowCount {298- col := palette.accentA299- if i%2 == 1 {300- col = palette.accentB301- }302- col = jitterRGBA(col, structureRNG, 28)303- glows = append(glows, coverGlow{304- center: coverVec2{305- x: 0.06 + structureRNG.Float64()*0.88,306- y: 0.08 + structureRNG.Float64()*0.84,307- },308- spread: 0.42 + structureRNG.Float64()*0.70,309- strength: 0.12 + structureRNG.Float64()*0.28,310- col: col,311- })312- }313-314- field := coverFieldProfile{315- freqX: 3.8 + structureRNG.Float64()*6.8,316- freqY: 3.6 + structureRNG.Float64()*6.5,317- freqMixX: 0.5 + structureRNG.Float64()*1.8,318- freqMixY: 0.5 + structureRNG.Float64()*1.8,319- freqCurlX: 0.4 + structureRNG.Float64()*2.0,320- freqCurlY: 0.4 + structureRNG.Float64()*2.0,321- ampX: 0.60 + structureRNG.Float64()*1.00,322- ampY: 0.60 + structureRNG.Float64()*1.00,323- ampMix: 0.26 + structureRNG.Float64()*0.92,324- ampCurl: 0.24 + structureRNG.Float64()*0.88,325- phaseX: 0.22 + structureRNG.Float64()*1.45,326- phaseY: 0.22 + structureRNG.Float64()*1.45,327- phaseMix: 0.20 + structureRNG.Float64()*1.20,328- phaseCurl: 0.20 + structureRNG.Float64()*1.20,329- swirl: (structureRNG.Float64()*2 - 1) * 0.40,330- shear: (structureRNG.Float64()*2 - 1) * 0.36,331- pinch: (structureRNG.Float64()*2 - 1) * 0.56,332- pivot: coverVec2{333- x: 0.20 + structureRNG.Float64()*0.60,334- y: 0.18 + structureRNG.Float64()*0.64,335- },336- }337-338- flowLayers := make([]coverFlowLayer, 0, 4)339- layerCount := 2 + structureRNG.Intn(3)340- for i := range layerCount {341- t := float64(i) / float64(maxInt(1, layerCount-1))342- a := jitterRGBA(lerpRGB(palette.accentA, palette.bgMid, 0.28+t*0.42), structureRNG, 24)343- b := jitterRGBA(lerpRGB(palette.accentB, palette.bgTop, 0.16+t*0.38), structureRNG, 24)344- flowLayers = append(flowLayers, coverFlowLayer{345- count: 220 + structureRNG.Intn(360),346- steps: 96 + structureRNG.Intn(132),347- step: 1.10 + structureRNG.Float64()*1.80,348- alpha: uint8(22 + structureRNG.Intn(42)),349- radius: 0.78 + structureRNG.Float64()*0.98,350- drift: 0.40 + structureRNG.Float64()*1.72,351- phase: structureRNG.Float64() * math.Pi * 2,352- waveXFreq: 4.6 + structureRNG.Float64()*7.8,353- waveYFreq: 4.6 + structureRNG.Float64()*7.8,354- waveXAmp: 0.06 + structureRNG.Float64()*0.34,355- waveYAmp: 0.06 + structureRNG.Float64()*0.34,356- a: a,357- b: b,358- })359- }360-361- lattice := coverLatticeProfile{362- enabled: true,363- layout: structureRNG.Intn(3),364- cols: 7 + structureRNG.Intn(6),365- rows: 6 + structureRNG.Intn(7),366- neighbors: 2 + structureRNG.Intn(3),367- jitterX: 24 + structureRNG.Float64()*48,368- jitterY: 28 + structureRNG.Float64()*56,369- center: coverVec2{x: 0.28 + structureRNG.Float64()*0.44, y: 0.24 + structureRNG.Float64()*0.50},370- radialWarp: (structureRNG.Float64()*2 - 1) * 0.40,371- edge: color.RGBA{R: palette.ink.R, G: palette.ink.G, B: palette.ink.B, A: uint8(52 + structureRNG.Intn(76))},372- nodeOuter: palette.nodeOuter,373- nodeInner: palette.nodeInner,374- nodeSize: 3.3 + structureRNG.Float64()*3.8,375- coreSize: 1.4 + structureRNG.Float64()*1.7,376- }377-378- orbit := coverOrbitProfile{379- enabled: true,380- center: coverVec2{x: 0.30 + structureRNG.Float64()*0.38, y: 0.26 + structureRNG.Float64()*0.38},381- count: 10 + structureRNG.Intn(18),382- radiusStart: 36 + structureRNG.Float64()*52,383- radiusStep: 13 + structureRNG.Float64()*20,384- arcCoverage: 0.48 + structureRNG.Float64()*0.48,385- segmentsMin: 34 + structureRNG.Intn(40),386- segmentsJitter: 42 + structureRNG.Intn(56),387- eccentricity: 0.66 + structureRNG.Float64()*0.56,388- wobble: 2.6 + structureRNG.Float64()*8.8,389- dotSize: 0.84 + structureRNG.Float64()*0.86,390- colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.22), structureRNG, 18),391- colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.34), structureRNG, 18),392- alphaBase: uint8(18 + structureRNG.Intn(28)),393- alphaStep: uint8(1 + structureRNG.Intn(3)),394- }395-396- marks := coverMarkProfile{397- enabled: true,398- count: 28 + structureRNG.Intn(58),399- gridW: 8 + structureRNG.Intn(9),400- gridH: 10 + structureRNG.Intn(10),401- jitter: 8 + structureRNG.Float64()*30,402- sizeMin: 1.7 + structureRNG.Float64()*1.5,403- sizeMax: 3.4 + structureRNG.Float64()*3.0,404- alphaBase: uint8(40 + structureRNG.Intn(40)),405- alphaRange: uint8(20 + structureRNG.Intn(30)),406- colorA: jitterRGBA(lerpRGB(palette.accentA, palette.bgTop, 0.18), structureRNG, 22),407- colorB: jitterRGBA(lerpRGB(palette.accentB, palette.bgMid, 0.30), structureRNG, 22),408- cutout: palette.markCutout,409- }410-411- direction := coverArtDirection{412- motif: int(primary),413- top: palette.bgTop,414- mid: palette.bgMid,415- bottom: palette.bgBottom,416- verticalCurve: 1.18 + structureRNG.Float64()*1.14,417- glows: glows,418- field: field,419- flowLayers: flowLayers,420- lattice: lattice,421- orbit: orbit,422- marks: marks,423- grainCount: 34000 + structureRNG.Intn(32000),424- grainWarm: palette.grainWarm,425- grainCool: palette.grainCool,426- }427-428- applyCoverPrimaryArchetype(&direction, primary, structureRNG)429- for _, modifier := range modifiers {430- applyCoverModifier(&direction, modifier, structureRNG)431- }432-433- return direction434-}435-436-func coverCompositionForSeed(seed uint64) (coverArchetype, []coverModifier) {437- structureRNG := coverStreamRNG(seed, "structure")438- return pickCoverComposition(structureRNG)439-}440-441-func pickCoverComposition(rng *rand.Rand) (coverArchetype, []coverModifier) {442- primary := pickWeightedCoverArchetype(rng)443- modifierCount := 0444- roll := rng.Float64()445- switch {446- case roll < 0.58:447- modifierCount = 1448- default:449- modifierCount = 2450- }451-452- modifierPool := []coverModifier{453- coverModifierDenseFlow,454- coverModifierHighOrbit,455- coverModifierQuietLattice,456- coverModifierMicroMarks,457- coverModifierGrainHeavy,458- coverModifierNegativeSpace,459- }460- rng.Shuffle(len(modifierPool), func(i, j int) {461- modifierPool[i], modifierPool[j] = modifierPool[j], modifierPool[i]462- })463-464- modifiers := make([]coverModifier, 0, modifierCount)465- for _, modifier := range modifierPool {466- if len(modifiers) >= modifierCount {467- break468- }469- if primary == coverArchetypeSparseGlyph && modifier == coverModifierNegativeSpace {470- continue471- }472- if primary == coverArchetypeBands && modifier == coverModifierHighOrbit {473- continue474- }475- modifiers = append(modifiers, modifier)476- }477- return primary, modifiers478-}479-480-func pickWeightedCoverArchetype(rng *rand.Rand) coverArchetype {481- weights := [...]int{18, 16, 14, 18, 17, 17}482- total := 0483- for _, weight := range weights {484- total += weight485- }486- pick := rng.Intn(total)487- for idx, weight := range weights {488- if pick < weight {489- return coverArchetype(idx)490- }491- pick -= weight492- }493- return coverArchetypeSparseGlyph494-}495-496-func newCoverPalette(base RGB, rng *rand.Rand) coverPalette {497- baseColor := rgbToColor(base)498- baseHue, baseSat, baseVal := rgbToHSV(baseColor)499- family := coverPaletteFamilies[rng.Intn(len(coverPaletteFamilies))]500- baseHue = wrapHue(baseHue + randCentered(rng, 0.32))501-502- baseSat = clamp(0.14, 0.50, baseSat*0.62+0.11+family.satBias*0.18+rng.Float64()*0.07)503- baseVal = clamp(0.34, 0.86, baseVal*0.84+0.12+family.valueBias*0.15+randCentered(rng, 0.05))504-505- top := hsvToRGB(506- wrapHue(baseHue+family.bgShift[0]+randCentered(rng, 0.10)),507- clamp(0.14, 0.34, baseSat*0.50+0.04+family.satBias*0.28+rng.Float64()*0.08),508- clamp(0.74, 0.98, baseVal+0.16+family.valueBias*0.24+rng.Float64()*0.14),509- )510- mid := hsvToRGB(511- wrapHue(baseHue+family.bgShift[1]+randCentered(rng, 0.14)),512- clamp(0.20, 0.50, baseSat*0.74+0.02+family.satBias*0.40+rng.Float64()*0.10),513- clamp(0.46, 0.88, baseVal-0.02+family.valueBias*0.12+randCentered(rng, 0.12)),514- )515- bottom := hsvToRGB(516- wrapHue(baseHue+family.bgShift[2]+randCentered(rng, 0.16)),517- clamp(0.18, 0.54, baseSat*0.82+0.01+family.satBias*0.35+rng.Float64()*0.10),518- clamp(0.08, 0.36, baseVal*0.32+0.02+family.valueBias*0.10+randCentered(rng, 0.10)),519- )520- accentA := hsvToRGB(521- wrapHue(baseHue+family.accentShift[0]+randCentered(rng, 0.18)),522- clamp(0.30, 0.62, baseSat+0.12+family.satBias*0.40+rng.Float64()*0.15),523- clamp(0.62, 0.96, baseVal+0.14+rng.Float64()*0.12),524- )525- accentB := hsvToRGB(526- wrapHue(baseHue+family.accentShift[1]+randCentered(rng, 0.20)),527- clamp(0.28, 0.60, baseSat+0.10+family.satBias*0.38+rng.Float64()*0.16),528- clamp(0.56, 0.92, baseVal+0.08+rng.Float64()*0.12),529- )530- ink := hsvToRGB(531- wrapHue(baseHue+0.50+family.bgShift[2]*0.35),532- clamp(0.22, 0.44, 0.24+baseSat*0.22),533- clamp(0.06, 0.22, 0.12+(1-baseVal)*0.10),534- )535-536- return coverPalette{537- bgTop: top,538- bgMid: mid,539- bgBottom: bottom,540- accentA: accentA,541- accentB: accentB,542- ink: ink,543- nodeOuter: blendRGB(top, color.RGBA{R: 255, G: 244, B: 220, A: 255}, 0.26),544- nodeInner: blendRGB(ink, bottom, 0.34),545- markCutout: blendRGB(bottom, color.RGBA{R: 6, G: 11, B: 20, A: 255}, 0.30),546- grainWarm: blendRGB(top, color.RGBA{R: 255, G: 248, B: 232, A: 255}, 0.36),547- grainCool: blendRGB(bottom, ink, 0.44),548- }549-}550-551-func applyCoverPrimaryArchetype(552- direction *coverArtDirection,553- primary coverArchetype,554- rng *rand.Rand,555-) {556- switch primary {557- case coverArchetypeConstellation:558- direction.lattice.layout = 0559- direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)560- direction.lattice.edge.A = minUint8(160, direction.lattice.edge.A+22)561- for i := range direction.flowLayers {562- direction.flowLayers[i].count = maxInt(160, int(float64(direction.flowLayers[i].count)*0.76))563- direction.flowLayers[i].alpha = maxUint8(16, direction.flowLayers[i].alpha-6)564- }565- direction.orbit.enabled = false566- direction.orbit.count = maxInt(4, direction.orbit.count/2)567- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*0.72 + 0.12)568- direction.marks.count += 12569- direction.field.swirl *= 0.88570- case coverArchetypeVortex:571- direction.lattice.enabled = false572- direction.orbit.count += 14573- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.20 + 0.08)574- direction.orbit.radiusStep *= 0.84575- direction.field.swirl *= 1.42576- for i := range direction.flowLayers {577- direction.flowLayers[i].alpha = minUint8(86, direction.flowLayers[i].alpha+10)578- direction.flowLayers[i].drift *= 1.18579- }580- direction.marks.count = maxInt(8, direction.marks.count-16)581- case coverArchetypeBands:582- direction.lattice.layout = 2583- direction.lattice.enabled = false584- direction.orbit.enabled = false585- direction.orbit.count = maxInt(5, direction.orbit.count/2)586- direction.field.shear *= 0.44587- direction.field.pinch *= 0.74588- for i := range direction.flowLayers {589- direction.flowLayers[i].waveYFreq = 1.8 + rng.Float64()*2.3590- direction.flowLayers[i].waveYAmp += 0.14 + rng.Float64()*0.16591- direction.flowLayers[i].waveXAmp *= 0.66592- direction.flowLayers[i].step *= 0.94593- direction.flowLayers[i].count = maxInt(180, int(float64(direction.flowLayers[i].count)*0.84))594- }595- direction.marks.count = maxInt(8, direction.marks.count-14)596- case coverArchetypeDriftField:597- direction.lattice.enabled = false598- for i := range direction.flowLayers {599- direction.flowLayers[i].count += 130600- direction.flowLayers[i].steps += 18601- direction.flowLayers[i].alpha = minUint8(90, direction.flowLayers[i].alpha+12)602- direction.flowLayers[i].drift *= 1.24603- }604- direction.orbit.count = maxInt(8, direction.orbit.count-4)605- direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)606- direction.marks.count = maxInt(10, direction.marks.count-12)607- direction.grainCount += 5000608- case coverArchetypeRadialMesh:609- direction.lattice.layout = 1610- direction.lattice.rows += 2611- direction.lattice.cols += 1612- direction.lattice.neighbors = minInt(5, direction.lattice.neighbors+1)613- direction.lattice.center = coverVec2{614- x: 0.44 + rng.Float64()*0.12,615- y: 0.38 + rng.Float64()*0.14,616- }617- direction.orbit.enabled = true618- direction.orbit.count += 8619- direction.orbit.eccentricity = clamp(0.56, 1.46, direction.orbit.eccentricity*1.16)620- direction.field.pinch *= 1.24621- for i := range direction.flowLayers {622- direction.flowLayers[i].count = maxInt(110, int(float64(direction.flowLayers[i].count)*0.45))623- direction.flowLayers[i].alpha = maxUint8(14, direction.flowLayers[i].alpha-6)624- }625- direction.marks.count = maxInt(10, direction.marks.count-14)626- case coverArchetypeSparseGlyph:627- for i := range direction.flowLayers {628- direction.flowLayers[i].count = maxInt(120, int(float64(direction.flowLayers[i].count)*0.55))629- direction.flowLayers[i].steps = maxInt(72, int(float64(direction.flowLayers[i].steps)*0.70))630- direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)631- }632- direction.lattice.enabled = rng.Float64() > 0.68633- if direction.lattice.enabled {634- direction.lattice.neighbors = 2635- direction.lattice.edge.A = maxUint8(14, direction.lattice.edge.A-26)636- }637- direction.orbit.enabled = rng.Float64() > 0.80638- direction.orbit.count = maxInt(5, direction.orbit.count/2)639- direction.orbit.arcCoverage *= 0.66640- direction.marks.count += 40641- direction.marks.sizeMax += 1.5642- direction.marks.jitter *= 0.76643- direction.field.swirl *= 0.72644- direction.grainCount += 9000645- }646-}647-648-func applyCoverModifier(649- direction *coverArtDirection,650- modifier coverModifier,651- rng *rand.Rand,652-) {653- switch modifier {654- case coverModifierDenseFlow:655- for i := range direction.flowLayers {656- direction.flowLayers[i].count += 96657- direction.flowLayers[i].steps += 16658- direction.flowLayers[i].alpha = minUint8(94, direction.flowLayers[i].alpha+10)659- }660- direction.grainCount += 3000661- case coverModifierHighOrbit:662- direction.orbit.enabled = true663- direction.orbit.count += 10664- direction.orbit.arcCoverage = clamp01(direction.orbit.arcCoverage*1.15 + 0.05)665- direction.orbit.dotSize += 0.12666- direction.orbit.alphaBase = minUint8(120, direction.orbit.alphaBase+6)667- case coverModifierQuietLattice:668- if direction.lattice.enabled {669- direction.lattice.edge.A = maxUint8(12, direction.lattice.edge.A-30)670- direction.lattice.neighbors = maxInt(2, direction.lattice.neighbors-1)671- }672- if rng.Float64() < 0.35 {673- direction.lattice.enabled = false674- }675- case coverModifierMicroMarks:676- direction.marks.count += 26677- direction.marks.sizeMin = clamp(0.8, 4.0, direction.marks.sizeMin*0.78)678- direction.marks.sizeMax = clamp(1.2, 5.2, direction.marks.sizeMax*0.74)679- direction.marks.jitter *= 0.86680- direction.marks.alphaBase = minUint8(108, direction.marks.alphaBase+8)681- case coverModifierGrainHeavy:682- direction.grainCount += 12000683- direction.grainWarm = blendRGB(direction.grainWarm, color.RGBA{R: 255, G: 250, B: 240, A: 255}, 0.12)684- case coverModifierNegativeSpace:685- for i := range direction.flowLayers {686- direction.flowLayers[i].count = maxInt(140, int(float64(direction.flowLayers[i].count)*0.62))687- direction.flowLayers[i].alpha = maxUint8(12, direction.flowLayers[i].alpha-8)688- }689- direction.orbit.count = maxInt(4, direction.orbit.count-6)690- direction.orbit.alphaBase = maxUint8(8, direction.orbit.alphaBase-4)691- direction.marks.count = maxInt(10, direction.marks.count-12)692- direction.verticalCurve = clamp(1.0, 2.8, direction.verticalCurve+0.26)693- for i := range direction.glows {694- direction.glows[i].strength *= 0.72695- }696- }697-}698-699-func randCentered(rng *rand.Rand, spread float64) float64 {700- return (rng.Float64()*2 - 1) * spread701-}702-703-func paintCoverBackground(img *image.RGBA, w, h int, direction coverArtDirection) {704- for y := range h {705- ty := float64(y) / float64(h-1)706- vertical := blendRGB(707- lerpRGB(direction.top, direction.mid, ty*1.12),708- direction.bottom,709- powClamp(ty, direction.verticalCurve),710- )711- for x := range w {712- tx := float64(x) / float64(w-1)713- col := vertical714- for _, glow := range direction.glows {715- amount := radialFalloff(tx, ty, glow.center.x, glow.center.y, glow.spread)716- col = blendRGB(col, glow.col, amount*glow.strength)717- }718- img.SetRGBA(x, y, col)719- }720- }721-}722-723-func drawCoverFlowTrails(724- img *image.RGBA,725- w, h int,726- direction coverArtDirection,727- rng *rand.Rand,728-) {729- for _, layer := range direction.flowLayers {730- for i := 0; i < layer.count; i++ {731- x := rng.Float64() * float64(w)732- y := rng.Float64() * float64(h)733- for step := 0; step < layer.steps; step++ {734- nx := x / float64(w)735- ny := y / float64(h)736- angle := coverFieldAngle(nx, ny, direction.field, layer.phase)737- angle += math.Sin((ny+layer.phase)*layer.waveYFreq) * layer.waveYAmp738- angle += math.Cos((nx-layer.phase)*layer.waveXFreq) * layer.waveXAmp739-740- x += math.Cos(angle) * layer.step741- y += math.Sin(angle) * layer.step742- x += math.Cos((ny-layer.phase)*math.Pi*2) * layer.drift * 0.04743- y += math.Sin((nx+layer.phase)*math.Pi*2) * layer.drift * 0.08744- if x < 1 || x >= float64(w-1) || y < 1 || y >= float64(h-1) {745- break746- }747- t := float64(step) / float64(layer.steps)748- c := lerpRGB(layer.a, layer.b, t)749- c.A = layer.alpha750- drawDisc(img, x, y, layer.radius, c)751- }752- }753- }754-}755-756-func drawCoverPuzzleLattice(757- img *image.RGBA,758- w, h int,759- direction coverArtDirection,760- rng *rand.Rand,761-) {762- if !direction.lattice.enabled {763- return764- }765- nodes := buildCoverLatticeNodes(w, h, direction.lattice, rng)766- if len(nodes) == 0 {767- return768- }769-770- for i := range nodes {771- for _, j := range nearestN(nodes, i, direction.lattice.neighbors) {772- if j > i {773- drawLine(img, nodes[i], nodes[j], direction.lattice.edge)774- }775- }776- }777- for _, n := range nodes {778- drawDisc(img, n.x, n.y, direction.lattice.nodeSize, direction.lattice.nodeOuter)779- drawDisc(img, n.x, n.y, direction.lattice.coreSize, direction.lattice.nodeInner)780- }781-}782-783-func buildCoverLatticeNodes(784- w, h int,785- profile coverLatticeProfile,786- rng *rand.Rand,787-) []coverVec2 {788- nodes := make([]coverVec2, 0, profile.cols*profile.rows)789- switch profile.layout {790- case 1:791- minDim := math.Min(float64(w), float64(h))792- rings := maxInt(4, profile.rows+1)793- spokes := maxInt(8, profile.cols+4)794- center := coverVec2{795- x: profile.center.x * float64(w),796- y: profile.center.y * float64(h),797- }798- for ring := 1; ring <= rings; ring++ {799- ringT := float64(ring) / float64(rings+1)800- radius := ringT * minDim * (0.16 + 0.62*ringT)801- offset := rng.Float64() * math.Pi * 2802- for spoke := range spokes {803- ang := offset + float64(spoke)/float64(spokes)*math.Pi*2804- ang += math.Sin(float64(ring)*0.9+float64(spoke)*0.45) * profile.radialWarp805- jitter := (rng.Float64() - 0.5) * profile.jitterX806- x := center.x + math.Cos(ang)*(radius+jitter)807- y := center.y + math.Sin(ang)*(radius+jitter*0.45)808- nodes = append(nodes, coverVec2{x: x, y: y})809- }810- }811- case 2:812- total := maxInt(38, profile.cols*profile.rows)813- for i := range total {814- tx := (rng.Float64()*0.84 + 0.08)815- ty := (rng.Float64()*0.84 + 0.08)816- warpX := math.Sin(ty*math.Pi*4+float64(i)*0.22) * profile.radialWarp * 0.12817- warpY := math.Cos(tx*math.Pi*3+float64(i)*0.18) * profile.radialWarp * 0.10818- x := (tx+warpX)*float64(w) + (rng.Float64()-0.5)*profile.jitterX819- y := (ty+warpY)*float64(h) + (rng.Float64()-0.5)*profile.jitterY820- nodes = append(nodes, coverVec2{x: x, y: y})821- }822- default:823- for row := 0; row < profile.rows; row++ {824- for col := 0; col < profile.cols; col++ {825- x := (float64(col) + 1) / (float64(profile.cols) + 1) * float64(w)826- y := (float64(row) + 1) / (float64(profile.rows) + 1) * float64(h)827- x += (rng.Float64() - 0.5) * profile.jitterX828- y += (rng.Float64() - 0.5) * profile.jitterY829- nodes = append(nodes, coverVec2{x: x, y: y})830- }831- }832- }833- return nodes834-}835-836-func drawCoverOrbitBands(837- img *image.RGBA,838- w, h int,839- direction coverArtDirection,840- rng *rand.Rand,841-) {842- if !direction.orbit.enabled {843- return844- }845- center := coverVec2{846- x: float64(w) * direction.orbit.center.x,847- y: float64(h) * direction.orbit.center.y,848- }849- for i := 0; i < direction.orbit.count; i++ {850- radius := direction.orbit.radiusStart + float64(i)*direction.orbit.radiusStep851- start := rng.Float64() * math.Pi * 2852- sweep := direction.orbit.arcCoverage * (0.74 + rng.Float64()*0.52) * math.Pi * 2853- sweep = math.Min(sweep, math.Pi*2)854- segments := direction.orbit.segmentsMin + rng.Intn(direction.orbit.segmentsJitter)855- t := float64(i) / float64(maxInt(1, direction.orbit.count-1))856- col := lerpRGB(direction.orbit.colorA, direction.orbit.colorB, t)857- col.A = minUint8(220, direction.orbit.alphaBase+uint8(i)*direction.orbit.alphaStep)858- for s := range segments {859- a := start + float64(s)/float64(segments)*sweep860- jx := math.Sin(float64(i)*0.43+a*2.9) * direction.orbit.wobble861- jy := math.Cos(float64(i)*0.37+a*2.3) * direction.orbit.wobble * 0.82862- x := center.x + math.Cos(a)*radius*direction.orbit.eccentricity + jx863- y := center.y + math.Sin(a)*radius + jy864- drawDisc(img, x, y, direction.orbit.dotSize, col)865- }866- }867-}868-869-func drawCoverSeedMarks(870- img *image.RGBA,871- w, h int,872- direction coverArtDirection,873- rng *rand.Rand,874-) {875- if !direction.marks.enabled {876- return877- }878- for i := 0; i < direction.marks.count; i++ {879- gx := 1 + rng.Intn(direction.marks.gridW)880- gy := 1 + rng.Intn(direction.marks.gridH)881- x := float64(gx) / float64(direction.marks.gridW+1) * float64(w)882- y := float64(gy) / float64(direction.marks.gridH+1) * float64(h)883- x += (rng.Float64() - 0.5) * direction.marks.jitter884- y += (rng.Float64() - 0.5) * direction.marks.jitter885-886- size := direction.marks.sizeMin887- if direction.marks.sizeMax > direction.marks.sizeMin {888- size += rng.Float64() * (direction.marks.sizeMax - direction.marks.sizeMin)889- }890- col := lerpRGB(direction.marks.colorA, direction.marks.colorB, rng.Float64())891- col.A = minUint8(220, direction.marks.alphaBase+uint8(rng.Intn(int(direction.marks.alphaRange)+1)))892- style := (i + rng.Intn(4) + direction.motif) % 4893- switch style {894- case 0:895- drawDisc(img, x, y, size*0.48, col)896- case 1:897- drawDisc(img, x, y, size, col)898- drawDisc(img, x, y, size*0.52, direction.marks.cutout)899- case 2:900- drawLine(img, coverVec2{x: x - size, y: y}, coverVec2{x: x + size, y: y}, col)901- drawLine(img, coverVec2{x: x, y: y - size}, coverVec2{x: x, y: y + size}, col)902- default:903- drawLine(img, coverVec2{x: x - size, y: y - size}, coverVec2{x: x + size, y: y + size}, col)904- drawLine(img, coverVec2{x: x - size, y: y + size}, coverVec2{x: x + size, y: y - size}, col)905- drawDisc(img, x, y, size*0.32, direction.marks.cutout)906- }907- }908-}909-910-func drawCoverFilmGrain(911- img *image.RGBA,912- w, h int,913- direction coverArtDirection,914- rng *rand.Rand,915-) {916- for i := 0; i < direction.grainCount; i++ {917- x := rng.Intn(w)918- y := rng.Intn(h)919- alpha := uint8(8 + rng.Intn(18))920- if rng.Intn(2) == 0 {921- blendPixel(img, x, y, color.RGBA{922- R: direction.grainWarm.R,923- G: direction.grainWarm.G,924- B: direction.grainWarm.B,925- A: alpha,926- })927- continue928- }929- blendPixel(img, x, y, color.RGBA{930- R: direction.grainCool.R,931- G: direction.grainCool.G,932- B: direction.grainCool.B,933- A: alpha,934- })935- }936-}937-938-func coverFieldAngle(x, y float64, field coverFieldProfile, phase float64) float64 {939- ax := math.Sin((x*field.freqX+phase*field.phaseX)*math.Pi*2) * field.ampX940- ay := math.Cos((y*field.freqY-phase*field.phaseY)*math.Pi*2) * field.ampY941- mix := math.Sin((x*field.freqMixX+y*field.freqMixY+phase*field.phaseMix)*math.Pi*2) * field.ampMix942- curl := math.Cos((x*field.freqCurlX-y*field.freqCurlY+phase*field.phaseCurl)*math.Pi*2) * field.ampCurl943- radial := math.Atan2(y-field.pivot.y, x-field.pivot.x)944- distance := math.Hypot(x-field.pivot.x, y-field.pivot.y)945- swirl := radial * field.swirl946- pinch := (0.5 - distance) * field.pinch947- shear := (x - y) * field.shear948- return ax + ay + mix + curl + swirl + pinch + shear949-}950-951-func nearestN(nodes []coverVec2, idx, n int) []int {952- n = maxInt(1, n)953- bestDistance := make([]float64, n)954- bestIndex := make([]int, n)955- for i := range bestDistance {956- bestDistance[i] = math.MaxFloat64957- bestIndex[i] = -1958- }959- for j := range nodes {960- if j == idx {961- continue962- }963- dx := nodes[idx].x - nodes[j].x964- dy := nodes[idx].y - nodes[j].y965- distance := dx*dx + dy*dy966- for k := range bestDistance {967- if distance >= bestDistance[k] {968- continue969- }970- for shift := len(bestDistance) - 1; shift > k; shift-- {971- bestDistance[shift] = bestDistance[shift-1]972- bestIndex[shift] = bestIndex[shift-1]973- }974- bestDistance[k] = distance975- bestIndex[k] = j976- break977- }978- }979- out := make([]int, 0, len(bestIndex))980- for _, best := range bestIndex {981- if best >= 0 {982- out = append(out, best)983- }984- }985- return out986-}987-988-func drawDisc(img *image.RGBA, cx, cy, radius float64, c color.RGBA) {989- minX := int(math.Floor(cx - radius))990- maxX := int(math.Ceil(cx + radius))991- minY := int(math.Floor(cy - radius))992- maxY := int(math.Ceil(cy + radius))993- r2 := radius * radius994- for y := minY; y <= maxY; y++ {995- for x := minX; x <= maxX; x++ {996- dx := float64(x) + 0.5 - cx997- dy := float64(y) + 0.5 - cy998- if dx*dx+dy*dy <= r2 {999- blendPixel(img, x, y, c)1000- }1001- }1002- }1003-}1004-1005-func drawLine(img *image.RGBA, a, b coverVec2, c color.RGBA) {1006- dx := b.x - a.x1007- dy := b.y - a.y1008- steps := int(math.Max(math.Abs(dx), math.Abs(dy)))1009- if steps <= 0 {1010- blendPixel(img, int(a.x), int(a.y), c)1011- return1012- }1013- for i := 0; i <= steps; i++ {1014- t := float64(i) / float64(steps)1015- drawDisc(img, a.x+dx*t, a.y+dy*t, 0.82, c)1016- }1017-}1018-1019-func blendPixel(img *image.RGBA, x, y int, src color.RGBA) {1020- if !image.Pt(x, y).In(img.Rect) {1021- return1022- }1023- dst := img.RGBAAt(x, y)1024- alpha := float64(src.A) / 2551025- inv := 1 - alpha1026- img.SetRGBA(x, y, color.RGBA{1027- R: uint8(float64(src.R)*alpha + float64(dst.R)*inv),1028- G: uint8(float64(src.G)*alpha + float64(dst.G)*inv),1029- B: uint8(float64(src.B)*alpha + float64(dst.B)*inv),1030- A: 255,1031- })1032-}1033-1034-func rgbToColor(c RGB) color.RGBA {1035- return color.RGBA{R: c.R, G: c.G, B: c.B, A: 255}1036-}1037-1038-func rgbToHSV(c color.RGBA) (h, s, v float64) {1039- r := float64(c.R) / 2551040- g := float64(c.G) / 2551041- b := float64(c.B) / 2551042- maxC := math.Max(r, math.Max(g, b))1043- minC := math.Min(r, math.Min(g, b))1044- chroma := maxC - minC1045-1046- v = maxC1047- if maxC == 0 {1048- s = 01049- } else {1050- s = chroma / maxC1051- }1052- if chroma == 0 {1053- return 0, s, v1054- }1055-1056- switch maxC {1057- case r:1058- h = math.Mod((g-b)/chroma, 6)1059- case g:1060- h = (b-r)/chroma + 21061- default:1062- h = (r-g)/chroma + 41063- }1064- h /= 61065- if h < 0 {1066- h += 11067- }1068- return h, s, v1069-}1070-1071-func hsvToRGB(h, s, v float64) color.RGBA {1072- h = wrapHue(h)1073- s = clamp01(s)1074- v = clamp01(v)1075- if s == 0 {1076- ch := uint8(math.Round(v * 255))1077- return color.RGBA{R: ch, G: ch, B: ch, A: 255}1078- }1079-1080- h6 := h * 61081- segment := math.Floor(h6)1082- f := h6 - segment1083- p := v * (1 - s)1084- q := v * (1 - s*f)1085- t := v * (1 - s*(1-f))1086-1087- var r, g, b float641088- switch int(segment) % 6 {1089- case 0:1090- r, g, b = v, t, p1091- case 1:1092- r, g, b = q, v, p1093- case 2:1094- r, g, b = p, v, t1095- case 3:1096- r, g, b = p, q, v1097- case 4:1098- r, g, b = t, p, v1099- default:1100- r, g, b = v, p, q1101- }1102-1103- return color.RGBA{1104- R: uint8(math.Round(clamp01(r) * 255)),1105- G: uint8(math.Round(clamp01(g) * 255)),1106- B: uint8(math.Round(clamp01(b) * 255)),1107- A: 255,1108- }1109-}1110-1111-func wrapHue(h float64) float64 {1112- if h >= 0 && h < 1 {1113- return h1114- }1115- h = math.Mod(h, 1)1116- if h < 0 {1117- h += 11118- }1119- return h1120-}1121-1122-func lerpRGB(a, b color.RGBA, t float64) color.RGBA {1123- t = clamp01(t)1124- return color.RGBA{1125- R: uint8(float64(a.R) + (float64(b.R)-float64(a.R))*t),1126- G: uint8(float64(a.G) + (float64(b.G)-float64(a.G))*t),1127- B: uint8(float64(a.B) + (float64(b.B)-float64(a.B))*t),1128- A: 255,1129- }1130-}1131-1132-func blendRGB(base, top color.RGBA, amount float64) color.RGBA {1133- return lerpRGB(base, top, clamp01(amount))1134-}1135-1136-func radialFalloff(x, y, cx, cy, spread float64) float64 {1137- dx := x - cx1138- dy := y - cy1139- distance := math.Sqrt(dx*dx + dy*dy)1140- if distance >= spread {1141- return 01142- }1143- q := 1 - distance/spread1144- return q * q1145-}1146-1147-func powClamp(v, p float64) float64 {1148- return math.Pow(clamp01(v), p)1149-}1150-1151-func clamp01(v float64) float64 {1152- if v < 0 {1153- return 01154- }1155- if v > 1 {1156- return 11157- }1158- return v1159-}1160-1161-func clamp(min, max, v float64) float64 {1162- if v < min {1163- return min1164- }1165- if v > max {1166- return max1167- }1168- return v1169-}1170-1171-func maxInt(a, b int) int {1172- if a > b {1173- return a1174- }1175- return b1176-}1177-1178-func minInt(a, b int) int {1179- if a < b {1180- return a1181- }1182- return b1183-}1184-1185-func maxUint8(a, b uint8) uint8 {1186- if a > b {1187- return a1188- }1189- return b1190-}1191-1192-func minUint8(a, b uint8) uint8 {1193- if a < b {1194- return a1195- }1196- return b1197-}1198-1199-func jitterRGBA(c color.RGBA, rng *rand.Rand, spread int) color.RGBA {1200- if spread <= 0 {1201- return c1202- }1203- half := spread / 21204- return color.RGBA{1205- R: shiftChannel(c.R, rng.Intn(spread)-half),1206- G: shiftChannel(c.G, rng.Intn(spread)-half),1207- B: shiftChannel(c.B, rng.Intn(spread)-half),1208- A: c.A,1209- }1210-}1211-1212-func shiftChannel(ch uint8, delta int) uint8 {1213- v := int(ch) + delta1214- if v < 0 {1215- return 01216- }1217- if v > 255 {1218- return 2551219- }1220- return uint8(v)1221-}1222-1223-func coverStreamRNG(seed uint64, stream string) *rand.Rand {1224- h := fnv.New64a()1225- var raw [8]byte1226- binary.LittleEndian.PutUint64(raw[:], seed)1227- _, _ = h.Write(raw[:])1228- _, _ = h.Write([]byte(stream))1229- return rand.New(rand.NewSource(int64(h.Sum64())))1230-}1231-1232-func coverSeedHash(text string) uint64 {1233- h := fnv.New64a()1234- h.Write([]byte(text))1235- return h.Sum64()1236-}
D pdfexport/cover_art_test.go +0 -204
@@ -1,204 +0,0 @@
1-package pdfexport2-3-import (4- "bytes"5- "fmt"6- "image"7- "image/color"8- "image/png"9- "math"10- "testing"11-)12-13-// --- Cover art seeding (P1) ---14-15-func TestRenderCoverArtworkPNGDeterministic(t *testing.T) {16- base := RGB{R: 86, G: 124, B: 149}17- seed := "quiet-fjord"18-19- first := renderCoverArtworkPNG(seed, "front", base)20- second := renderCoverArtworkPNG(seed, "front", base)21-22- if len(first) == 0 || len(second) == 0 {23- t.Fatalf("renderCoverArtworkPNG returned empty output")24- }25- if !bytes.Equal(first, second) {26- t.Fatalf("expected identical PNG bytes for identical seed and variant")27- }28-}29-30-func TestRenderCoverArtworkPNGVariesBySeedAndVariant(t *testing.T) {31- if testing.Short() {32- t.Skip("skipping cover art variation check in short mode")33- }34-35- base := RGB{R: 90, G: 128, B: 154}36- frontA := renderCoverArtworkPNG("quiet-fjord", "front", base)37- frontB := renderCoverArtworkPNG("quiet-fjord-alt", "front", base)38- backA := renderCoverArtworkPNG("quiet-fjord", "back", base)39-40- assertArtworkDiff(t, frontA, frontB, 0.03)41- assertArtworkDiff(t, frontA, backA, 0.03)42-}43-44-func TestCoverArtworkBatchSaturationFloor(t *testing.T) {45- if testing.Short() {46- t.Skip("skipping batch saturation check in short mode")47- }48-49- base := RGB{R: 90, G: 128, B: 154}50- seeds := coverSeedSamples(30)51- var total float6452- for _, seed := range seeds {53- img := decodePNG(t, renderCoverArtworkPNG(seed, "front", base))54- total += sampledMeanSaturation(img, 14)55- }56- meanSaturation := total / float64(len(seeds))57- if meanSaturation < 0.24 {58- t.Fatalf("mean saturation too low: got %.4f want >= 0.2400", meanSaturation)59- }60- if meanSaturation > 0.36 {61- t.Fatalf("mean saturation unexpectedly high: got %.4f want <= 0.3600", meanSaturation)62- }63-}64-65-func TestCoverArtworkBatchDiversityFloor(t *testing.T) {66- if testing.Short() {67- t.Skip("skipping batch diversity check in short mode")68- }69-70- base := RGB{R: 90, G: 128, B: 154}71- seeds := coverSeedSamples(30)72- images := make([]image.Image, 0, len(seeds))73- for _, seed := range seeds {74- images = append(images, decodePNG(t, renderCoverArtworkPNG(seed, "front", base)))75- }76-77- minDiff := math.MaxFloat6478- var total float6479- var pairs int80- for i := range images {81- for j := i + 1; j < len(images); j++ {82- diff := sampledMeanChannelDiff(images[i], images[j], 12)83- if diff < minDiff {84- minDiff = diff85- }86- total += diff87- pairs++88- }89- }90- meanDiff := total / float64(pairs)91- if meanDiff < 0.090 {92- t.Fatalf("mean pairwise diff too low: got %.4f want >= 0.0900", meanDiff)93- }94- if minDiff < 0.040 {95- t.Fatalf("minimum pairwise diff too low: got %.4f want >= 0.0400", minDiff)96- }97-}98-99-func TestCoverArtworkArchetypeCoverage(t *testing.T) {100- seen := make(map[coverArchetype]bool, int(coverArchetypeCount))101- for i := 1; i <= 120; i++ {102- seed := fmt.Sprintf("coverage-%03d|front", i)103- archetype, _ := coverCompositionForSeed(coverSeedHash(seed))104- seen[archetype] = true105- }106-107- if len(seen) != int(coverArchetypeCount) {108- t.Fatalf("archetype coverage incomplete: got %d want %d", len(seen), coverArchetypeCount)109- }110-}111-112-func TestRenderCoverArtworkPNGHandlesLowAndHighChromaBase(t *testing.T) {113- lowChroma := RGB{R: 128, G: 128, B: 128}114- highChroma := RGB{R: 245, G: 62, B: 58}115-116- low := renderCoverArtworkPNG("chroma-check", "front", lowChroma)117- high := renderCoverArtworkPNG("chroma-check", "front", highChroma)118-119- if len(low) == 0 || len(high) == 0 {120- t.Fatalf("renderCoverArtworkPNG returned empty output for chroma check")121- }122- assertArtworkDiff(t, low, high, 0.03)123-}124-125-func assertArtworkDiff(t *testing.T, left, right []byte, minMeanDiff float64) {126- t.Helper()127-128- if bytes.Equal(left, right) {129- t.Fatalf("unexpected byte-identical images for different inputs")130- }131-132- imgA := decodePNG(t, left)133- imgB := decodePNG(t, right)134- diff := sampledMeanChannelDiff(imgA, imgB, 12)135- if diff < minMeanDiff {136- t.Fatalf("mean sampled channel diff too low: got %.4f want >= %.4f", diff, minMeanDiff)137- }138-}139-140-func decodePNG(t *testing.T, data []byte) image.Image {141- t.Helper()142- img, err := png.Decode(bytes.NewReader(data))143- if err != nil {144- t.Fatalf("png.Decode error: %v", err)145- }146- return img147-}148-149-func sampledMeanChannelDiff(a, b image.Image, step int) float64 {150- bounds := a.Bounds()151- if step < 1 {152- step = 1153- }154-155- var total float64156- var samples int157- for y := bounds.Min.Y; y < bounds.Max.Y; y += step {158- for x := bounds.Min.X; x < bounds.Max.X; x += step {159- ar, ag, ab, _ := a.At(x, y).RGBA()160- br, bg, bb, _ := b.At(x, y).RGBA()161- total += math.Abs(float64(ar)-float64(br)) / 65535162- total += math.Abs(float64(ag)-float64(bg)) / 65535163- total += math.Abs(float64(ab)-float64(bb)) / 65535164- samples += 3165- }166- }167-168- if samples == 0 {169- return 0170- }171- return total / float64(samples)172-}173-174-func sampledMeanSaturation(img image.Image, step int) float64 {175- bounds := img.Bounds()176- if step < 1 {177- step = 1178- }179-180- var total float64181- var samples int182- for y := bounds.Min.Y; y < bounds.Max.Y; y += step {183- for x := bounds.Min.X; x < bounds.Max.X; x += step {184- r, g, b, _ := img.At(x, y).RGBA()185- c := color.RGBA{R: uint8(r >> 8), G: uint8(g >> 8), B: uint8(b >> 8), A: 255}186- _, sat, _ := rgbToHSV(c)187- total += sat188- samples++189- }190- }191-192- if samples == 0 {193- return 0194- }195- return total / float64(samples)196-}197-198-func coverSeedSamples(n int) []string {199- out := make([]string, 0, n)200- for i := 1; i <= n; i++ {201- out = append(out, fmt.Sprintf("test-cover-%02d", i))202- }203- return out204-}
A pdfexport/parse_fillomino.go +50 -0
A pdfexport/parse_hashi.go +50 -0
A pdfexport/parse_helpers.go +68 -0
@@ -0,0 +1,68 @@
1+package pdfexport2+3+import (4+ "fmt"5+ "strconv"6+ "strings"7+)8+9+func splitNormalizedLines(raw string) []string {10+ normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")11+ if strings.TrimSpace(normalized) == "" {12+ return nil13+ }14+ return strings.Split(normalized, "\n")15+}16+17+func parseNumberGrid(encoded string, width, height int) ([][]int, error) {18+ rows := strings.Split(strings.TrimSpace(encoded), "\n")19+ grid := make([][]int, height)20+ for y := range height {21+ grid[y] = make([]int, width)22+ if y >= len(rows) {23+ continue24+ }25+26+ fields := strings.Fields(rows[y])27+ if len(fields) == 0 {28+ fields = splitCompactFields(rows[y])29+ }30+ if len(fields) != width {31+ return nil, fmt.Errorf("decode number grid: invalid row width")32+ }33+ for x := range width {34+ if fields[x] == "." {35+ continue36+ }37+ value, err := strconv.Atoi(fields[x])38+ if err != nil {39+ return nil, fmt.Errorf("decode number grid: %w", err)40+ }41+ grid[y][x] = value42+ }43+ }44+ return grid, nil45+}46+47+func parseProvidedMask(encoded string, width, height int) [][]bool {48+ rows := strings.Split(encoded, "\n")49+ mask := make([][]bool, height)50+ for y := range height {51+ mask[y] = make([]bool, width)52+ if y >= len(rows) {53+ continue54+ }55+ for x := 0; x < width && x < len(rows[y]); x++ {56+ mask[y][x] = rows[y][x] == '#'57+ }58+ }59+ return mask60+}61+62+func splitCompactFields(row string) []string {63+ fields := make([]string, 0, len(row))64+ for _, r := range row {65+ fields = append(fields, string(r))66+ }67+ return fields68+}
A pdfexport/parse_hitori.go +103 -0
@@ -0,0 +1,103 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+ "unicode/utf8"8+)9+10+type hitoriSave struct {11+ Size int `json:"size"`12+ Numbers string `json:"numbers"`13+}14+15+func ParseHitoriPrintData(saveData []byte) (*HitoriData, error) {16+ if len(strings.TrimSpace(string(saveData))) == 0 {17+ return nil, nil18+ }19+20+ var save hitoriSave21+ if err := json.Unmarshal(saveData, &save); err != nil {22+ return nil, fmt.Errorf("decode hitori save: %w", err)23+ }24+25+ rows := splitNormalizedLines(save.Numbers)26+ size := save.Size27+ if size <= 0 {28+ size = len(rows)29+ }30+ if size <= 0 {31+ return nil, nil32+ }33+34+ numbers := make([][]string, size)35+ for y := 0; y < size; y++ {36+ numbers[y] = make([]string, size)37+ if y >= len(rows) {38+ continue39+ }40+41+ rowValues := parseHitoriRowValues(rows[y])42+ for x := 0; x < size && x < len(rowValues); x++ {43+ numbers[y][x] = rowValues[x]44+ }45+ }46+47+ return &HitoriData{48+ Size: size,49+ Numbers: numbers,50+ }, nil51+}52+53+func parseHitoriRowValues(row string) []string {54+ row = strings.TrimSpace(row)55+ if row == "" {56+ return nil57+ }58+59+ if strings.Contains(row, " ") || strings.Contains(row, ",") {60+ fields := strings.Fields(strings.ReplaceAll(row, ",", " "))61+ if len(fields) > 1 {62+ values := make([]string, len(fields))63+ for i, field := range fields {64+ values[i] = normalizeHitoriToken(field)65+ }66+ return values67+ }68+ }69+70+ runes := []rune(row)71+ values := make([]string, len(runes))72+ for i, r := range runes {73+ values[i] = normalizeHitoriRune(r)74+ }75+ return values76+}77+78+func normalizeHitoriToken(token string) string {79+ token = strings.TrimSpace(token)80+ if token == "" || token == "." {81+ return ""82+ }83+ if utf8.RuneCountInString(token) == 1 {84+ r, _ := utf8.DecodeRuneInString(token)85+ return normalizeHitoriRune(r)86+ }87+ return token88+}89+90+func normalizeHitoriRune(r rune) string {91+ switch {92+ case r == '.':93+ return ""94+ case r >= '0' && r <= '9':95+ return string(r)96+ default:97+ value := int(r - '0')98+ if value >= 10 && value <= 35 {99+ return fmt.Sprintf("%d", value)100+ }101+ return string(r)102+ }103+}
A pdfexport/parse_nonogram.go +129 -0
@@ -0,0 +1,129 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+type nonogramSave struct {10+ State string `json:"state"`11+ Width int `json:"width"`12+ Height int `json:"height"`13+ RowHints [][]int `json:"row-hints"`14+ ColHints [][]int `json:"col-hints"`15+}16+17+func ParseNonogramPrintData(saveData []byte) (*NonogramData, error) {18+ if len(strings.TrimSpace(string(saveData))) == 0 {19+ return nil, nil20+ }21+22+ var save nonogramSave23+ if err := json.Unmarshal(saveData, &save); err != nil {24+ return nil, fmt.Errorf("decode nonogram save: %w", err)25+ }26+27+ stateRows := splitNonogramStateRows(save.State)28+29+ width := save.Width30+ if width <= 0 {31+ width = len(save.ColHints)32+ }33+ if width <= 0 {34+ width = maxRuneWidth(stateRows)35+ }36+37+ height := save.Height38+ if height <= 0 {39+ height = len(save.RowHints)40+ }41+ if height <= 0 {42+ height = len(stateRows)43+ }44+45+ if width <= 0 || height <= 0 {46+ return nil, nil47+ }48+49+ return &NonogramData{50+ Width: width,51+ Height: height,52+ RowHints: normalizeNonogramHintRows(save.RowHints, height),53+ ColHints: normalizeNonogramHintRows(save.ColHints, width),54+ Grid: normalizeNonogramStateGrid(stateRows, width, height),55+ }, nil56+}57+58+func splitNonogramStateRows(raw string) []string {59+ normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")60+ if normalized == "" {61+ return nil62+ }63+ return strings.Split(normalized, "\n")64+}65+66+func maxRuneWidth(rows []string) int {67+ maxWidth := 068+ for _, row := range rows {69+ if n := len([]rune(row)); n > maxWidth {70+ maxWidth = n71+ }72+ }73+ return maxWidth74+}75+76+func normalizeNonogramHintRows(src [][]int, size int) [][]int {77+ if size <= 0 {78+ return nil79+ }80+81+ normalized := make([][]int, size)82+ for i := range size {83+ if i >= len(src) {84+ normalized[i] = []int{0}85+ continue86+ }87+88+ filtered := make([]int, 0, len(src[i]))89+ for _, value := range src[i] {90+ if value > 0 {91+ filtered = append(filtered, value)92+ }93+ }94+ if len(filtered) == 0 {95+ filtered = []int{0}96+ }97+ normalized[i] = filtered98+ }99+100+ return normalized101+}102+103+func normalizeNonogramStateGrid(rows []string, width, height int) [][]string {104+ if width <= 0 || height <= 0 {105+ return nil106+ }107+108+ grid := make([][]string, height)109+ for y := range height {110+ grid[y] = make([]string, width)111+ for x := range width {112+ grid[y][x] = " "113+ }114+115+ if y >= len(rows) {116+ continue117+ }118+119+ runes := []rune(rows[y])120+ for x := 0; x < width && x < len(runes); x++ {121+ if runes[x] == ' ' {122+ continue123+ }124+ grid[y][x] = string(runes[x])125+ }126+ }127+128+ return grid129+}
A pdfexport/parse_nurikabe.go +74 -0
@@ -0,0 +1,74 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strconv"7+ "strings"8+)9+10+type nurikabeSave struct {11+ Width int `json:"width"`12+ Height int `json:"height"`13+ Clues string `json:"clues"`14+}15+16+func ParseNurikabePrintData(saveData []byte) (*NurikabeData, error) {17+ if len(strings.TrimSpace(string(saveData))) == 0 {18+ return nil, nil19+ }20+21+ var save nurikabeSave22+ if err := json.Unmarshal(saveData, &save); err != nil {23+ return nil, fmt.Errorf("decode nurikabe save: %w", err)24+ }25+26+ width := save.Width27+ height := save.Height28+ if width <= 0 || height <= 0 {29+ return nil, nil30+ }31+32+ clues, err := parseNurikabeClues(save.Clues, width, height)33+ if err != nil {34+ return nil, err35+ }36+37+ return &NurikabeData{38+ Width: width,39+ Height: height,40+ Clues: clues,41+ }, nil42+}43+44+func parseNurikabeClues(raw string, width, height int) ([][]int, error) {45+ if width <= 0 || height <= 0 {46+ return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height)47+ }48+49+ clues := make([][]int, height)50+ for y := range height {51+ clues[y] = make([]int, width)52+ }53+54+ rows := splitNormalizedLines(raw)55+ for y := 0; y < len(rows) && y < height; y++ {56+ parts := strings.Split(rows[y], ",")57+ for x := 0; x < len(parts) && x < width; x++ {58+ token := strings.TrimSpace(parts[x])59+ if token == "" {60+ continue61+ }62+ value, err := strconv.Atoi(token)63+ if err != nil {64+ return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", token, x, y, err)65+ }66+ if value < 0 {67+ return nil, fmt.Errorf("negative clue value %d at (%d,%d)", value, x, y)68+ }69+ clues[y][x] = value70+ }71+ }72+73+ return clues, nil74+}
A pdfexport/parse_rippleeffect.go +40 -0
A pdfexport/parse_shikaku.go +54 -0
A pdfexport/parse_sudoku.go +69 -0
A pdfexport/parse_takuzu.go +71 -0
@@ -0,0 +1,71 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+type takuzuSave struct {10+ Size int `json:"size"`11+ State string `json:"state"`12+ Provided string `json:"provided"`13+}14+15+func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) {16+ if len(strings.TrimSpace(string(saveData))) == 0 {17+ return nil, nil18+ }19+20+ var save takuzuSave21+ if err := json.Unmarshal(saveData, &save); err != nil {22+ return nil, fmt.Errorf("decode takuzu save: %w", err)23+ }24+25+ stateRows := splitNormalizedLines(save.State)26+ providedRows := splitNormalizedLines(save.Provided)27+28+ size := save.Size29+ if size <= 0 {30+ size = max(len(stateRows), len(providedRows))31+ }32+ if size <= 0 {33+ return nil, nil34+ }35+36+ givens := make([][]string, size)37+ for y := 0; y < size; y++ {38+ givens[y] = make([]string, size)39+40+ var stateRunes []rune41+ if y < len(stateRows) {42+ stateRunes = []rune(stateRows[y])43+ }44+45+ var providedRunes []rune46+ if y < len(providedRows) {47+ providedRunes = []rune(providedRows[y])48+ }49+50+ for x := 0; x < size; x++ {51+ if x >= len(providedRunes) || providedRunes[x] != '#' {52+ continue53+ }54+ if x >= len(stateRunes) {55+ continue56+ }57+ if stateRunes[x] != '0' && stateRunes[x] != '1' {58+ continue59+ }60+ givens[y][x] = string(stateRunes[x])61+ }62+ }63+64+ return &TakuzuData{65+ Size: size,66+ Givens: givens,67+ HorizontalRelations: make([][]string, size),68+ VerticalRelations: make([][]string, max(size-1, 0)),69+ GroupEveryTwo: true,70+ }, nil71+}
A pdfexport/parse_takuzuplus.go +94 -0
@@ -0,0 +1,94 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+)8+9+type takuzuPlusSave struct {10+ Size int `json:"size"`11+ State string `json:"state"`12+ Provided string `json:"provided"`13+ HorizontalRelations string `json:"horizontal_relations"`14+ VerticalRelations string `json:"vertical_relations"`15+}16+17+func ParseTakuzuPlusPrintData(saveData []byte) (*TakuzuData, error) {18+ if len(strings.TrimSpace(string(saveData))) == 0 {19+ return nil, nil20+ }21+22+ var save takuzuPlusSave23+ if err := json.Unmarshal(saveData, &save); err != nil {24+ return nil, fmt.Errorf("decode takuzu+ save: %w", err)25+ }26+27+ stateRows := splitNormalizedLines(save.State)28+ providedRows := splitNormalizedLines(save.Provided)29+ horizontalRows := splitNormalizedLines(save.HorizontalRelations)30+ verticalRows := splitNormalizedLines(save.VerticalRelations)31+32+ size := save.Size33+ if size <= 0 {34+ size = max(max(len(stateRows), len(providedRows)), max(len(horizontalRows), len(verticalRows)+1))35+ }36+ if size <= 0 {37+ return nil, nil38+ }39+40+ givens := make([][]string, size)41+ horizontal := make([][]string, size)42+ vertical := make([][]string, max(size-1, 0))43+ for y := 0; y < size; y++ {44+ givens[y] = make([]string, size)45+ horizontal[y] = make([]string, max(size-1, 0))46+47+ var stateRunes []rune48+ if y < len(stateRows) {49+ stateRunes = []rune(stateRows[y])50+ }51+ var providedRunes []rune52+ if y < len(providedRows) {53+ providedRunes = []rune(providedRows[y])54+ }55+ var relationRunes []rune56+ if y < len(horizontalRows) {57+ relationRunes = []rune(horizontalRows[y])58+ }59+60+ for x := 0; x < size; x++ {61+ if x < size-1 && x < len(relationRunes) && (relationRunes[x] == '=' || relationRunes[x] == 'x') {62+ horizontal[y][x] = string(relationRunes[x])63+ }64+ if x >= len(providedRunes) || providedRunes[x] != '#' || x >= len(stateRunes) {65+ continue66+ }67+ if stateRunes[x] != '0' && stateRunes[x] != '1' {68+ continue69+ }70+ givens[y][x] = string(stateRunes[x])71+ }72+ }73+74+ for y := 0; y < size-1; y++ {75+ vertical[y] = make([]string, size)76+ if y >= len(verticalRows) {77+ continue78+ }79+ relationRunes := []rune(verticalRows[y])80+ for x := 0; x < size && x < len(relationRunes); x++ {81+ if relationRunes[x] == '=' || relationRunes[x] == 'x' {82+ vertical[y][x] = string(relationRunes[x])83+ }84+ }85+ }86+87+ return &TakuzuData{88+ Size: size,89+ Givens: givens,90+ HorizontalRelations: horizontal,91+ VerticalRelations: vertical,92+ GroupEveryTwo: true,93+ }, nil94+}
A pdfexport/parse_wordsearch.go +84 -0
@@ -0,0 +1,84 @@
1+package pdfexport2+3+import (4+ "encoding/json"5+ "fmt"6+ "strings"7+ "unicode"8+)9+10+type wordSearchSave struct {11+ Width int `json:"width"`12+ Height int `json:"height"`13+ Grid string `json:"grid"`14+ Words []wordSearchWord `json:"words"`15+}16+17+type wordSearchWord struct {18+ Text string `json:"text"`19+}20+21+func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {22+ if len(strings.TrimSpace(string(saveData))) == 0 {23+ return nil, nil24+ }25+26+ var save wordSearchSave27+ if err := json.Unmarshal(saveData, &save); err != nil {28+ return nil, fmt.Errorf("decode word search save: %w", err)29+ }30+31+ rows := strings.Split(strings.ReplaceAll(strings.ReplaceAll(save.Grid, "\r\n", "\n"), "\r", "\n"), "\n")32+ if len(rows) == 1 && rows[0] == "" {33+ rows = nil34+ }35+36+ width := save.Width37+ for _, row := range rows {38+ if n := len([]rune(row)); n > width {39+ width = n40+ }41+ }42+43+ height := save.Height44+ height = max(height, len(rows))45+ if width <= 0 || height <= 0 {46+ return nil, nil47+ }48+49+ grid := make([][]string, height)50+ for y := 0; y < height; y++ {51+ grid[y] = make([]string, width)52+ runes := []rune{}53+ if y < len(rows) {54+ runes = []rune(rows[y])55+ }56+ for x := 0; x < width; x++ {57+ grid[y][x] = " "58+ if x >= len(runes) {59+ continue60+ }61+ r := runes[x]62+ if unicode.IsSpace(r) {63+ continue64+ }65+ grid[y][x] = string(unicode.ToUpper(r))66+ }67+ }68+69+ words := make([]string, 0, len(save.Words))70+ for _, word := range save.Words {71+ text := strings.ToUpper(strings.TrimSpace(word.Text))72+ if text == "" {73+ continue74+ }75+ words = append(words, text)76+ }77+78+ return &WordSearchData{79+ Width: width,80+ Height: height,81+ Grid: grid,82+ Words: words,83+ }, nil84+}
D pdfexport/printdata.go +0 -809
@@ -1,809 +0,0 @@
1-package pdfexport2-3-import (4- "encoding/json"5- "fmt"6- "strconv"7- "strings"8- "unicode"9- "unicode/utf8"10-)11-12-type nurikabeSave struct {13- Width int `json:"width"`14- Height int `json:"height"`15- Clues string `json:"clues"`16-}17-18-type nonogramSave struct {19- State string `json:"state"`20- Width int `json:"width"`21- Height int `json:"height"`22- RowHints [][]int `json:"row-hints"`23- ColHints [][]int `json:"col-hints"`24-}25-26-type hashiSave struct {27- Width int `json:"width"`28- Height int `json:"height"`29- Islands []hashiIsland `json:"islands"`30-}31-32-type hashiIsland struct {33- X int `json:"x"`34- Y int `json:"y"`35- Required int `json:"required"`36-}37-38-type shikakuSave struct {39- Width int `json:"width"`40- Height int `json:"height"`41- Clues []shikakuClue `json:"clues"`42-}43-44-type shikakuClue struct {45- X int `json:"x"`46- Y int `json:"y"`47- Value int `json:"value"`48-}49-50-type hitoriSave struct {51- Size int `json:"size"`52- Numbers string `json:"numbers"`53-}54-55-type takuzuSave struct {56- Size int `json:"size"`57- State string `json:"state"`58- Provided string `json:"provided"`59-}60-61-type takuzuPlusSave struct {62- Size int `json:"size"`63- State string `json:"state"`64- Provided string `json:"provided"`65- HorizontalRelations string `json:"horizontal_relations"`66- VerticalRelations string `json:"vertical_relations"`67-}68-69-type sudokuSave struct {70- Provided []sudokuCell `json:"provided"`71-}72-73-type fillominoSave struct {74- Width int `json:"width"`75- Height int `json:"height"`76- State string `json:"state"`77- Provided string `json:"provided"`78-}79-80-type rippleEffectSave struct {81- Width int `json:"width"`82- Height int `json:"height"`83- Givens string `json:"givens"`84- Cages []RippleEffectCage `json:"cages"`85-}86-87-type sudokuCell struct {88- X int `json:"x"`89- Y int `json:"y"`90- V int `json:"v"`91-}92-93-type wordSearchSave struct {94- Width int `json:"width"`95- Height int `json:"height"`96- Grid string `json:"grid"`97- Words []wordSearchWord `json:"words"`98-}99-100-type wordSearchWord struct {101- Text string `json:"text"`102-}103-104-func ParseNonogramPrintData(saveData []byte) (*NonogramData, error) {105- if len(strings.TrimSpace(string(saveData))) == 0 {106- return nil, nil107- }108-109- var save nonogramSave110- if err := json.Unmarshal(saveData, &save); err != nil {111- return nil, fmt.Errorf("decode nonogram save: %w", err)112- }113-114- stateRows := splitNonogramStateRows(save.State)115-116- width := save.Width117- if width <= 0 {118- width = len(save.ColHints)119- }120- if width <= 0 {121- width = maxRuneWidth(stateRows)122- }123-124- height := save.Height125- if height <= 0 {126- height = len(save.RowHints)127- }128- if height <= 0 {129- height = len(stateRows)130- }131-132- if width <= 0 || height <= 0 {133- return nil, nil134- }135-136- return &NonogramData{137- Width: width,138- Height: height,139- RowHints: normalizeNonogramHintRows(save.RowHints, height),140- ColHints: normalizeNonogramHintRows(save.ColHints, width),141- Grid: normalizeNonogramStateGrid(stateRows, width, height),142- }, nil143-}144-145-func ParseNurikabePrintData(saveData []byte) (*NurikabeData, error) {146- if len(strings.TrimSpace(string(saveData))) == 0 {147- return nil, nil148- }149-150- var save nurikabeSave151- if err := json.Unmarshal(saveData, &save); err != nil {152- return nil, fmt.Errorf("decode nurikabe save: %w", err)153- }154-155- width := save.Width156- height := save.Height157- if width <= 0 || height <= 0 {158- return nil, nil159- }160-161- clues, err := parseNurikabeClues(save.Clues, width, height)162- if err != nil {163- return nil, err164- }165-166- return &NurikabeData{167- Width: width,168- Height: height,169- Clues: clues,170- }, nil171-}172-173-func ParseShikakuPrintData(saveData []byte) (*ShikakuData, error) {174- if len(strings.TrimSpace(string(saveData))) == 0 {175- return nil, nil176- }177-178- var save shikakuSave179- if err := json.Unmarshal(saveData, &save); err != nil {180- return nil, fmt.Errorf("decode shikaku save: %w", err)181- }182- if save.Width <= 0 || save.Height <= 0 {183- return nil, nil184- }185-186- clues := make([][]int, save.Height)187- for y := 0; y < save.Height; y++ {188- clues[y] = make([]int, save.Width)189- }190-191- for _, clue := range save.Clues {192- if clue.X < 0 || clue.X >= save.Width || clue.Y < 0 || clue.Y >= save.Height {193- continue194- }195- if clue.Value <= 0 {196- continue197- }198- clues[clue.Y][clue.X] = clue.Value199- }200-201- return &ShikakuData{202- Width: save.Width,203- Height: save.Height,204- Clues: clues,205- }, nil206-}207-208-func ParseHashiPrintData(saveData []byte) (*HashiData, error) {209- if len(strings.TrimSpace(string(saveData))) == 0 {210- return nil, nil211- }212-213- var save hashiSave214- if err := json.Unmarshal(saveData, &save); err != nil {215- return nil, fmt.Errorf("decode hashiwokakero save: %w", err)216- }217- if save.Width <= 0 || save.Height <= 0 {218- return nil, nil219- }220-221- islands := make([]HashiIsland, 0, len(save.Islands))222- for _, island := range save.Islands {223- if island.X < 0 || island.X >= save.Width || island.Y < 0 || island.Y >= save.Height {224- continue225- }226- if island.Required <= 0 {227- continue228- }229- islands = append(islands, HashiIsland(island))230- }231-232- return &HashiData{233- Width: save.Width,234- Height: save.Height,235- Islands: islands,236- }, nil237-}238-239-func ParseHitoriPrintData(saveData []byte) (*HitoriData, error) {240- if len(strings.TrimSpace(string(saveData))) == 0 {241- return nil, nil242- }243-244- var save hitoriSave245- if err := json.Unmarshal(saveData, &save); err != nil {246- return nil, fmt.Errorf("decode hitori save: %w", err)247- }248-249- rows := splitNormalizedLines(save.Numbers)250- size := save.Size251- if size <= 0 {252- size = len(rows)253- }254- if size <= 0 {255- return nil, nil256- }257-258- numbers := make([][]string, size)259- for y := 0; y < size; y++ {260- numbers[y] = make([]string, size)261- if y >= len(rows) {262- continue263- }264-265- rowValues := parseHitoriRowValues(rows[y])266- for x := 0; x < size && x < len(rowValues); x++ {267- numbers[y][x] = rowValues[x]268- }269- }270-271- return &HitoriData{272- Size: size,273- Numbers: numbers,274- }, nil275-}276-277-func ParseTakuzuPrintData(saveData []byte) (*TakuzuData, error) {278- if len(strings.TrimSpace(string(saveData))) == 0 {279- return nil, nil280- }281-282- var save takuzuSave283- if err := json.Unmarshal(saveData, &save); err != nil {284- return nil, fmt.Errorf("decode takuzu save: %w", err)285- }286-287- stateRows := splitNormalizedLines(save.State)288- providedRows := splitNormalizedLines(save.Provided)289-290- size := save.Size291- if size <= 0 {292- size = max(len(stateRows), len(providedRows))293- }294- if size <= 0 {295- return nil, nil296- }297-298- givens := make([][]string, size)299- for y := 0; y < size; y++ {300- givens[y] = make([]string, size)301-302- var stateRunes []rune303- if y < len(stateRows) {304- stateRunes = []rune(stateRows[y])305- }306-307- var providedRunes []rune308- if y < len(providedRows) {309- providedRunes = []rune(providedRows[y])310- }311-312- for x := 0; x < size; x++ {313- if x >= len(providedRunes) || providedRunes[x] != '#' {314- continue315- }316- if x >= len(stateRunes) {317- continue318- }319- if stateRunes[x] != '0' && stateRunes[x] != '1' {320- continue321- }322- givens[y][x] = string(stateRunes[x])323- }324- }325-326- return &TakuzuData{327- Size: size,328- Givens: givens,329- HorizontalRelations: make([][]string, size),330- VerticalRelations: make([][]string, max(size-1, 0)),331- GroupEveryTwo: true,332- }, nil333-}334-335-func ParseTakuzuPlusPrintData(saveData []byte) (*TakuzuData, error) {336- if len(strings.TrimSpace(string(saveData))) == 0 {337- return nil, nil338- }339-340- var save takuzuPlusSave341- if err := json.Unmarshal(saveData, &save); err != nil {342- return nil, fmt.Errorf("decode takuzu+ save: %w", err)343- }344-345- stateRows := splitNormalizedLines(save.State)346- providedRows := splitNormalizedLines(save.Provided)347- horizontalRows := splitNormalizedLines(save.HorizontalRelations)348- verticalRows := splitNormalizedLines(save.VerticalRelations)349-350- size := save.Size351- if size <= 0 {352- size = max(max(len(stateRows), len(providedRows)), max(len(horizontalRows), len(verticalRows)+1))353- }354- if size <= 0 {355- return nil, nil356- }357-358- givens := make([][]string, size)359- horizontal := make([][]string, size)360- vertical := make([][]string, max(size-1, 0))361- for y := 0; y < size; y++ {362- givens[y] = make([]string, size)363- horizontal[y] = make([]string, max(size-1, 0))364-365- var stateRunes []rune366- if y < len(stateRows) {367- stateRunes = []rune(stateRows[y])368- }369- var providedRunes []rune370- if y < len(providedRows) {371- providedRunes = []rune(providedRows[y])372- }373- var relationRunes []rune374- if y < len(horizontalRows) {375- relationRunes = []rune(horizontalRows[y])376- }377-378- for x := 0; x < size; x++ {379- if x < size-1 && x < len(relationRunes) && (relationRunes[x] == '=' || relationRunes[x] == 'x') {380- horizontal[y][x] = string(relationRunes[x])381- }382- if x >= len(providedRunes) || providedRunes[x] != '#' || x >= len(stateRunes) {383- continue384- }385- if stateRunes[x] != '0' && stateRunes[x] != '1' {386- continue387- }388- givens[y][x] = string(stateRunes[x])389- }390- }391-392- for y := 0; y < size-1; y++ {393- vertical[y] = make([]string, size)394- if y >= len(verticalRows) {395- continue396- }397- relationRunes := []rune(verticalRows[y])398- for x := 0; x < size && x < len(relationRunes); x++ {399- if relationRunes[x] == '=' || relationRunes[x] == 'x' {400- vertical[y][x] = string(relationRunes[x])401- }402- }403- }404-405- return &TakuzuData{406- Size: size,407- Givens: givens,408- HorizontalRelations: horizontal,409- VerticalRelations: vertical,410- GroupEveryTwo: true,411- }, nil412-}413-414-func ParseSudokuPrintData(saveData []byte) (*SudokuData, error) {415- if len(strings.TrimSpace(string(saveData))) == 0 {416- return nil, nil417- }418-419- var save sudokuSave420- if err := json.Unmarshal(saveData, &save); err != nil {421- return nil, fmt.Errorf("decode sudoku save: %w", err)422- }423-424- var givens [9][9]int425- for _, cell := range save.Provided {426- if !isSudokuCellInBounds(cell.X, cell.Y) {427- continue428- }429- if cell.V < 1 || cell.V > 9 {430- continue431- }432- givens[cell.Y][cell.X] = cell.V433- }434-435- return &SudokuData{Givens: givens}, nil436-}437-438-func ParseSudokuRGBPrintData(saveData []byte) (*SudokuData, error) {439- if len(strings.TrimSpace(string(saveData))) == 0 {440- return nil, nil441- }442-443- var save sudokuSave444- if err := json.Unmarshal(saveData, &save); err != nil {445- return nil, fmt.Errorf("decode sudoku rgb save: %w", err)446- }447-448- var givens [9][9]int449- for _, cell := range save.Provided {450- if !isSudokuCellInBounds(cell.X, cell.Y) {451- continue452- }453- if cell.V < 1 || cell.V > 3 {454- continue455- }456- givens[cell.Y][cell.X] = cell.V457- }458-459- return &SudokuData{Givens: givens}, nil460-}461-462-func ParseFillominoPrintData(saveData []byte) (*FillominoData, error) {463- if len(strings.TrimSpace(string(saveData))) == 0 {464- return nil, nil465- }466-467- var save fillominoSave468- if err := json.Unmarshal(saveData, &save); err != nil {469- return nil, fmt.Errorf("decode fillomino save: %w", err)470- }471- if save.Width <= 0 || save.Height <= 0 {472- return nil, nil473- }474-475- state, err := parseNumberGrid(save.State, save.Width, save.Height)476- if err != nil {477- return nil, err478- }479- provided := parseProvidedMask(save.Provided, save.Width, save.Height)480-481- givens := make([][]int, save.Height)482- for y := 0; y < save.Height; y++ {483- givens[y] = make([]int, save.Width)484- for x := 0; x < save.Width; x++ {485- if provided[y][x] {486- givens[y][x] = state[y][x]487- }488- }489- }490-491- return &FillominoData{492- Width: save.Width,493- Height: save.Height,494- Givens: givens,495- }, nil496-}497-498-func ParseRippleEffectPrintData(saveData []byte) (*RippleEffectData, error) {499- if len(strings.TrimSpace(string(saveData))) == 0 {500- return nil, nil501- }502-503- var save rippleEffectSave504- if err := json.Unmarshal(saveData, &save); err != nil {505- return nil, fmt.Errorf("decode ripple effect save: %w", err)506- }507- if save.Width <= 0 || save.Height <= 0 {508- return nil, nil509- }510-511- givens, err := parseNumberGrid(save.Givens, save.Width, save.Height)512- if err != nil {513- return nil, err514- }515-516- return &RippleEffectData{517- Width: save.Width,518- Height: save.Height,519- Givens: givens,520- Cages: append([]RippleEffectCage(nil), save.Cages...),521- }, nil522-}523-524-func ParseWordSearchPrintData(saveData []byte) (*WordSearchData, error) {525- if len(strings.TrimSpace(string(saveData))) == 0 {526- return nil, nil527- }528-529- var save wordSearchSave530- if err := json.Unmarshal(saveData, &save); err != nil {531- return nil, fmt.Errorf("decode word search save: %w", err)532- }533-534- rows := strings.Split(strings.ReplaceAll(strings.ReplaceAll(save.Grid, "\r\n", "\n"), "\r", "\n"), "\n")535- if len(rows) == 1 && rows[0] == "" {536- rows = nil537- }538-539- width := save.Width540- for _, row := range rows {541- if n := len([]rune(row)); n > width {542- width = n543- }544- }545-546- height := save.Height547- height = max(height, len(rows))548- if width <= 0 || height <= 0 {549- return nil, nil550- }551-552- grid := make([][]string, height)553- for y := 0; y < height; y++ {554- grid[y] = make([]string, width)555- runes := []rune{}556- if y < len(rows) {557- runes = []rune(rows[y])558- }559- for x := 0; x < width; x++ {560- grid[y][x] = " "561- if x >= len(runes) {562- continue563- }564- r := runes[x]565- if unicode.IsSpace(r) {566- continue567- }568- grid[y][x] = string(unicode.ToUpper(r))569- }570- }571-572- words := make([]string, 0, len(save.Words))573- for _, word := range save.Words {574- text := strings.ToUpper(strings.TrimSpace(word.Text))575- if text == "" {576- continue577- }578- words = append(words, text)579- }580-581- return &WordSearchData{582- Width: width,583- Height: height,584- Grid: grid,585- Words: words,586- }, nil587-}588-589-func parseNumberGrid(encoded string, width, height int) ([][]int, error) {590- rows := strings.Split(strings.TrimSpace(encoded), "\n")591- grid := make([][]int, height)592- for y := range height {593- grid[y] = make([]int, width)594- if y >= len(rows) {595- continue596- }597-598- fields := strings.Fields(rows[y])599- if len(fields) == 0 {600- fields = splitCompactFields(rows[y])601- }602- if len(fields) != width {603- return nil, fmt.Errorf("decode number grid: invalid row width")604- }605- for x := range width {606- if fields[x] == "." {607- continue608- }609- value, err := strconv.Atoi(fields[x])610- if err != nil {611- return nil, fmt.Errorf("decode number grid: %w", err)612- }613- grid[y][x] = value614- }615- }616- return grid, nil617-}618-619-func parseProvidedMask(encoded string, width, height int) [][]bool {620- rows := strings.Split(encoded, "\n")621- mask := make([][]bool, height)622- for y := range height {623- mask[y] = make([]bool, width)624- if y >= len(rows) {625- continue626- }627- for x := 0; x < width && x < len(rows[y]); x++ {628- mask[y][x] = rows[y][x] == '#'629- }630- }631- return mask632-}633-634-func splitCompactFields(row string) []string {635- fields := make([]string, 0, len(row))636- for _, r := range row {637- fields = append(fields, string(r))638- }639- return fields640-}641-642-func isSudokuCellInBounds(x, y int) bool {643- return x >= 0 && x < 9 && y >= 0 && y < 9644-}645-646-func splitNormalizedLines(raw string) []string {647- normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")648- if strings.TrimSpace(normalized) == "" {649- return nil650- }651- return strings.Split(normalized, "\n")652-}653-654-func parseHitoriRowValues(row string) []string {655- row = strings.TrimSpace(row)656- if row == "" {657- return nil658- }659-660- if strings.Contains(row, " ") || strings.Contains(row, ",") {661- fields := strings.Fields(strings.ReplaceAll(row, ",", " "))662- if len(fields) > 1 {663- values := make([]string, len(fields))664- for i, field := range fields {665- values[i] = normalizeHitoriToken(field)666- }667- return values668- }669- }670-671- runes := []rune(row)672- values := make([]string, len(runes))673- for i, r := range runes {674- values[i] = normalizeHitoriRune(r)675- }676- return values677-}678-679-func normalizeHitoriToken(token string) string {680- token = strings.TrimSpace(token)681- if token == "" || token == "." {682- return ""683- }684- if utf8.RuneCountInString(token) == 1 {685- r, _ := utf8.DecodeRuneInString(token)686- return normalizeHitoriRune(r)687- }688- return token689-}690-691-func normalizeHitoriRune(r rune) string {692- switch {693- case r == '.':694- return ""695- case r >= '0' && r <= '9':696- return string(r)697- default:698- value := int(r - '0')699- if value >= 10 && value <= 35 {700- return fmt.Sprintf("%d", value)701- }702- return string(r)703- }704-}705-706-func parseNurikabeClues(raw string, width, height int) ([][]int, error) {707- if width <= 0 || height <= 0 {708- return nil, fmt.Errorf("invalid clue dimensions: %dx%d", width, height)709- }710-711- clues := make([][]int, height)712- for y := range height {713- clues[y] = make([]int, width)714- }715-716- rows := splitNormalizedLines(raw)717- for y := 0; y < len(rows) && y < height; y++ {718- parts := strings.Split(rows[y], ",")719- for x := 0; x < len(parts) && x < width; x++ {720- token := strings.TrimSpace(parts[x])721- if token == "" {722- continue723- }724- value, err := strconv.Atoi(token)725- if err != nil {726- return nil, fmt.Errorf("invalid clue value %q at (%d,%d): %w", token, x, y, err)727- }728- if value < 0 {729- return nil, fmt.Errorf("negative clue value %d at (%d,%d)", value, x, y)730- }731- clues[y][x] = value732- }733- }734-735- return clues, nil736-}737-738-func splitNonogramStateRows(raw string) []string {739- normalized := strings.ReplaceAll(strings.ReplaceAll(raw, "\r\n", "\n"), "\r", "\n")740- if normalized == "" {741- return nil742- }743- return strings.Split(normalized, "\n")744-}745-746-func maxRuneWidth(rows []string) int {747- maxWidth := 0748- for _, row := range rows {749- if n := len([]rune(row)); n > maxWidth {750- maxWidth = n751- }752- }753- return maxWidth754-}755-756-func normalizeNonogramHintRows(src [][]int, size int) [][]int {757- if size <= 0 {758- return nil759- }760-761- normalized := make([][]int, size)762- for i := range size {763- if i >= len(src) {764- normalized[i] = []int{0}765- continue766- }767-768- filtered := make([]int, 0, len(src[i]))769- for _, value := range src[i] {770- if value > 0 {771- filtered = append(filtered, value)772- }773- }774- if len(filtered) == 0 {775- filtered = []int{0}776- }777- normalized[i] = filtered778- }779-780- return normalized781-}782-783-func normalizeNonogramStateGrid(rows []string, width, height int) [][]string {784- if width <= 0 || height <= 0 {785- return nil786- }787-788- grid := make([][]string, height)789- for y := range height {790- grid[y] = make([]string, width)791- for x := range width {792- grid[y][x] = " "793- }794-795- if y >= len(rows) {796- continue797- }798-799- runes := []rune(rows[y])800- for x := 0; x < width && x < len(runes); x++ {801- if runes[x] == ' ' {802- continue803- }804- grid[y][x] = string(runes[x])805- }806- }807-808- return grid809-}
M pdfexport/render_cover.go +16 -31
@@ -23,11 +23,12 @@ func splitCoverTextLines(pdf *fpdf.Fpdf, text string, maxW float64) []string {
2323 return lines2424 }2525 26-func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxLines int) []string {26+func splitClampedTextLines(pdf *fpdf.Fpdf, text string, maxW float64, maxLines int) []string {2727 if maxLines < 1 {2828 maxLines = 12929 }30- lines := splitCoverTextLines(pdf, subtitle, maxW)30+31+ lines := splitCoverTextLines(pdf, text, maxW)3132 if len(lines) <= maxLines {3233 return lines3334 }
@@ -38,6 +39,10 @@ func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxL
3839 return out3940 }4041 42+func splitCoverSubtitleLines(pdf *fpdf.Fpdf, subtitle string, maxW float64, maxLines int) []string {43+ return splitClampedTextLines(pdf, subtitle, maxW, maxLines)44+}45+4146 func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RGB) {4247 ink := RGB{R: 8, G: 8, B: 8}4348
@@ -50,9 +55,6 @@ func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RG
5055 frameInset := 7.55156 drawCoverFrame(pdf, frameInset, pageW, pageH, ink)5257 53- scene := rectMM{x: frameInset + 4.0, y: frameInset + 10.0, w: pageW - (frameInset+4.0)*2, h: 132.0}54- drawCoverArtwork(pdf, scene, cfg.ShuffleSeed, coverColor, ink)55-5658 subtitle := strings.TrimSpace(cfg.CoverSubtitle)5759 if subtitle == "" {5860 subtitle = "PuzzleTea Collection"
@@ -76,7 +78,7 @@ func renderCoverPage(pdf *fpdf.Fpdf, _ []Puzzle, cfg RenderConfig, coverColor RG
7678 pdf.SetFont(coverFontFamily, "", fontSize)7779 titleLines := splitCoverSubtitleLines(pdf, subtitle, labelW, 2)7880 lineH := fontSize * 0.4579- y := scene.y + scene.h + 8.581+ y := frameInset + 12.08082 for _, line := range titleLines {8183 pdf.SetXY(frameInset+6.0, y)8284 pdf.CellFormat(labelW, lineH, line, "", 0, "L", false, 0, "")
@@ -98,18 +100,6 @@ func drawCoverFrame(pdf *fpdf.Fpdf, inset, pageW, pageH float64, ink RGB) {
98100 pdf.Rect(inner, inner, pageW-2*inner, pageH-2*inner, "D")99101 }100102 101-func drawCoverArtwork(pdf *fpdf.Fpdf, scene rectMM, seed string, bg, ink RGB) {102- drawCoverArtworkImage(pdf, scene, seed, "front", bg)103-104- pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))105- pdf.SetLineWidth(0.40)106- pdf.Rect(scene.x, scene.y, scene.w, scene.h, "D")107-108- pdf.SetLineWidth(0.20)109- inset := 1.8110- pdf.Rect(scene.x+inset, scene.y+inset, scene.w-2*inset, scene.h-2*inset, "D")111-}112-113103 func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) {114104 ink := RGB{R: 8, G: 8, B: 8}115105
@@ -121,9 +111,6 @@ func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) {
121111 frameInset := 7.5122112 drawCoverFrame(pdf, frameInset, pageW, pageH, ink)123113 124- motif := rectMM{x: frameInset + 5.5, y: frameInset + 14.0, w: pageW - 2*(frameInset+5.5), h: 96.0}125- drawBackMotif(pdf, motif, cfg.ShuffleSeed, coverColor, ink)126-127114 labelW := pageW - 2*(frameInset+6.0)128115 pdf.SetTextColor(int(ink.R), int(ink.G), int(ink.B))129116 pdf.SetFont(sansFontFamily, "B", 8.4)
@@ -131,18 +118,16 @@ func renderBackCoverPage(pdf *fpdf.Fpdf, cfg RenderConfig, coverColor RGB) {
131118 pdf.CellFormat(labelW, 4.2, "PuzzleTea", "", 0, "L", false, 0, "")132119 133120 pdf.SetFont(sansFontFamily, "", 8.0)134- pdf.SetXY(frameInset+6.0, pageH-frameInset-17.0)135- pdf.CellFormat(labelW, 4.2, cfg.AdvertText, "", 0, "L", false, 0, "")121+ advertLines := splitCoverTextLines(pdf, cfg.AdvertText, labelW)122+ advertLineH := 4.2123+ advertY := pageH - frameInset - 17.0 - float64(len(advertLines)-1)*advertLineH124+ for _, line := range advertLines {125+ pdf.SetXY(frameInset+6.0, advertY)126+ pdf.CellFormat(labelW, advertLineH, line, "", 0, "L", false, 0, "")127+ advertY += advertLineH128+ }136129 137130 pdf.SetFont(sansFontFamily, "B", 8.2)138131 pdf.SetXY(frameInset+6.0, pageH-frameInset-10.5)139132 pdf.CellFormat(labelW, 4.2, fmt.Sprintf("VOL. %02d", cfg.VolumeNumber), "", 0, "L", false, 0, "")140133 }141-142-func drawBackMotif(pdf *fpdf.Fpdf, scene rectMM, seed string, bg, ink RGB) {143- drawCoverArtworkImage(pdf, scene, seed, "back", bg)144-145- pdf.SetDrawColor(int(ink.R), int(ink.G), int(ink.B))146- pdf.SetLineWidth(0.34)147- pdf.Rect(scene.x, scene.y, scene.w, scene.h, "D")148-}
M pdfexport/render_cover_test.go +21 -0
M pdfexport/render_title.go +110 -178
@@ -2,10 +2,9 @@ package pdfexport
22 33 import (44 "fmt"5- "math"65 "sort"7- "strconv"86 "strings"7+ "time"98 109 "codeberg.org/go-pdf/fpdf"1110 )
@@ -15,6 +14,7 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
1514 pageW, pageH := pdf.GetPageSize()1615 margin := 12.01716 contentWidth := pageW - 2*margin17+ categoryTotals := summarizeCategoryTotals(puzzles)1818 1919 pdf.SetTextColor(20, 20, 20)2020 pdf.SetFont(sansFontFamily, "B", 22)
@@ -26,114 +26,61 @@ func renderTitlePage(pdf *fpdf.Fpdf, docs []PackDocument, puzzles []Puzzle, cfg
2626 pdf.SetXY(0, 35)2727 pdf.CellFormat(pageW, 8, cfg.CoverSubtitle, "", 0, "C", false, 0, "")2828 29- pdf.SetFont(sansFontFamily, "", 11)30- pdf.SetTextColor(70, 70, 70)31- pdf.SetXY(0, 44)32- pdf.CellFormat(pageW, 6, "PuzzleTea Puzzle Pack", "", 0, "C", false, 0, "")33-34- versionLine := fmt.Sprintf("PuzzleTea Version: %s", strings.Join(summarizeVersions(docs), ", "))35- pdf.SetFont(sansFontFamily, "", 10)36- wrappedVersions := pdf.SplitLines([]byte(versionLine), contentWidth)37- if len(wrappedVersions) == 0 {38- wrappedVersions = [][]byte{[]byte(versionLine)}39- }40-41- headerLineH := 4.842- headerGap := 1.243- headerStartY := 54.844- metaY := 56.029+ bodyY := 49.04530 if header := strings.TrimSpace(cfg.HeaderText); header != "" {4631 pdf.SetFont(sansFontFamily, "", 9.2)4732 pdf.SetTextColor(74, 74, 74)48- wrappedHeader := pdf.SplitLines([]byte(header), contentWidth)33+ wrappedHeader := pdf.SplitLines([]byte(header), contentWidth-20)4934 if len(wrappedHeader) == 0 {5035 wrappedHeader = [][]byte{[]byte(header)}5136 }52-53- metaY = headerStartY + float64(len(wrappedHeader))*headerLineH + headerGap54- sourceStartY := titlePageSourceTableStartY(metaY, len(wrappedVersions))55- sourceMaxY := pageH - 4556- if spare := titlePageSourceTableWhitespace(sourceMaxY, sourceStartY, len(docs)); spare > 0 {57- headerGap += spare58- }59-60- headerY := headerStartY6137 for _, line := range wrappedHeader {62- pdf.SetXY(margin, headerY)63- pdf.CellFormat(contentWidth, headerLineH, string(line), "", 0, "C", false, 0, "")64- headerY += headerLineH38+ pdf.SetXY(margin, bodyY)39+ pdf.CellFormat(contentWidth, 4.6, string(line), "", 0, "C", false, 0, "")40+ bodyY += 4.66541 }66- metaY = headerY + headerGap42+ bodyY += 2.06743 }6844 69- pdf.SetTextColor(25, 25, 25)70- pdf.SetFont(sansFontFamily, "", 10)71- pdf.SetXY(margin, metaY)72- pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Generated: %s", cfg.GeneratedAt.Format("January 2, 2006")), "", 0, "L", false, 0, "")73- metaY += 674- for _, line := range wrappedVersions {75- pdf.SetXY(margin, metaY)76- pdf.CellFormat(contentWidth, 5.2, string(line), "", 0, "L", false, 0, "")77- metaY += 5.245+ introLines := splitCoverTextLines(pdf, cfg.AdvertText, contentWidth)46+ if len(introLines) > 0 {47+ pdf.SetTextColor(58, 58, 58)48+ pdf.SetFont(sansFontFamily, "", 9.3)49+ for _, line := range introLines {50+ pdf.SetXY(margin, bodyY)51+ pdf.CellFormat(contentWidth, 4.8, line, "", 0, "C", false, 0, "")52+ bodyY += 4.853+ }54+ bodyY += 3.87855 }79- metaY += 0.88056 81- pdf.SetXY(margin, metaY)82- pdf.CellFormat(contentWidth, 6, fmt.Sprintf("Puzzles: %d", len(puzzles)), "", 0, "L", false, 0, "")83- metaY += 684- metaY += 1.857+ pdf.SetTextColor(40, 40, 40)58+ pdf.SetFont(sansFontFamily, "B", 9.6)59+ pdf.SetXY(margin, bodyY)60+ pdf.CellFormat(contentWidth, 5.4, fmt.Sprintf("%d puzzles across %d categories", len(puzzles), len(categoryTotals)), "", 0, "C", false, 0, "")61+ bodyY += 7.68562 8663 pdf.SetFont(sansFontFamily, "B", 10)8764 pdf.SetTextColor(45, 45, 45)88- pdf.SetXY(margin, metaY)89- pdf.CellFormat(contentWidth, 6, "Source Exports", "", 0, "L", false, 0, "")90- metaY += 765+ pdf.SetXY(margin, bodyY)66+ pdf.CellFormat(contentWidth, 6, "Inside This Volume", "", 0, "C", false, 0, "")67+ bodyY += 7.09168 92- renderSourceExportsTable(pdf, docs, margin, metaY, contentWidth, pageH-45)69+ renderCategoryOverview(pdf, categoryTotals, margin, bodyY, contentWidth, pageH-32)9370 9471 pdf.SetTextColor(50, 50, 50)9572 pdf.SetFont(sansFontFamily, "B", 12)96- pdf.SetXY(margin, pageH-30)73+ footerTitleY := pageH - 31.074+ pdf.SetXY(margin, footerTitleY)9775 pdf.CellFormat(contentWidth, 7, "Made with PuzzleTea", "", 0, "C", false, 0, "")9876 99- pdf.SetFont(sansFontFamily, "", 10)100- pdf.SetXY(margin, pageH-22)101- pdf.CellFormat(contentWidth, 6, cfg.AdvertText, "", 0, "C", false, 0, "")102-}103-104-func titlePageSourceTableStartY(metaY float64, versionLineCount int) float64 {105- y := metaY106- y += 6107- y += float64(max(versionLineCount, 1)) * 5.2108- y += 0.8109- y += 6110- y += 1.8111- y += 7112- return y113-}114-115-func titlePageSourceTableWhitespace(maxY, sourceStartY float64, docCount int) float64 {116- const (117- headerHeight = 5.2118- rowHeight = 4.8119- )120-121- availableRowsHeight := maxY - sourceStartY - headerHeight122- if availableRowsHeight < rowHeight {123- return 0124- }125-126- maxRows := int(math.Floor(availableRowsHeight / rowHeight))127- if maxRows < 1 {128- return 077+ colophon := titlePageColophon(docs, cfg.GeneratedAt)78+ if colophon != "" {79+ pdf.SetFont(sansFontFamily, "", 7.8)80+ pdf.SetTextColor(112, 112, 112)81+ pdf.SetXY(margin, footerTitleY+8.0)82+ pdf.CellFormat(contentWidth, 4.0, colophon, "", 0, "C", false, 0, "")12983 }130-131- rowCount := min(docCount, maxRows)132- used := headerHeight + float64(rowCount)*rowHeight133- if spare := maxY - sourceStartY - used; spare > 0 {134- return spare135- }136- return 013784 }13885 13986 func summarizeVersions(docs []PackDocument) []string {
@@ -167,116 +114,101 @@ func defaultTitle(docs []PackDocument) string {
167114 return "PuzzleTea Mixed Puzzle Pack"168115 }169116 170-func renderSourceExportsTable(171- pdf *fpdf.Fpdf,172- docs []PackDocument,173- x, y, width, maxY float64,174-) float64 {175- if width <= 0 || y >= maxY {176- return y177- }117+type namedCount struct {118+ Name string119+ Count int120+}178121 179- headers := []string{"Source", "Category", "Mode", "Count", "Seed"}180- columnRatios := []float64{0.33, 0.20, 0.22, 0.10, 0.15}181- columnWidths := make([]float64, len(columnRatios))182- usedWidth := 0.0183- for i := 0; i < len(columnRatios)-1; i++ {184- columnWidths[i] = width * columnRatios[i]185- usedWidth += columnWidths[i]122+func summarizeCategoryTotals(puzzles []Puzzle) []namedCount {123+ m := make(map[string]*namedCount)124+ for _, puzzle := range puzzles {125+ category := strings.TrimSpace(puzzle.Category)126+ if category == "" {127+ category = "Unknown"128+ }129+ key := normalizeToken(category)130+ if entry, ok := m[key]; ok {131+ entry.Count++132+ continue133+ }134+ m[key] = &namedCount{Name: category, Count: 1}186135 }187- columnWidths[len(columnWidths)-1] = width - usedWidth136+ return sortedNamedCounts(m)137+}188138 189- headerHeight := 5.2190- rowHeight := 4.8191- availableRowsHeight := maxY - y - headerHeight192- if availableRowsHeight < rowHeight {193- return y139+func sortedNamedCounts(m map[string]*namedCount) []namedCount {140+ counts := make([]namedCount, 0, len(m))141+ for _, entry := range m {142+ counts = append(counts, *entry)194143 }195- maxRows := int(math.Floor(availableRowsHeight / rowHeight))196- if maxRows < 1 {197- return y198- }199-200- rowCount := len(docs)201- rowCount = min(rowCount, maxRows)144+ sort.SliceStable(counts, func(i, j int) bool {145+ return strings.Compare(normalizeToken(counts[i].Name), normalizeToken(counts[j].Name)) < 0146+ })147+ return counts148+}202149 203- pdf.SetDrawColor(125, 125, 125)204- pdf.SetLineWidth(thinGridLineMM)205- pdf.SetFillColor(245, 245, 245)206- pdf.SetTextColor(45, 45, 45)207- pdf.SetFont(sansFontFamily, "B", 8.9)150+func renderCategoryOverview(151+ pdf *fpdf.Fpdf,152+ categoryTotals []namedCount,153+ x, y, width, maxY float64,154+) float64 {155+ const (156+ lineHeight = 4.8157+ categoryGap = 1.2158+ columnGap = 6.0159+ maxWidth = 96.0160+ widthScale = 0.82161+ )208162 209- curX := x210- for i, header := range headers {211- pdf.SetXY(curX, y)212- pdf.CellFormat(columnWidths[i], headerHeight, header, "1", 0, "C", true, 0, "")213- curX += columnWidths[i]163+ if width <= 0 || y >= maxY || len(categoryTotals) == 0 {164+ return y214165 }215166 216- pdf.SetFont(sansFontFamily, "", 8.6)217167 pdf.SetTextColor(ruleTextGray, ruleTextGray, ruleTextGray)218- for i := 0; i < rowCount; i++ {219- rowY := y + headerHeight + float64(i)*rowHeight220- mode := ""221- if !isMixedModes(docs[i].Metadata.ModeSelection) {222- mode = docs[i].Metadata.ModeSelection168+ pdf.SetFont(sansFontFamily, "", 9.0)169+170+ containerWidth := min(width, min(maxWidth, width*widthScale))171+ containerX := x + (width-containerWidth)/2172+ colWidth := (containerWidth - columnGap) / 2173+ leftCount := (len(categoryTotals) + 1) / 2174+ maxRows := max(leftCount, len(categoryTotals)-leftCount)175+ curY := y176+ for row := 0; row < maxRows; row++ {177+ rowY := curY + float64(row)*(lineHeight+categoryGap)178+ if rowY+lineHeight > maxY {179+ return rowY223180 }224-225- values := []string{226- docs[i].Metadata.SourceFileName,227- docs[i].Metadata.Category,228- mode,229- strconv.Itoa(docs[i].Metadata.Count),230- emptyAs(docs[i].Metadata.Seed, "none"),181+ if row < leftCount {182+ pdf.SetXY(containerX, rowY)183+ pdf.CellFormat(colWidth, lineHeight, formatCountLabel(categoryTotals[row]), "", 0, "R", false, 0, "")231184 }232-233- curX = x234- for col := range values {235- cellText := fitTableCellText(pdf, values[col], columnWidths[col]-1.6)236- align := "L"237- if col == 3 {238- align = "C"239- }240- pdf.SetXY(curX, rowY)241- pdf.CellFormat(columnWidths[col], rowHeight, cellText, "1", 0, align, false, 0, "")242- curX += columnWidths[col]185+ rightIndex := leftCount + row186+ if rightIndex < len(categoryTotals) {187+ pdf.SetXY(containerX+colWidth+columnGap, rowY)188+ pdf.CellFormat(colWidth, lineHeight, formatCountLabel(categoryTotals[rightIndex]), "", 0, "L", false, 0, "")243189 }244190 }245191 246- return y + headerHeight + float64(rowCount)*rowHeight192+ return curY + float64(maxRows)*(lineHeight+categoryGap)247193 }248194 249-func fitTableCellText(pdf *fpdf.Fpdf, text string, maxWidth float64) string {250- text = strings.TrimSpace(text)251- if text == "" {252- return ""253- }254- if maxWidth <= 0 {255- return ""256- }257- if pdf.GetStringWidth(text) <= maxWidth {258- return text259- }195+func formatCountLabel(item namedCount) string {196+ return fmt.Sprintf("%s x%d", item.Name, item.Count)197+}260198 261- ellipsis := "..."262- if pdf.GetStringWidth(ellipsis) > maxWidth {263- return ellipsis199+func titlePageColophon(docs []PackDocument, generatedAt time.Time) string {200+ parts := []string{}201+ versions := summarizeVersions(docs)202+ if len(versions) > 0 && versions[0] != "Unknown" {203+ parts = append(parts, fmt.Sprintf("PuzzleTea %s", strings.Join(versions, ", ")))264204 }265205 266- runes := []rune(text)267- for len(runes) > 0 {268- candidate := string(runes) + ellipsis269- if pdf.GetStringWidth(candidate) <= maxWidth {270- return candidate271- }272- runes = runes[:len(runes)-1]206+ if !generatedAt.IsZero() {207+ parts = append(parts, generatedAt.Format("January 2, 2006"))273208 }274- return ellipsis275-}276209 277-func emptyAs(v, fallback string) string {278- if strings.TrimSpace(v) == "" {279- return fallback210+ if len(parts) == 0 {211+ return ""280212 }281- return v213+ return strings.Join(parts, " | ")282214 }
A pdfexport/render_title_test.go +51 -0
M registry/registry.go +4 -12
R rippleeffect/Export.go → rippleeffect/export.go +0 -0

No content changes.

R rippleeffect/Gamemode.go → rippleeffect/gamemode.go +2 -3
R rippleeffect/Model.go → rippleeffect/model.go +0 -0

No content changes.

R rippleeffect/PrintAdapter.go → rippleeffect/print_adapter.go +0 -0

No content changes.

M session/session.go +2 -1
R shikaku/Export.go → shikaku/export.go +0 -0

No content changes.

R shikaku/Gamemode.go → shikaku/gamemode.go +2 -3
R shikaku/Model.go → shikaku/model.go +0 -0

No content changes.

R shikaku/PrintAdapter.go → shikaku/print_adapter.go +0 -0

No content changes.

R spellpuzzle/Export.go → spellpuzzle/export.go +0 -0

No content changes.

R spellpuzzle/Gamemode.go → spellpuzzle/gamemode.go +2 -3
R spellpuzzle/Model.go → spellpuzzle/model.go +0 -0

No content changes.

R spellpuzzle/PrintAdapter.go → spellpuzzle/print_adapter.go +0 -0

No content changes.

R sudoku/Export.go → sudoku/export.go +0 -0

No content changes.

R sudoku/Gamemode.go → sudoku/gamemode.go +2 -3
R sudoku/Model.go → sudoku/model.go +0 -0

No content changes.

R sudoku/PrintAdapter.go → sudoku/print_adapter.go +0 -0

No content changes.

R sudokurgb/Export.go → sudokurgb/export.go +0 -0

No content changes.

R sudokurgb/Gamemode.go → sudokurgb/gamemode.go +2 -3
R sudokurgb/Model.go → sudokurgb/model.go +0 -0

No content changes.

R sudokurgb/PrintAdapter.go → sudokurgb/print_adapter.go +0 -0

No content changes.

R takuzu/Export.go → takuzu/export.go +0 -0

No content changes.

R takuzu/Gamemode.go → takuzu/gamemode.go +2 -3
R takuzu/Model.go → takuzu/model.go +0 -0

No content changes.

R takuzu/PrintAdapter.go → takuzu/print_adapter.go +0 -0

No content changes.

R takuzuplus/Export.go → takuzuplus/export.go +0 -0

No content changes.

R takuzuplus/Gamemode.go → takuzuplus/gamemode.go +2 -3
R takuzuplus/Model.go → takuzuplus/model.go +0 -0

No content changes.

R takuzuplus/PrintAdapter.go → takuzuplus/print_adapter.go +0 -0

No content changes.

R wordsearch/Export.go → wordsearch/export.go +0 -0

No content changes.

R wordsearch/Gamemode.go → wordsearch/gamemode.go +2 -3
R wordsearch/Model.go → wordsearch/model.go +0 -0

No content changes.

R wordsearch/PrintAdapter.go → wordsearch/print_adapter.go +0 -0

No content changes.