create-pixi-vn 2.1.2 → 2.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/package.json +1 -1
  2. package/template-lib/package.json +5 -5
  3. package/template-nqtr-react-vite-muijoy/src/components/menus/save-menu/index.tsx +47 -13
  4. package/template-nqtr-react-vite-muijoy/src/components/menus/save-menu/save-slots.tsx +3 -3
  5. package/template-nqtr-react-vite-muijoy/src/components/menus/settings/menus/save-load.tsx +18 -2
  6. package/template-nqtr-react-vite-muijoy/src/components/quick-tools.tsx +4 -8
  7. package/template-nqtr-react-vite-muijoy/src/lib/hooks/hotkeys-hooks.ts +3 -3
  8. package/template-nqtr-react-vite-muijoy/src/lib/hooks/save-hooks.tsx +3 -2
  9. package/template-nqtr-react-vite-muijoy/src/lib/utils/save-utility.ts +57 -0
  10. package/template-nqtr-react-vite-muijoy/src/locales/en.json +1 -0
  11. package/template-nqtr-react-vite-muijoy-ink/package.json +1 -1
  12. package/template-nqtr-react-vite-muijoy-ink/src/components/menus/save-menu/index.tsx +47 -13
  13. package/template-nqtr-react-vite-muijoy-ink/src/components/menus/save-menu/save-slots.tsx +3 -3
  14. package/template-nqtr-react-vite-muijoy-ink/src/components/menus/settings/menus/save-load.tsx +18 -2
  15. package/template-nqtr-react-vite-muijoy-ink/src/components/quick-tools.tsx +4 -8
  16. package/template-nqtr-react-vite-muijoy-ink/src/lib/hooks/hotkeys-hooks.ts +3 -3
  17. package/template-nqtr-react-vite-muijoy-ink/src/lib/hooks/save-hooks.tsx +3 -2
  18. package/template-nqtr-react-vite-muijoy-ink/src/lib/utils/save-utility.ts +57 -0
  19. package/template-nqtr-react-vite-muijoy-ink/src/locales/en.json +1 -0
  20. package/template-nqtr-react-vite-muijoy-ink-tauri/package.json +1 -1
  21. package/template-nqtr-react-vite-muijoy-ink-tauri/src/components/menus/save-menu/index.tsx +47 -13
  22. package/template-nqtr-react-vite-muijoy-ink-tauri/src/components/menus/save-menu/save-slots.tsx +3 -3
  23. package/template-nqtr-react-vite-muijoy-ink-tauri/src/components/menus/settings/menus/save-load.tsx +18 -2
  24. package/template-nqtr-react-vite-muijoy-ink-tauri/src/components/quick-tools.tsx +4 -8
  25. package/template-nqtr-react-vite-muijoy-ink-tauri/src/lib/hooks/hotkeys-hooks.ts +3 -3
  26. package/template-nqtr-react-vite-muijoy-ink-tauri/src/lib/hooks/save-hooks.tsx +3 -2
  27. package/template-nqtr-react-vite-muijoy-ink-tauri/src/lib/utils/save-utility.ts +57 -0
  28. package/template-nqtr-react-vite-muijoy-ink-tauri/src/locales/en.json +1 -0
  29. package/template-nqtr-react-vite-muijoy-tauri/src/components/menus/save-menu/index.tsx +47 -13
  30. package/template-nqtr-react-vite-muijoy-tauri/src/components/menus/save-menu/save-slots.tsx +3 -3
  31. package/template-nqtr-react-vite-muijoy-tauri/src/components/menus/settings/menus/save-load.tsx +18 -2
  32. package/template-nqtr-react-vite-muijoy-tauri/src/components/quick-tools.tsx +4 -8
  33. package/template-nqtr-react-vite-muijoy-tauri/src/lib/hooks/hotkeys-hooks.ts +3 -3
  34. package/template-nqtr-react-vite-muijoy-tauri/src/lib/hooks/save-hooks.tsx +3 -2
  35. package/template-nqtr-react-vite-muijoy-tauri/src/lib/utils/save-utility.ts +57 -0
  36. package/template-nqtr-react-vite-muijoy-tauri/src/locales/en.json +1 -0
  37. package/template-react-vite-muijoy/src/components/menus/save-menu/index.tsx +47 -13
  38. package/template-react-vite-muijoy/src/components/menus/save-menu/save-slots.tsx +3 -3
  39. package/template-react-vite-muijoy/src/components/menus/settings/menus/save-load.tsx +18 -2
  40. package/template-react-vite-muijoy/src/components/quick-tools.tsx +4 -8
  41. package/template-react-vite-muijoy/src/lib/hooks/hotkeys-hooks.ts +3 -3
  42. package/template-react-vite-muijoy/src/lib/hooks/save-hooks.tsx +3 -2
  43. package/template-react-vite-muijoy/src/lib/utils/save-utility.ts +57 -0
  44. package/template-react-vite-muijoy/src/locales/en.json +1 -0
  45. package/template-react-vite-muijoy-ink/package.json +1 -1
  46. package/template-react-vite-muijoy-ink/src/components/menus/save-menu/index.tsx +47 -13
  47. package/template-react-vite-muijoy-ink/src/components/menus/save-menu/save-slots.tsx +3 -3
  48. package/template-react-vite-muijoy-ink/src/components/menus/settings/menus/save-load.tsx +18 -2
  49. package/template-react-vite-muijoy-ink/src/components/quick-tools.tsx +4 -8
  50. package/template-react-vite-muijoy-ink/src/lib/hooks/hotkeys-hooks.ts +3 -3
  51. package/template-react-vite-muijoy-ink/src/lib/hooks/save-hooks.tsx +3 -2
  52. package/template-react-vite-muijoy-ink/src/lib/utils/save-utility.ts +57 -0
  53. package/template-react-vite-muijoy-ink/src/locales/en.json +1 -0
  54. package/template-react-vite-muijoy-ink-tauri/package.json +1 -1
  55. package/template-react-vite-muijoy-ink-tauri/src/components/menus/save-menu/index.tsx +47 -13
  56. package/template-react-vite-muijoy-ink-tauri/src/components/menus/save-menu/save-slots.tsx +3 -3
  57. package/template-react-vite-muijoy-ink-tauri/src/components/menus/settings/menus/save-load.tsx +18 -2
  58. package/template-react-vite-muijoy-ink-tauri/src/components/quick-tools.tsx +4 -8
  59. package/template-react-vite-muijoy-ink-tauri/src/lib/hooks/hotkeys-hooks.ts +3 -3
  60. package/template-react-vite-muijoy-ink-tauri/src/lib/hooks/save-hooks.tsx +3 -2
  61. package/template-react-vite-muijoy-ink-tauri/src/lib/utils/save-utility.ts +57 -0
  62. package/template-react-vite-muijoy-ink-tauri/src/locales/en.json +1 -0
  63. package/template-react-vite-muijoy-tauri/src/components/menus/save-menu/index.tsx +47 -13
  64. package/template-react-vite-muijoy-tauri/src/components/menus/save-menu/save-slots.tsx +3 -3
  65. package/template-react-vite-muijoy-tauri/src/components/menus/settings/menus/save-load.tsx +18 -2
  66. package/template-react-vite-muijoy-tauri/src/components/quick-tools.tsx +4 -8
  67. package/template-react-vite-muijoy-tauri/src/lib/hooks/hotkeys-hooks.ts +3 -3
  68. package/template-react-vite-muijoy-tauri/src/lib/hooks/save-hooks.tsx +3 -2
  69. package/template-react-vite-muijoy-tauri/src/lib/utils/save-utility.ts +57 -0
  70. package/template-react-vite-muijoy-tauri/src/locales/en.json +1 -0
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "create-pixi-vn",
3
3
  "description": "Create a new Pixi’VN project",
4
- "version": "2.1.2",
4
+ "version": "2.1.3",
5
5
  "type": "module",
6
6
  "license": "GPL-3.0",
7
7
  "author": "DRincs-Productions",
@@ -41,7 +41,10 @@
41
41
  "vite-tsconfig-paths": "^6.1.1"
42
42
  },
43
43
  "peerDependencies": {
44
- "@drincs/pixi-vn": ">=1.5.0"
44
+ "@drincs/pixi-vn": ">=1.5.13"
45
+ },
46
+ "dependencies": {
47
+ "vitest": "^4.1.4"
45
48
  },
46
49
  "keywords": [
47
50
  "game",
@@ -51,8 +54,5 @@
51
54
  "pixi-vn",
52
55
  "visual",
53
56
  "visual-novel"
54
- ],
55
- "dependencies": {
56
- "vitest": "^4.1.4"
57
- }
57
+ ]
58
58
  }
@@ -10,42 +10,71 @@ import {
10
10
  } from "@/components/ui/pagination";
11
11
  import { ScrollArea } from "@/components/ui/scroll-area";
12
12
  import { SaveMenuPagination } from "@/lib/stores/save-menu-pagination-store";
13
+ import { getQuickSaveIds } from "@/lib/utils/save-utility";
13
14
  import { useSelector } from "@tanstack/react-store";
14
15
 
15
- const TOTAL_PAGES = 999;
16
+ const TOTAL_PAGES = 10;
17
+ /** Number of save slots shown per page. */
18
+ const SAVES_PER_PAGE = 6;
19
+ const QUICK_SAVE_IDS = getQuickSaveIds();
16
20
 
17
- function getPageNumbers(current: number, total: number): (number | "ellipsis")[] {
21
+ /** Number of consecutive page numbers shown around the current page on desktop. */
22
+ const DESKTOP_WINDOW_SIZE = 4;
23
+
24
+ type PageEntry = number | "ellipsis-start" | "ellipsis-end";
25
+
26
+ function getPageNumbers(current: number, total: number): PageEntry[] {
18
27
  if (total <= 7) {
19
28
  return Array.from({ length: total }, (_, i) => i + 1);
20
29
  }
21
- const pages: (number | "ellipsis")[] = [1];
22
- if (current > 3) pages.push("ellipsis");
23
- for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
30
+ const start = Math.max(1, Math.min(current - 1, total - DESKTOP_WINDOW_SIZE));
31
+ const end = start + DESKTOP_WINDOW_SIZE - 1;
32
+
33
+ const pages: PageEntry[] = [];
34
+ if (start > 1) {
35
+ pages.push(1);
36
+ if (start > 2) pages.push("ellipsis-start");
37
+ }
38
+ for (let i = start; i <= end; i++) {
24
39
  pages.push(i);
25
40
  }
26
- if (current < total - 2) pages.push("ellipsis");
27
- pages.push(total);
41
+ if (end < total) {
42
+ if (end < total - 1) pages.push("ellipsis-end");
43
+ pages.push(total);
44
+ }
28
45
  return pages;
29
46
  }
30
47
 
31
- export function GameSaveMenu() {
48
+ export function GameSaveMenu({ quickSaves }: { quickSaves: boolean }) {
32
49
  const page = useSelector(SaveMenuPagination.store, (state) => state.page);
33
50
 
34
51
  const currentPage = page + 1;
35
52
  const pageNumbers = getPageNumbers(currentPage, TOTAL_PAGES);
36
53
 
54
+ if (quickSaves) {
55
+ return (
56
+ <ScrollArea className="min-h-0 flex-1">
57
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
58
+ {QUICK_SAVE_IDS.map((id) => (
59
+ <SaveSlot key={`SaveFile${id}`} saveId={id} />
60
+ ))}
61
+ </div>
62
+ </ScrollArea>
63
+ );
64
+ }
65
+
37
66
  return (
38
67
  <div className="flex flex-1 min-h-0 flex-col">
39
68
  <ScrollArea className="min-h-0 flex-1">
40
69
  <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
41
- {Array.from({ length: 6 }).map((_, index) => {
42
- const id = page * 6 + index;
70
+ {Array.from({ length: SAVES_PER_PAGE }).map((_, index) => {
71
+ const id = page * SAVES_PER_PAGE + index;
43
72
  return <SaveSlot key={`SaveFile${id}`} saveId={id} />;
44
73
  })}
45
74
  </div>
46
75
  </ScrollArea>
47
76
  <Pagination className="shrink-0 border-t py-1.5">
48
- <PaginationContent>
77
+ <PaginationContent className="w-full justify-between px-2 sm:w-auto sm:justify-center sm:px-0">
49
78
  <PaginationItem>
50
79
  <PaginationPrevious
51
80
  onClick={(e) => {
@@ -56,9 +85,14 @@ export function GameSaveMenu() {
56
85
  className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
57
86
  />
58
87
  </PaginationItem>
88
+ <PaginationItem className="sm:hidden">
89
+ <span className="px-2 text-sm text-muted-foreground">
90
+ {currentPage} / {TOTAL_PAGES}
91
+ </span>
92
+ </PaginationItem>
59
93
  {pageNumbers.map((p) =>
60
- p === "ellipsis" ? (
61
- <PaginationItem key={`ellipsis-${currentPage}`}>
94
+ p === "ellipsis-start" || p === "ellipsis-end" ? (
95
+ <PaginationItem key={p} className="hidden sm:list-item">
62
96
  <PaginationEllipsis />
63
97
  </PaginationItem>
64
98
  ) : (
@@ -6,7 +6,7 @@ import { overlayTextShadowClass } from "@/constants";
6
6
  import { useSaveActions } from "@/lib/hooks/save-hooks";
7
7
  import { useQuerySaves } from "@/lib/query/save-query";
8
8
  import { cn } from "@/lib/utils";
9
- import { downloadGameSave } from "@/lib/utils/save-utility";
9
+ import { downloadGameSave, getSaveSlotLabel } from "@/lib/utils/save-utility";
10
10
  import type { FileRouteTypes } from "@/routeTree.gen";
11
11
  import { useLocation } from "@tanstack/react-router";
12
12
  import { Download, Save, SquarePen, Trash2 } from "lucide-react";
@@ -54,7 +54,7 @@ export function SaveSlot({ saveId }: { saveId: number }) {
54
54
  src={saveData.image}
55
55
  layout="fullWidth"
56
56
  alt={saveData.name}
57
- className="absolute inset-0 size-full object-contain pointer-events-none select-none rounded-lg"
57
+ className="absolute inset-0 size-full object-contain pointer-events-none select-none rounded-md"
58
58
  />
59
59
  {/* top-left metadata */}
60
60
  <div className="absolute top-2.5 left-2.5 flex flex-col gap-0.5 pointer-events-none">
@@ -73,7 +73,7 @@ export function SaveSlot({ saveId }: { saveId: number }) {
73
73
  {saveData.date.toLocaleTimeString()}
74
74
  </span>
75
75
  <span className={cn("text-sm text-neutral-300", overlayTextShadowClass)}>
76
- {`${t("save_slot")} ${String(saveId + 1).padStart(2, "0")}`}
76
+ {getSaveSlotLabel(saveId, t)}
77
77
  </span>
78
78
  </div>
79
79
  {/* top-right delete */}
@@ -5,7 +5,8 @@ import { useGameProps } from "@/lib/hooks/props-hooks";
5
5
  import { downloadGameSave, loadGameSaveFromFile } from "@/lib/utils/save-utility";
6
6
  import type { FileRouteTypes } from "@/routeTree.gen";
7
7
  import { useLocation } from "@tanstack/react-router";
8
- import { Download, FolderOpen } from "lucide-react";
8
+ import { Download, FolderOpen, Zap } from "lucide-react";
9
+ import { useState } from "react";
9
10
  import { useTranslation } from "react-i18next";
10
11
  import { toast } from "sonner";
11
12
 
@@ -13,10 +14,25 @@ export function SaveLoadSettingsPage() {
13
14
  const { t } = useTranslation(["ui"]);
14
15
  const gameProps = useGameProps();
15
16
  const location = useLocation();
17
+ const [showQuickSaves, setShowQuickSaves] = useState(false);
16
18
 
17
19
  const toolbar = (
18
20
  <TooltipProvider>
19
21
  <div className="flex items-center gap-1">
22
+ <Tooltip>
23
+ <TooltipTrigger render={<span />}>
24
+ <Button
25
+ variant={showQuickSaves ? "secondary" : "ghost"}
26
+ size="icon-lg"
27
+ onClick={() => setShowQuickSaves((v) => !v)}
28
+ aria-pressed={showQuickSaves}
29
+ aria-label={t("quick_saves")}
30
+ >
31
+ <Zap className="size-6" />
32
+ </Button>
33
+ </TooltipTrigger>
34
+ <TooltipContent>{t("quick_saves")}</TooltipContent>
35
+ </Tooltip>
20
36
  <Tooltip>
21
37
  <TooltipTrigger render={<span />}>
22
38
  <Button
@@ -60,7 +76,7 @@ export function SaveLoadSettingsPage() {
60
76
  return (
61
77
  <div className="flex min-h-0 flex-1 flex-col">
62
78
  <div className="border-b p-4">{toolbar}</div>
63
- <GameSaveMenu />
79
+ <GameSaveMenu quickSaves={showQuickSaves} />
64
80
  </div>
65
81
  );
66
82
  }
@@ -15,7 +15,7 @@ import { AutoSettings } from "@/lib/stores/auto-settings-store";
15
15
  import { GameStatus } from "@/lib/stores/game-status-store";
16
16
  import { SkipSettings } from "@/lib/stores/skip-settings-store";
17
17
  import { cn } from "@/lib/utils";
18
- import { loadSave, saveGameToIndexDB } from "@/lib/utils/save-utility";
18
+ import { getSaveSlotLabel, loadSave, quickSaveGameToIndexDB } from "@/lib/utils/save-utility";
19
19
  import { useQueryClient } from "@tanstack/react-query";
20
20
  import { useSelector } from "@tanstack/react-store";
21
21
  import { useTranslation } from "react-i18next";
@@ -39,11 +39,7 @@ export function QuickTools() {
39
39
  const setSettingsTab = useSetSearchParamState<string>("settings_tab");
40
40
 
41
41
  return (
42
- <div
43
- className={cn(
44
- "flex flex-wrap items-center justify-end gap-0.5 sm:gap-1",
45
- )}
46
- >
42
+ <div className={cn("flex flex-wrap items-center justify-end gap-0.5 sm:gap-1")}>
47
43
  <Button
48
44
  variant="ghost"
49
45
  size="xs"
@@ -104,7 +100,7 @@ export function QuickTools() {
104
100
  size="xs"
105
101
  className="h-5 px-1 text-[10px] sm:h-6 sm:px-2 sm:text-xs"
106
102
  onClick={() => {
107
- const savePromise = saveGameToIndexDB().then((save) => {
103
+ const savePromise = quickSaveGameToIndexDB().then((save) => {
108
104
  queryClient.setQueryData([SAVES_USE_QUERY_KEY, save.id], save);
109
105
  queryClient.setQueryData([LAST_SAVE_USE_QUERY_KEY], save);
110
106
  });
@@ -126,7 +122,7 @@ export function QuickTools() {
126
122
  openAlertDialog({
127
123
  head: t("load"),
128
124
  content: t("you_sure_to_load_save", {
129
- name: lastSave.name || `${t("save_slot")} ${lastSave.id}`,
125
+ name: lastSave.name || getSaveSlotLabel(lastSave.id, t),
130
126
  }),
131
127
  onConfirm: () =>
132
128
  loadSave(lastSave)
@@ -17,7 +17,7 @@ import { QuickActionsWheelState } from "@/lib/stores/quick-actions-wheel-store";
17
17
  import { SearchParams } from "@/lib/stores/search-param-store";
18
18
  import { SkipSettings } from "@/lib/stores/skip-settings-store";
19
19
  import { TextDisplaySettings } from "@/lib/stores/text-display-settings-store";
20
- import { loadSave, saveGameToIndexDB } from "@/lib/utils/save-utility";
20
+ import { getSaveSlotLabel, loadSave, quickSaveGameToIndexDB } from "@/lib/utils/save-utility";
21
21
  import { navigator, type RoomIdType } from "@drincs/nqtr";
22
22
  import { narration } from "@drincs/pixi-vn";
23
23
  import type { RegisterableHotkey } from "@tanstack/hotkeys";
@@ -78,7 +78,7 @@ export function useSaveHotkeys(): null {
78
78
  console.log("Can't save on home page");
79
79
  return;
80
80
  }
81
- const savePromise = saveGameToIndexDB().then((save) => {
81
+ const savePromise = quickSaveGameToIndexDB().then((save) => {
82
82
  queryClient.setQueryData([SAVES_USE_QUERY_KEY, save.id], save);
83
83
  queryClient.setQueryData([LAST_SAVE_USE_QUERY_KEY], save);
84
84
  });
@@ -97,7 +97,7 @@ export function useSaveHotkeys(): null {
97
97
  openAlertDialog({
98
98
  head: t("load"),
99
99
  content: t("you_sure_to_load_save", {
100
- name: lastSave.name || `${t("save_slot")} ${lastSave.id}`,
100
+ name: lastSave.name || getSaveSlotLabel(lastSave.id, t),
101
101
  }),
102
102
  onConfirm: () =>
103
103
  loadSave(lastSave)
@@ -6,6 +6,7 @@ import { LAST_SAVE_USE_QUERY_KEY, SAVES_USE_QUERY_KEY } from "@/lib/query/save-q
6
6
  import {
7
7
  addRefreshSave,
8
8
  deleteSaveFromIndexDB,
9
+ getSaveSlotLabel,
9
10
  loadSave,
10
11
  saveGameToIndexDB,
11
12
  } from "@/lib/utils/save-utility";
@@ -31,7 +32,7 @@ export function useSaveActions() {
31
32
  openAlertDialog({
32
33
  head: t("load"),
33
34
  content: t("you_sure_to_load_save", {
34
- name: data.name || `${t("save_slot")} ${String(data.id + 1).padStart(2, "0")}`,
35
+ name: data.name || getSaveSlotLabel(data.id, t),
35
36
  }),
36
37
  onConfirm: () =>
37
38
  loadSave(data)
@@ -57,7 +58,7 @@ export function useSaveActions() {
57
58
  openAlertDialog({
58
59
  head: t("delete"),
59
60
  content: t("you_sure_to_delete_save", {
60
- name: `${t("save_slot")} ${String(id + 1).padStart(2, "0")}`,
61
+ name: getSaveSlotLabel(id, t),
61
62
  }),
62
63
  onConfirm: () =>
63
64
  deleteSaveFromIndexDB(id)
@@ -12,6 +12,40 @@ import { canvas, Game } from "@drincs/pixi-vn";
12
12
  const SAVE_FILE_EXTENSION = "json";
13
13
  const REFRESH_SAVE_LOCAL_STORAGE_KEY = "refresh_save";
14
14
 
15
+ /**
16
+ * Quick saves live in a fixed, reserved range of negative ids (`-2`, `-3`, ...) so they
17
+ * never collide with the auto-incrementing ids used by manual saves (`0`, `1`, ...) or
18
+ * with the `-1` id reserved for the "refresh save" (see {@link getLastSaveFromIndexDB}).
19
+ */
20
+ const QUICK_SAVE_ID_START = -2;
21
+ /** Number of quick-save slots. */
22
+ const QUICK_SAVE_SLOTS = 6;
23
+
24
+ function getQuickSaveId(slotIndex: number): number {
25
+ return QUICK_SAVE_ID_START - slotIndex;
26
+ }
27
+
28
+ export function getQuickSaveIds(): number[] {
29
+ return Array.from({ length: QUICK_SAVE_SLOTS }, (_, index) => getQuickSaveId(index));
30
+ }
31
+
32
+ export function isQuickSaveId(id: number): boolean {
33
+ return id <= QUICK_SAVE_ID_START;
34
+ }
35
+
36
+ /** 1-based slot number for a quick-save id, for display purposes. */
37
+ export function getQuickSaveSlotNumber(id: number): number {
38
+ return QUICK_SAVE_ID_START - id + 1;
39
+ }
40
+
41
+ /** Human-readable label for a save slot, e.g. "File 01" or "Quick Save 2". */
42
+ export function getSaveSlotLabel(id: number, t: (key: string) => string): string {
43
+ if (isQuickSaveId(id)) {
44
+ return `${t("quick_save")} ${getQuickSaveSlotNumber(id)}`;
45
+ }
46
+ return `${t("save_slot")} ${String(id + 1).padStart(2, "0")}`;
47
+ }
48
+
15
49
  export function createGameSave(options?: { image?: string; name?: string }): GameSaveData {
16
50
  const { image, name = "" } = options || {};
17
51
  return {
@@ -54,6 +88,29 @@ export async function saveGameToIndexDB(
54
88
  return (await getLastSaveFromIndexDB()) as GameSaveData & { id: number };
55
89
  }
56
90
 
91
+ /**
92
+ * Saves into the dedicated quick-save slots (see {@link getQuickSaveIds}). Fills the first
93
+ * empty slot; once all slots are full, overwrites the least recently saved one.
94
+ */
95
+ export async function quickSaveGameToIndexDB(): Promise<GameSaveData & { id: number }> {
96
+ const ids = getQuickSaveIds();
97
+ const slots = await Promise.all(ids.map((id) => getSaveFromIndexDB(id)));
98
+
99
+ let targetIndex = slots.findIndex((slot) => !slot);
100
+ if (targetIndex === -1) {
101
+ targetIndex = 0;
102
+ for (let index = 1; index < slots.length; index++) {
103
+ const slot = slots[index];
104
+ const oldest = slots[targetIndex];
105
+ if (slot && oldest && new Date(slot.date) < new Date(oldest.date)) {
106
+ targetIndex = index;
107
+ }
108
+ }
109
+ }
110
+
111
+ return saveGameToIndexDB({ id: ids[targetIndex] });
112
+ }
113
+
57
114
  export async function getSaveFromIndexDB(
58
115
  id: number,
59
116
  ): Promise<(GameSaveData & { id: number }) | null> {
@@ -40,6 +40,7 @@
40
40
  "quick_save_hotkey_description": "Save the current progress immediately.",
41
41
  "quick_save_hotkey_alternative_description": "Save the current progress with Ctrl+S.",
42
42
  "quick_save_restricted": "Q.Save",
43
+ "quick_saves": "Quick Saves",
43
44
  "return_main_menu": "Return to Main Menu",
44
45
  "save": "Save",
45
46
  "saving": "Saving…",
@@ -21,7 +21,7 @@
21
21
  "@base-ui/react": "^1.6.0",
22
22
  "@drincs/nqtr": "^1.0.2",
23
23
  "@drincs/pixi-vn": "^1.8.21",
24
- "@drincs/pixi-vn-ink": "^1.1.0",
24
+ "@drincs/pixi-vn-ink": "^1.1.4",
25
25
  "@tailwindcss/vite": "^4.3.0",
26
26
  "@tanstack/hotkeys": "^0.8.0",
27
27
  "@tanstack/react-hotkeys": "^0.10.0",
@@ -10,42 +10,71 @@ import {
10
10
  } from "@/components/ui/pagination";
11
11
  import { ScrollArea } from "@/components/ui/scroll-area";
12
12
  import { SaveMenuPagination } from "@/lib/stores/save-menu-pagination-store";
13
+ import { getQuickSaveIds } from "@/lib/utils/save-utility";
13
14
  import { useSelector } from "@tanstack/react-store";
14
15
 
15
- const TOTAL_PAGES = 999;
16
+ const TOTAL_PAGES = 10;
17
+ /** Number of save slots shown per page. */
18
+ const SAVES_PER_PAGE = 6;
19
+ const QUICK_SAVE_IDS = getQuickSaveIds();
16
20
 
17
- function getPageNumbers(current: number, total: number): (number | "ellipsis")[] {
21
+ /** Number of consecutive page numbers shown around the current page on desktop. */
22
+ const DESKTOP_WINDOW_SIZE = 4;
23
+
24
+ type PageEntry = number | "ellipsis-start" | "ellipsis-end";
25
+
26
+ function getPageNumbers(current: number, total: number): PageEntry[] {
18
27
  if (total <= 7) {
19
28
  return Array.from({ length: total }, (_, i) => i + 1);
20
29
  }
21
- const pages: (number | "ellipsis")[] = [1];
22
- if (current > 3) pages.push("ellipsis");
23
- for (let i = Math.max(2, current - 1); i <= Math.min(total - 1, current + 1); i++) {
30
+ const start = Math.max(1, Math.min(current - 1, total - DESKTOP_WINDOW_SIZE));
31
+ const end = start + DESKTOP_WINDOW_SIZE - 1;
32
+
33
+ const pages: PageEntry[] = [];
34
+ if (start > 1) {
35
+ pages.push(1);
36
+ if (start > 2) pages.push("ellipsis-start");
37
+ }
38
+ for (let i = start; i <= end; i++) {
24
39
  pages.push(i);
25
40
  }
26
- if (current < total - 2) pages.push("ellipsis");
27
- pages.push(total);
41
+ if (end < total) {
42
+ if (end < total - 1) pages.push("ellipsis-end");
43
+ pages.push(total);
44
+ }
28
45
  return pages;
29
46
  }
30
47
 
31
- export function GameSaveMenu() {
48
+ export function GameSaveMenu({ quickSaves }: { quickSaves: boolean }) {
32
49
  const page = useSelector(SaveMenuPagination.store, (state) => state.page);
33
50
 
34
51
  const currentPage = page + 1;
35
52
  const pageNumbers = getPageNumbers(currentPage, TOTAL_PAGES);
36
53
 
54
+ if (quickSaves) {
55
+ return (
56
+ <ScrollArea className="min-h-0 flex-1">
57
+ <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
58
+ {QUICK_SAVE_IDS.map((id) => (
59
+ <SaveSlot key={`SaveFile${id}`} saveId={id} />
60
+ ))}
61
+ </div>
62
+ </ScrollArea>
63
+ );
64
+ }
65
+
37
66
  return (
38
67
  <div className="flex flex-1 min-h-0 flex-col">
39
68
  <ScrollArea className="min-h-0 flex-1">
40
69
  <div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3">
41
- {Array.from({ length: 6 }).map((_, index) => {
42
- const id = page * 6 + index;
70
+ {Array.from({ length: SAVES_PER_PAGE }).map((_, index) => {
71
+ const id = page * SAVES_PER_PAGE + index;
43
72
  return <SaveSlot key={`SaveFile${id}`} saveId={id} />;
44
73
  })}
45
74
  </div>
46
75
  </ScrollArea>
47
76
  <Pagination className="shrink-0 border-t py-1.5">
48
- <PaginationContent>
77
+ <PaginationContent className="w-full justify-between px-2 sm:w-auto sm:justify-center sm:px-0">
49
78
  <PaginationItem>
50
79
  <PaginationPrevious
51
80
  onClick={(e) => {
@@ -56,9 +85,14 @@ export function GameSaveMenu() {
56
85
  className={currentPage === 1 ? "pointer-events-none opacity-50" : ""}
57
86
  />
58
87
  </PaginationItem>
88
+ <PaginationItem className="sm:hidden">
89
+ <span className="px-2 text-sm text-muted-foreground">
90
+ {currentPage} / {TOTAL_PAGES}
91
+ </span>
92
+ </PaginationItem>
59
93
  {pageNumbers.map((p) =>
60
- p === "ellipsis" ? (
61
- <PaginationItem key={`ellipsis-${currentPage}`}>
94
+ p === "ellipsis-start" || p === "ellipsis-end" ? (
95
+ <PaginationItem key={p} className="hidden sm:list-item">
62
96
  <PaginationEllipsis />
63
97
  </PaginationItem>
64
98
  ) : (
@@ -6,7 +6,7 @@ import { overlayTextShadowClass } from "@/constants";
6
6
  import { useSaveActions } from "@/lib/hooks/save-hooks";
7
7
  import { useQuerySaves } from "@/lib/query/save-query";
8
8
  import { cn } from "@/lib/utils";
9
- import { downloadGameSave } from "@/lib/utils/save-utility";
9
+ import { downloadGameSave, getSaveSlotLabel } from "@/lib/utils/save-utility";
10
10
  import type { FileRouteTypes } from "@/routeTree.gen";
11
11
  import { useLocation } from "@tanstack/react-router";
12
12
  import { Download, Save, SquarePen, Trash2 } from "lucide-react";
@@ -54,7 +54,7 @@ export function SaveSlot({ saveId }: { saveId: number }) {
54
54
  src={saveData.image}
55
55
  layout="fullWidth"
56
56
  alt={saveData.name}
57
- className="absolute inset-0 size-full object-contain pointer-events-none select-none rounded-lg"
57
+ className="absolute inset-0 size-full object-contain pointer-events-none select-none rounded-md"
58
58
  />
59
59
  {/* top-left metadata */}
60
60
  <div className="absolute top-2.5 left-2.5 flex flex-col gap-0.5 pointer-events-none">
@@ -73,7 +73,7 @@ export function SaveSlot({ saveId }: { saveId: number }) {
73
73
  {saveData.date.toLocaleTimeString()}
74
74
  </span>
75
75
  <span className={cn("text-sm text-neutral-300", overlayTextShadowClass)}>
76
- {`${t("save_slot")} ${String(saveId + 1).padStart(2, "0")}`}
76
+ {getSaveSlotLabel(saveId, t)}
77
77
  </span>
78
78
  </div>
79
79
  {/* top-right delete */}
@@ -5,7 +5,8 @@ import { useGameProps } from "@/lib/hooks/props-hooks";
5
5
  import { downloadGameSave, loadGameSaveFromFile } from "@/lib/utils/save-utility";
6
6
  import type { FileRouteTypes } from "@/routeTree.gen";
7
7
  import { useLocation } from "@tanstack/react-router";
8
- import { Download, FolderOpen } from "lucide-react";
8
+ import { Download, FolderOpen, Zap } from "lucide-react";
9
+ import { useState } from "react";
9
10
  import { useTranslation } from "react-i18next";
10
11
  import { toast } from "sonner";
11
12
 
@@ -13,10 +14,25 @@ export function SaveLoadSettingsPage() {
13
14
  const { t } = useTranslation(["ui"]);
14
15
  const gameProps = useGameProps();
15
16
  const location = useLocation();
17
+ const [showQuickSaves, setShowQuickSaves] = useState(false);
16
18
 
17
19
  const toolbar = (
18
20
  <TooltipProvider>
19
21
  <div className="flex items-center gap-1">
22
+ <Tooltip>
23
+ <TooltipTrigger render={<span />}>
24
+ <Button
25
+ variant={showQuickSaves ? "secondary" : "ghost"}
26
+ size="icon-lg"
27
+ onClick={() => setShowQuickSaves((v) => !v)}
28
+ aria-pressed={showQuickSaves}
29
+ aria-label={t("quick_saves")}
30
+ >
31
+ <Zap className="size-6" />
32
+ </Button>
33
+ </TooltipTrigger>
34
+ <TooltipContent>{t("quick_saves")}</TooltipContent>
35
+ </Tooltip>
20
36
  <Tooltip>
21
37
  <TooltipTrigger render={<span />}>
22
38
  <Button
@@ -60,7 +76,7 @@ export function SaveLoadSettingsPage() {
60
76
  return (
61
77
  <div className="flex min-h-0 flex-1 flex-col">
62
78
  <div className="border-b p-4">{toolbar}</div>
63
- <GameSaveMenu />
79
+ <GameSaveMenu quickSaves={showQuickSaves} />
64
80
  </div>
65
81
  );
66
82
  }
@@ -15,7 +15,7 @@ import { AutoSettings } from "@/lib/stores/auto-settings-store";
15
15
  import { GameStatus } from "@/lib/stores/game-status-store";
16
16
  import { SkipSettings } from "@/lib/stores/skip-settings-store";
17
17
  import { cn } from "@/lib/utils";
18
- import { loadSave, saveGameToIndexDB } from "@/lib/utils/save-utility";
18
+ import { getSaveSlotLabel, loadSave, quickSaveGameToIndexDB } from "@/lib/utils/save-utility";
19
19
  import { useQueryClient } from "@tanstack/react-query";
20
20
  import { useSelector } from "@tanstack/react-store";
21
21
  import { useTranslation } from "react-i18next";
@@ -39,11 +39,7 @@ export function QuickTools() {
39
39
  const setSettingsTab = useSetSearchParamState<string>("settings_tab");
40
40
 
41
41
  return (
42
- <div
43
- className={cn(
44
- "flex flex-wrap items-center justify-end gap-0.5 sm:gap-1",
45
- )}
46
- >
42
+ <div className={cn("flex flex-wrap items-center justify-end gap-0.5 sm:gap-1")}>
47
43
  <Button
48
44
  variant="ghost"
49
45
  size="xs"
@@ -104,7 +100,7 @@ export function QuickTools() {
104
100
  size="xs"
105
101
  className="h-5 px-1 text-[10px] sm:h-6 sm:px-2 sm:text-xs"
106
102
  onClick={() => {
107
- const savePromise = saveGameToIndexDB().then((save) => {
103
+ const savePromise = quickSaveGameToIndexDB().then((save) => {
108
104
  queryClient.setQueryData([SAVES_USE_QUERY_KEY, save.id], save);
109
105
  queryClient.setQueryData([LAST_SAVE_USE_QUERY_KEY], save);
110
106
  });
@@ -126,7 +122,7 @@ export function QuickTools() {
126
122
  openAlertDialog({
127
123
  head: t("load"),
128
124
  content: t("you_sure_to_load_save", {
129
- name: lastSave.name || `${t("save_slot")} ${lastSave.id}`,
125
+ name: lastSave.name || getSaveSlotLabel(lastSave.id, t),
130
126
  }),
131
127
  onConfirm: () =>
132
128
  loadSave(lastSave)
@@ -17,7 +17,7 @@ import { QuickActionsWheelState } from "@/lib/stores/quick-actions-wheel-store";
17
17
  import { SearchParams } from "@/lib/stores/search-param-store";
18
18
  import { SkipSettings } from "@/lib/stores/skip-settings-store";
19
19
  import { TextDisplaySettings } from "@/lib/stores/text-display-settings-store";
20
- import { loadSave, saveGameToIndexDB } from "@/lib/utils/save-utility";
20
+ import { getSaveSlotLabel, loadSave, quickSaveGameToIndexDB } from "@/lib/utils/save-utility";
21
21
  import { navigator, type RoomIdType } from "@drincs/nqtr";
22
22
  import { narration } from "@drincs/pixi-vn";
23
23
  import type { RegisterableHotkey } from "@tanstack/hotkeys";
@@ -78,7 +78,7 @@ export function useSaveHotkeys(): null {
78
78
  console.log("Can't save on home page");
79
79
  return;
80
80
  }
81
- const savePromise = saveGameToIndexDB().then((save) => {
81
+ const savePromise = quickSaveGameToIndexDB().then((save) => {
82
82
  queryClient.setQueryData([SAVES_USE_QUERY_KEY, save.id], save);
83
83
  queryClient.setQueryData([LAST_SAVE_USE_QUERY_KEY], save);
84
84
  });
@@ -97,7 +97,7 @@ export function useSaveHotkeys(): null {
97
97
  openAlertDialog({
98
98
  head: t("load"),
99
99
  content: t("you_sure_to_load_save", {
100
- name: lastSave.name || `${t("save_slot")} ${lastSave.id}`,
100
+ name: lastSave.name || getSaveSlotLabel(lastSave.id, t),
101
101
  }),
102
102
  onConfirm: () =>
103
103
  loadSave(lastSave)