feat(assets): use single texture atlas instead of loading all images individually

This commit is contained in:
2026-01-08 19:55:43 +01:00
parent c93a8c4437
commit 44b876a5ca
41 changed files with 488 additions and 377 deletions

1
.gitignore vendored
View File

@@ -1,5 +1,6 @@
# generated # generated
public/nds/images/projects/pokemons public/nds/images/projects/pokemons
public/nds/atlas.webp
app/composables/useAssets.ts app/composables/useAssets.ts
# ESlint # ESlint

View File

@@ -13,29 +13,17 @@ const props = withDefaults(
); );
const { onRender } = useScreen(); const { onRender } = useScreen();
const app = useAppStore();
const { assets } = useAssets(); const { assets } = useAssets();
const BAR_WIDTH = 256;
const BAR_HEIGHT = 24;
const TITLE_Y = 5; const TITLE_Y = 5;
onRender((ctx) => { onRender((ctx) => {
ctx.globalAlpha = props.opacity; ctx.globalAlpha = props.opacity;
// top bar // top bar
ctx.drawImage( assets.images.common.barsSheet.draw(ctx, 0, -props.yOffset, {
assets.common.barsSheet, colored: true,
0, });
(app.color.row * 4 + app.color.col) * BAR_HEIGHT,
BAR_WIDTH,
BAR_HEIGHT,
0,
-props.yOffset,
BAR_WIDTH,
BAR_HEIGHT,
);
if (props.title) { if (props.title) {
ctx.fillStyle = "#000000"; ctx.fillStyle = "#000000";
@@ -46,17 +34,9 @@ onRender((ctx) => {
ctx.scale(1, -1); ctx.scale(1, -1);
// bottom bar // bottom bar
ctx.drawImage( assets.images.common.barsSheet.draw(ctx, 0, -192 - props.yOffset, {
assets.common.barsSheet, colored: true,
0, });
(app.color.row * 4 + app.color.col) * BAR_HEIGHT,
BAR_WIDTH,
BAR_HEIGHT,
0,
-192 - props.yOffset,
BAR_WIDTH,
BAR_HEIGHT,
);
}, 50); }, 50);
defineOptions({ defineOptions({

View File

@@ -11,11 +11,9 @@ const props = withDefaults(
const { onRender } = useScreen(); const { onRender } = useScreen();
const app = useAppStore();
const { assets } = useAssets(); const { assets } = useAssets();
const ANIMATION_SPEED = 15; const ANIMATION_SPEED = 15;
const CORNER_SIZE = 11;
let [currentX, currentY, currentWidth, currentHeight] = props.rect; let [currentX, currentY, currentWidth, currentHeight] = props.rect;
@@ -48,67 +46,31 @@ onRender((ctx, deltaTime) => {
ctx.globalAlpha = props.opacity; ctx.globalAlpha = props.opacity;
const spriteY = (app.color.row * 4 + app.color.col) * CORNER_SIZE; // top-left corner
assets.images.common.selectorCornersSheet.draw(ctx, x, y, { colored: true });
// Top-left corner // top-right corner
ctx.drawImage(
assets.common.selectorCornersSheet,
0,
spriteY,
CORNER_SIZE,
CORNER_SIZE,
x,
y,
CORNER_SIZE,
CORNER_SIZE,
);
// Top-right corner
ctx.save(); ctx.save();
ctx.scale(-1, 1); ctx.scale(-1, 1);
ctx.drawImage( assets.images.common.selectorCornersSheet.draw(ctx, -(x + w), y, {
assets.common.selectorCornersSheet, colored: true,
0, });
spriteY,
CORNER_SIZE,
CORNER_SIZE,
-(x + w),
y,
CORNER_SIZE,
CORNER_SIZE,
);
ctx.restore(); ctx.restore();
// Bottom-left corner // bottom-left corner
ctx.save(); ctx.save();
ctx.scale(1, -1); ctx.scale(1, -1);
ctx.drawImage( assets.images.common.selectorCornersSheet.draw(ctx, x, -(y + h), {
assets.common.selectorCornersSheet, colored: true,
0, });
spriteY,
CORNER_SIZE,
CORNER_SIZE,
x,
-(y + h),
CORNER_SIZE,
CORNER_SIZE,
);
ctx.restore(); ctx.restore();
// Bottom-right corner // bottom-right corner
ctx.save(); ctx.save();
ctx.scale(-1, -1); ctx.scale(-1, -1);
ctx.drawImage( assets.images.common.selectorCornersSheet.draw(ctx, -(x + w), -(y + h), {
assets.common.selectorCornersSheet, colored: true,
0, });
spriteY,
CORNER_SIZE,
CORNER_SIZE,
-(x + w),
-(y + h),
CORNER_SIZE,
CORNER_SIZE,
);
ctx.restore(); ctx.restore();
}, 40); }, 40);

View File

@@ -15,9 +15,9 @@ const { onRender, onClick } = useScreen();
const { assets } = useAssets(); const { assets } = useAssets();
const BUTTON_WIDTH = assets.common.button.width; const BUTTON_WIDTH = assets.images.common.button.rect.width;
const BUTTON_HEIGHT = assets.common.button.height; const BUTTON_HEIGHT = assets.images.common.button.rect.height;
const LETTER_WIDTH = assets.common.B.width; const LETTER_WIDTH = assets.images.common.B.rect.width;
const B_BUTTON: Rect = [31, 172, BUTTON_WIDTH, BUTTON_HEIGHT]; const B_BUTTON: Rect = [31, 172, BUTTON_WIDTH, BUTTON_HEIGHT];
const A_BUTTON: Rect = [144, 172, BUTTON_WIDTH, BUTTON_HEIGHT]; const A_BUTTON: Rect = [144, 172, BUTTON_WIDTH, BUTTON_HEIGHT];
@@ -29,23 +29,25 @@ onRender((ctx) => {
ctx.translate(0, props.yOffset); ctx.translate(0, props.yOffset);
const drawButton = ( const drawButton = (
image: HTMLImageElement, image: {
draw: (ctx: CanvasRenderingContext2D, x: number, y: number) => void;
},
text: string, text: string,
x: number, x: number,
offset: number, offset: number,
) => { ) => {
ctx.drawImage(assets.common.button, x, 172); assets.images.common.button.draw(ctx, x, 172);
const { actualBoundingBoxRight: textWidth } = ctx.measureText(text); const { actualBoundingBoxRight: textWidth } = ctx.measureText(text);
const width = LETTER_WIDTH + 4 + textWidth; const width = LETTER_WIDTH + 4 + textWidth;
const left = Math.ceil(x + BUTTON_WIDTH / 2 - width / 2 - offset / 2); const left = Math.ceil(x + BUTTON_WIDTH / 2 - width / 2 - offset / 2);
ctx.drawImage(image, left, 176); image.draw(ctx, left, 176);
ctx.fillText(text, left + LETTER_WIDTH + 4, 185); ctx.fillText(text, left + LETTER_WIDTH + 4, 185);
}; };
drawButton(assets.common.B, props.bLabel, 31, 5); drawButton(assets.images.common.B, props.bLabel, 31, 5);
drawButton(assets.common.A, props.aLabel, 144, 0); drawButton(assets.images.common.A, props.aLabel, 144, 0);
}, 60); }, 60);
onClick((x, y) => { onClick((x, y) => {

View File

@@ -6,8 +6,8 @@ const { onRender } = useScreen();
const { assets } = useAssets(); const { assets } = useAssets();
const confirmationModal = useConfirmationModal(); const confirmationModal = useConfirmationModal();
const BG_WIDTH = assets.common.confirmationModal.width; const BG_WIDTH = assets.images.common.confirmationModal.rect.width;
const BG_HEIGHT = assets.common.confirmationModal.height; const BG_HEIGHT = assets.images.common.confirmationModal.rect.height;
const BG_X = Math.floor((SCREEN_WIDTH - BG_WIDTH) / 2); const BG_X = Math.floor((SCREEN_WIDTH - BG_WIDTH) / 2);
const BG_Y = Math.floor((SCREEN_HEIGHT - BG_HEIGHT) / 2); const BG_Y = Math.floor((SCREEN_HEIGHT - BG_HEIGHT) / 2);
@@ -34,7 +34,7 @@ onRender((ctx) => {
ctx.translate(0, confirmationModal.offsetY); ctx.translate(0, confirmationModal.offsetY);
ctx.drawImage(assets.common.confirmationModal, BG_X, BG_Y); assets.images.common.confirmationModal.draw(ctx, BG_X, BG_Y);
ctx.font = "16px Pokemon DP Pro"; ctx.font = "16px Pokemon DP Pro";
ctx.textBaseline = "top"; ctx.textBaseline = "top";

View File

@@ -5,12 +5,12 @@ const store = useContactStore();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.home.bottomScreen.background, 0, 0); assets.images.home.bottomScreen.background.draw(ctx, 0, 0);
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro
? store.intro.stage2Opacity ? store.intro.stage2Opacity
: store.outro.stage3Opacity; : store.outro.stage3Opacity;
ctx.drawImage(assets.contact.bottomScreen.background, 0, 0); assets.images.contact.bottomScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -8,7 +8,7 @@ onRender((ctx) => {
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro
? store.intro.stage3Opacity ? store.intro.stage3Opacity
: store.outro.stage1Opacity; : store.outro.stage1Opacity;
ctx.drawImage(assets.contact.bottomScreen.buttons, 31, 32); assets.images.contact.bottomScreen.buttons.draw(ctx, 31, 32);
}); });
defineOptions({ defineOptions({

View File

@@ -5,12 +5,12 @@ const store = useContactStore();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.home.topScreen.background, 0, 0); assets.images.home.topScreen.background.draw(ctx, 0, 0);
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro
? store.intro.stage2Opacity ? store.intro.stage2Opacity
: store.outro.stage3Opacity; : store.outro.stage3Opacity;
ctx.drawImage(assets.contact.topScreen.background, 0, 0); assets.images.contact.topScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -8,12 +8,12 @@ onRender((ctx) => {
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity ? store.intro.stage1Opacity
: store.outro.stage2Opacity; : store.outro.stage2Opacity;
ctx.drawImage(assets.contact.topScreen.leftBar, 0, 0); assets.images.contact.topScreen.leftBar.draw(ctx, 0, 0);
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro
? store.intro.stage3Opacity ? store.intro.stage3Opacity
: store.outro.stage1Opacity; : store.outro.stage1Opacity;
ctx.drawImage(assets.contact.topScreen.leftBarThings, 0, 0); assets.images.contact.topScreen.leftBarThings.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -15,7 +15,7 @@ onRender((ctx) => {
const y = 169 - 24 * index + store.notificationsYOffset; const y = 169 - 24 * index + store.notificationsYOffset;
if (y < -24) break; if (y < -24) break;
ctx.drawImage(assets.contact.bottomScreen.notification, 21, y); assets.images.contact.bottomScreen.notification.draw(ctx, 21, y);
const content = store.notifications[i]!; const content = store.notifications[i]!;
ctx.fillStyle = content.includes("opened") ? "#00fbba" : "#e3f300"; ctx.fillStyle = content.includes("opened") ? "#00fbba" : "#e3f300";
@@ -26,8 +26,8 @@ onRender((ctx) => {
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity ? store.intro.stage1Opacity
: store.outro.stage2Opacity; : store.outro.stage2Opacity;
ctx.drawImage( assets.images.contact.topScreen.title.draw(
assets.contact.topScreen.title, ctx,
21, 21,
store.isIntro store.isIntro
? store.intro.titleY ? store.intro.titleY

View File

@@ -4,7 +4,7 @@ const store = useGalleryStore();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.home.bottomScreen.background, 0, 0); assets.images.home.bottomScreen.background.draw(ctx, 0, 0);
ctx.fillStyle = "#000000"; ctx.fillStyle = "#000000";
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro

View File

@@ -14,7 +14,7 @@ onMounted(() => {
}); });
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.home.topScreen.background, 0, 0); assets.images.home.topScreen.background.draw(ctx, 0, 0);
ctx.fillStyle = "#000000"; ctx.fillStyle = "#000000";
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro

View File

@@ -3,11 +3,12 @@ const { onRender } = useScreen();
const store = useHomeStore(); const store = useHomeStore();
const app = useAppStore(); const app = useAppStore();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.globalAlpha = app.booted ? 1 : store.intro.stage1Opacity; ctx.globalAlpha = app.booted ? 1 : store.intro.stage1Opacity;
ctx.drawImage(assets.home.bottomScreen.background, 0, 0); assets.images.home.bottomScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -3,14 +3,16 @@ const props = defineProps<{
x: number; x: number;
y: number; y: number;
opacity: number; opacity: number;
image: HTMLImageElement; image: {
draw: (ctx: CanvasRenderingContext2D, x: number, y: number) => void;
};
}>(); }>();
const { onRender } = useScreen(); const { onRender } = useScreen();
onRender((ctx) => { onRender((ctx) => {
ctx.globalAlpha = props.opacity; ctx.globalAlpha = props.opacity;
ctx.drawImage(props.image, props.x, props.y); props.image.draw(ctx, props.x, props.y);
}); });
defineOptions({ defineOptions({

View File

@@ -128,45 +128,45 @@ onRender((ctx) => {
:x="33" :x="33"
:y="25 + getButtonOffset('projects')" :y="25 + getButtonOffset('projects')"
:opacity="getOpacity('projects')" :opacity="getOpacity('projects')"
:image="assets.home.bottomScreen.buttons.game" :image="assets.images.home.bottomScreen.buttons.game"
/> />
<Button <Button
:x="32" :x="32"
:y="72 + getButtonOffset('contact')" :y="72 + getButtonOffset('contact')"
:opacity="getOpacity('contact')" :opacity="getOpacity('contact')"
:image="assets.home.bottomScreen.buttons.contact" :image="assets.images.home.bottomScreen.buttons.contact"
/> />
<Button <Button
:x="128" :x="128"
:y="72 + getButtonOffset('gallery')" :y="72 + getButtonOffset('gallery')"
:opacity="getOpacity('gallery')" :opacity="getOpacity('gallery')"
:image="assets.home.bottomScreen.buttons.gallery" :image="assets.images.home.bottomScreen.buttons.gallery"
/> />
<Button <Button
:x="33" :x="33"
:y="121" :y="121"
:opacity="getOpacity()" :opacity="getOpacity()"
:image="assets.home.bottomScreen.buttons.gamePak" :image="assets.images.home.bottomScreen.buttons.gamePak"
/> />
<Button <Button
:x="10" :x="10"
:y="175 + getButtonOffset('theme')" :y="175 + getButtonOffset('theme')"
:opacity="getOpacity('theme')" :opacity="getOpacity('theme')"
:image="assets.home.bottomScreen.buttons.theme" :image="assets.images.home.bottomScreen.buttons.theme"
/> />
<Button <Button
:x="117" :x="117"
:y="170 + getButtonOffset('settings')" :y="170 + getButtonOffset('settings')"
:opacity="getOpacity('settings')" :opacity="getOpacity('settings')"
:image="assets.home.bottomScreen.buttons.settings" :image="assets.images.home.bottomScreen.buttons.settings"
/> />
<Button <Button
:x="235" :x="235"
:y="175 + getButtonOffset('alarm')" :y="175 + getButtonOffset('alarm')"
:opacity="getOpacity('alarm')" :opacity="getOpacity('alarm')"
:image="assets.home.bottomScreen.buttons.alarm" :image="assets.images.home.bottomScreen.buttons.alarm"
/> />
<Selector :rect="selectorPosition" :opacity="getOpacity()" /> <Selector :rect="selectorPosition" :opacity="getOpacity()" />

View File

@@ -7,7 +7,7 @@ const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.globalAlpha = app.booted ? 1 : store.intro.stage1Opacity; ctx.globalAlpha = app.booted ? 1 : store.intro.stage1Opacity;
ctx.drawImage(assets.home.topScreen.background, 0, 0); assets.images.home.topScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -2,7 +2,6 @@
const { onRender } = useScreen(); const { onRender } = useScreen();
// NOTE: calendar background is handled by TopScreenBackground // NOTE: calendar background is handled by TopScreenBackground
const app = useAppStore();
const store = useHomeStore(); const store = useHomeStore();
const { assets } = useAssets(); const { assets } = useAssets();
@@ -14,7 +13,6 @@ onRender((ctx) => {
const CALENDAR_ROWS = 5; const CALENDAR_ROWS = 5;
const CALENDAR_LEFT = 128; const CALENDAR_LEFT = 128;
const CALENDAR_TOP = 64; const CALENDAR_TOP = 64;
const SELECTOR_SIZE = 13;
ctx.fillStyle = "#343434"; ctx.fillStyle = "#343434";
@@ -30,16 +28,16 @@ onRender((ctx) => {
: store.isOutro && store.outro.animateTop : store.isOutro && store.outro.animateTop
? store.outro.stage1Opacity ? store.outro.stage1Opacity
: 1; : 1;
ctx.drawImage( assets.images.home.topScreen.calendar.calendar.draw(
assets.home.topScreen.calendar.calendar, ctx,
CALENDAR_LEFT - 3, CALENDAR_LEFT - 3,
CALENDAR_TOP - 33, CALENDAR_TOP - 33,
); );
const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0; const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0;
if (extraRow) { if (extraRow) {
ctx.drawImage( assets.images.home.topScreen.calendar.lastRow.draw(
assets.home.topScreen.calendar.lastRow, ctx,
CALENDAR_LEFT - 3, CALENDAR_LEFT - 3,
CALENDAR_TOP + 79, CALENDAR_TOP + 79,
); );
@@ -63,16 +61,11 @@ onRender((ctx) => {
const cellTop = CALENDAR_TOP + col * 16; const cellTop = CALENDAR_TOP + col * 16;
if (now.getDate() === day) { if (now.getDate() === day) {
ctx.drawImage( assets.images.home.topScreen.calendar.daySelectorsSheet.draw(
assets.home.topScreen.calendar.daySelectorsSheet, ctx,
0,
(app.color.row * 4 + app.color.col) * SELECTOR_SIZE,
SELECTOR_SIZE,
SELECTOR_SIZE,
cellLeft + 1, cellLeft + 1,
cellTop + 1, cellTop + 1,
SELECTOR_SIZE, { colored: true },
SELECTOR_SIZE,
); );
} }

View File

@@ -59,7 +59,7 @@ onRender((ctx) => {
: store.isOutro && store.outro.animateTop : store.isOutro && store.outro.animateTop
? store.outro.stage1Opacity ? store.outro.stage1Opacity
: 1; : 1;
ctx.drawImage(assets.home.topScreen.clock, 13, 45); assets.images.home.topScreen.clock.draw(ctx, 13, 45);
ctx.globalAlpha = store.isIntro ctx.globalAlpha = store.isIntro
? store.intro.stage1Opacity ? store.intro.stage1Opacity

View File

@@ -1,13 +1,9 @@
<script setup lang="ts"> <script setup lang="ts">
const { onRender } = useScreen(); const { onRender } = useScreen();
const app = useAppStore();
const store = useHomeStore(); const store = useHomeStore();
const { assets } = useAssets(); const { assets } = useAssets();
const BAR_WIDTH = 256;
const BAR_HEIGHT = 16;
onRender((ctx) => { onRender((ctx) => {
const TEXT_Y = 11; const TEXT_Y = 11;
@@ -15,17 +11,9 @@ onRender((ctx) => {
ctx.globalAlpha = ctx.globalAlpha =
store.isOutro && store.outro.animateTop ? store.outro.stage2Opacity : 1; store.isOutro && store.outro.animateTop ? store.outro.stage2Opacity : 1;
ctx.drawImage( assets.images.home.topScreen.statusBar.statusBarsSheet.draw(ctx, 0, 0, {
assets.home.topScreen.statusBar.statusBarsSheet, colored: true,
0, });
(app.color.row * 4 + app.color.col) * BAR_HEIGHT,
BAR_WIDTH,
BAR_HEIGHT,
0,
0,
BAR_WIDTH,
BAR_HEIGHT,
);
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.font = "7px NDS7"; ctx.font = "7px NDS7";
@@ -55,9 +43,9 @@ onRender((ctx) => {
fillNumberCell(now.getMonth() + 1, 12, -1); fillNumberCell(now.getMonth() + 1, 12, -1);
// icons // icons
ctx.drawImage(assets.home.topScreen.statusBar.gbaDisplay, 210, 2); assets.images.home.topScreen.statusBar.gbaDisplay.draw(ctx, 210, 2);
ctx.drawImage(assets.home.topScreen.statusBar.startupMode, 226, 2); assets.images.home.topScreen.statusBar.startupMode.draw(ctx, 226, 2);
ctx.drawImage(assets.home.topScreen.statusBar.battery, 242, 4); assets.images.home.topScreen.statusBar.battery.draw(ctx, 242, 4);
}); });
defineOptions({ defineOptions({

View File

@@ -27,7 +27,7 @@ const props = defineProps<{
const { assets } = useAssets(); const { assets } = useAssets();
const app = useAppStore(); const app = useAppStore();
const model = assets.nitendoDs.model.clone(true); const model = assets.models.nitendoDs.model.clone(true);
let topScreenTexture: THREE.CanvasTexture | null = null; let topScreenTexture: THREE.CanvasTexture | null = null;
let bottomScreenTexture: THREE.CanvasTexture | null = null; let bottomScreenTexture: THREE.CanvasTexture | null = null;

View File

@@ -4,7 +4,7 @@ const { onRender } = useScreen();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.projects.bottomScreen.background, 0, 0); assets.images.projects.bottomScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({
render: () => null, render: () => null,

View File

@@ -11,23 +11,28 @@ const CLICK_RADIUS = 22;
const BUTTONS = { const BUTTONS = {
prev: { prev: {
position: [36, 100], position: [36, 100],
image: assets.projects.bottomScreen.prevPressed, image: assets.images.projects.bottomScreen.prevPressed,
}, },
quit: { quit: {
position: [88, 156], position: [88, 156],
image: assets.projects.bottomScreen.quitPressed, image: assets.images.projects.bottomScreen.quitPressed,
}, },
link: { link: {
position: [168, 156], position: [168, 156],
image: assets.projects.bottomScreen.linkPressed, image: assets.images.projects.bottomScreen.linkPressed,
}, },
next: { next: {
position: [220, 100], position: [220, 100],
image: assets.projects.bottomScreen.nextPressed, image: assets.images.projects.bottomScreen.nextPressed,
}, },
} as const satisfies Record< } as const satisfies Record<
string, string,
{ position: Point; image: HTMLImageElement } {
position: Point;
image: {
draw: (ctx: CanvasRenderingContext2D, x: number, y: number) => void;
};
}
>; >;
type ButtonType = keyof typeof BUTTONS; type ButtonType = keyof typeof BUTTONS;
@@ -115,15 +120,15 @@ onClick((x, y) => {
onRender((ctx) => { onRender((ctx) => {
// Draw disabled buttons // Draw disabled buttons
if (store.currentProject === 0) { if (store.currentProject === 0) {
ctx.drawImage( assets.images.projects.bottomScreen.prevDisabled.draw(
assets.projects.bottomScreen.prevDisabled, ctx,
BUTTONS.prev.position[0] - 14, BUTTONS.prev.position[0] - 14,
BUTTONS.prev.position[1] - 14, BUTTONS.prev.position[1] - 14,
); );
} }
if (store.currentProject === store.projects.length - 1) { if (store.currentProject === store.projects.length - 1) {
ctx.drawImage( assets.images.projects.bottomScreen.nextDisabled.draw(
assets.projects.bottomScreen.nextDisabled, ctx,
BUTTONS.next.position[0] - 14, BUTTONS.next.position[0] - 14,
BUTTONS.next.position[1] - 14, BUTTONS.next.position[1] - 14,
); );
@@ -131,24 +136,24 @@ onRender((ctx) => {
if (currentAnimation?.showButton) { if (currentAnimation?.showButton) {
const image = BUTTONS[currentAnimation.type].image; const image = BUTTONS[currentAnimation.type].image;
ctx.drawImage( image.draw(
image!, ctx,
currentAnimation.position[0] - 14, currentAnimation.position[0] - 14,
currentAnimation.position[1] - 14, currentAnimation.position[1] - 14,
); );
} }
if (currentAnimation?.showSmallCircle) { if (currentAnimation?.showSmallCircle) {
ctx.drawImage( assets.images.projects.bottomScreen.circleSmall.draw(
assets.projects.bottomScreen.circleSmall, ctx,
currentAnimation.position[0] - 28, currentAnimation.position[0] - 28,
currentAnimation.position[1] - 28, currentAnimation.position[1] - 28,
); );
} }
if (currentAnimation?.showBigCircle) { if (currentAnimation?.showBigCircle) {
ctx.drawImage( assets.images.projects.bottomScreen.circleBig.draw(
assets.projects.bottomScreen.circleBig, ctx,
currentAnimation.position[0] - 44, currentAnimation.position[0] - 44,
currentAnimation.position[1] - 44, currentAnimation.position[1] - 44,
); );

View File

@@ -124,7 +124,7 @@ onRender((ctx) => {
ctx.strokeRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT); ctx.strokeRect(0, 0, SCREEN_WIDTH, SCREEN_HEIGHT);
// text // text
ctx.drawImage(assets.projects.bottomScreen.popupTextBackground, 2, 146); assets.images.projects.bottomScreen.popupTextBackground.draw(ctx, 2, 146);
ctx.font = "16px Pokemon DP Pro"; ctx.font = "16px Pokemon DP Pro";
ctx.textBaseline = "top"; ctx.textBaseline = "top";
const displayedText = text.value.slice( const displayedText = text.value.slice(
@@ -135,10 +135,14 @@ onRender((ctx) => {
// choice box // choice box
if (textProgress.value >= 1) { if (textProgress.value >= 1) {
ctx.drawImage(assets.projects.bottomScreen.popupChoiceBackground, 194, 98); assets.images.projects.bottomScreen.popupChoiceBackground.draw(
ctx,
194,
98,
);
const y = selectedOption === "yes" ? YES_Y : NO_Y; const y = selectedOption === "yes" ? YES_Y : NO_Y;
ctx.drawImage(assets.projects.bottomScreen.popupSelector, 200, y); assets.images.projects.bottomScreen.popupSelector.draw(ctx, 200, y);
drawTextWithShadow( drawTextWithShadow(
ctx, ctx,

View File

@@ -4,7 +4,7 @@ const { onRender } = useScreen();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.projects.topScreen.background, 0, 0); assets.images.projects.topScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -56,7 +56,7 @@ const drawTextWithShadow2Lines = (
}; };
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.projects.topScreen.background, 0, 0); assets.images.projects.topScreen.background.draw(ctx, 0, 0);
ctx.textBaseline = "hanging"; ctx.textBaseline = "hanging";
ctx.font = "16px Pokemon DP Pro"; ctx.font = "16px Pokemon DP Pro";
@@ -80,19 +80,19 @@ onRender((ctx) => {
const offsetFromCurrent = i - store.currentProject; const offsetFromCurrent = i - store.currentProject;
// TODO: project.id should be typed from useAssets, shouldn't be just a string // TODO: project.id should be typed from useAssets, shouldn't be just a string
const thumbnailImage = const thumbnailImage =
assets.projects.pokemons[ assets.images.projects.pokemons[
store.projects[i]!.id as keyof typeof assets.projects.pokemons store.projects[i]!.id as keyof typeof assets.images.projects.pokemons
]!; ]!;
ctx.drawImage( thumbnailImage.draw(
thumbnailImage, ctx,
Math.floor( Math.floor(
thumbnailCenterX + thumbnailCenterX +
offsetFromCurrent * thumbnailSpacing - offsetFromCurrent * thumbnailSpacing -
thumbnailImage.width / 2 + thumbnailImage.rect.width / 2 +
store.offsetX, store.offsetX,
), ),
Math.floor(thumbnailCenterY - thumbnailImage.height / 2), Math.floor(thumbnailCenterY - thumbnailImage.rect.height / 2),
); );
} }

View File

@@ -4,7 +4,7 @@ const { onRender } = useScreen();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.home.bottomScreen.background, 0, 0); assets.images.home.bottomScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -33,27 +33,27 @@ onRender((ctx) => {
ctx.translate(props.x, props.y); ctx.translate(props.x, props.y);
if (isOpen.value || animation.playing) { if (isOpen.value || animation.playing) {
ctx.drawImage( assets.images.settings.topScreen.clock.time.draw(
assets.settings.topScreen.clock.time, ctx,
48 - animation.stage2Offset, 48 - animation.stage2Offset,
-48 + animation.stage1Offset, -48 + animation.stage1Offset,
); );
ctx.drawImage( assets.images.settings.topScreen.clock.date.draw(
assets.settings.topScreen.clock.date, ctx,
0, 0,
-96 + animation.stage2Offset + animation.stage1Offset, -96 + animation.stage2Offset + animation.stage1Offset,
); );
ctx.drawImage( assets.images.settings.topScreen.clock.alarm.draw(
assets.settings.topScreen.clock.alarm, ctx,
0, 0,
-48 + animation.stage1Offset, -48 + animation.stage1Offset,
); );
ctx.drawImage(assets.settings.topScreen.clock.clockActive, 0, 0); assets.images.settings.topScreen.clock.clockActive.draw(ctx, 0, 0);
} else if (isAnyOtherMenuOpen.value) { } else if (isAnyOtherMenuOpen.value) {
ctx.drawImage(assets.settings.topScreen.clock.clockDisabled, 0, 0); assets.images.settings.topScreen.clock.clockDisabled.draw(ctx, 0, 0);
} else { } else {
ctx.drawImage(assets.settings.topScreen.clock.clock, 0, 0); assets.images.settings.topScreen.clock.clock.draw(ctx, 0, 0);
} }
}); });

View File

@@ -33,27 +33,27 @@ onRender((ctx) => {
ctx.translate(props.x, props.y); ctx.translate(props.x, props.y);
if (isOpen.value || animation.playing) { if (isOpen.value || animation.playing) {
ctx.drawImage( assets.images.settings.topScreen.options.language.draw(
assets.settings.topScreen.options.language, ctx,
0, 0,
-48 + animation.stage1Offset, -48 + animation.stage1Offset,
); );
ctx.drawImage( assets.images.settings.topScreen.options.gbaMode.draw(
assets.settings.topScreen.options.gbaMode, ctx,
48 - animation.stage2Offset, 48 - animation.stage2Offset,
-48 + animation.stage1Offset, -48 + animation.stage1Offset,
); );
ctx.drawImage( assets.images.settings.topScreen.options.startUp.draw(
assets.settings.topScreen.options.startUp, ctx,
0, 0,
-96 + animation.stage2Offset + animation.stage1Offset, -96 + animation.stage2Offset + animation.stage1Offset,
); );
ctx.drawImage(assets.settings.topScreen.options.optionsActive, 0, 0); assets.images.settings.topScreen.options.optionsActive.draw(ctx, 0, 0);
} else if (isAnyOtherMenuOpen.value) { } else if (isAnyOtherMenuOpen.value) {
ctx.drawImage(assets.settings.topScreen.options.optionsDisabled, 0, 0); assets.images.settings.topScreen.options.optionsDisabled.draw(ctx, 0, 0);
} else { } else {
ctx.drawImage(assets.settings.topScreen.options.options, 0, 0); assets.images.settings.topScreen.options.options.draw(ctx, 0, 0);
} }
}); });

View File

@@ -27,14 +27,14 @@ const isAnyOtherMenuOpen = computed(() => {
onRender((ctx) => { onRender((ctx) => {
if (isAnyOtherMenuOpen.value) { if (isAnyOtherMenuOpen.value) {
ctx.drawImage( assets.images.settings.topScreen.touchScreen.touchScreenDisabled.draw(
assets.settings.topScreen.touchScreen.touchScreenDisabled, ctx,
props.x, props.x,
props.y, props.y,
); );
} else { } else {
ctx.drawImage( assets.images.settings.topScreen.touchScreen.touchScreen.draw(
assets.settings.topScreen.touchScreen.touchScreen, ctx,
props.x, props.x,
props.y, props.y,
); );

View File

@@ -63,7 +63,7 @@ onClick((x, y) => {
}); });
onRender((ctx, deltaTime) => { onRender((ctx, deltaTime) => {
ctx.drawImage(assets.settings.bottomScreen.user.colorPalette, 16, 32); assets.images.settings.bottomScreen.user.colorPalette.draw(ctx, 16, 32);
// animate // animate
const finalSelectorX = GRID_START_X + selectedCol * (CELL_SIZE + SPACING) - 4; const finalSelectorX = GRID_START_X + selectedCol * (CELL_SIZE + SPACING) - 4;

View File

@@ -33,32 +33,32 @@ onRender((ctx) => {
ctx.translate(props.x, props.y); ctx.translate(props.x, props.y);
if (isOpen.value || animation.playing) { if (isOpen.value || animation.playing) {
ctx.drawImage( assets.images.settings.topScreen.user.birthday.draw(
assets.settings.topScreen.user.birthday, ctx,
-48 + animation.stage2Offset, -48 + animation.stage2Offset,
-48 + animation.stage1Offset, -48 + animation.stage1Offset,
); );
ctx.drawImage( assets.images.settings.topScreen.user.userName.draw(
assets.settings.topScreen.user.userName, ctx,
0, 0,
-48 + animation.stage1Offset, -48 + animation.stage1Offset,
); );
ctx.drawImage( assets.images.settings.topScreen.user.message.draw(
assets.settings.topScreen.user.message, ctx,
48 - animation.stage2Offset, 48 - animation.stage2Offset,
-48 + animation.stage1Offset, -48 + animation.stage1Offset,
); );
ctx.drawImage( assets.images.settings.topScreen.user.color.draw(
assets.settings.topScreen.user.color, ctx,
0, 0,
-96 + animation.stage2Offset + animation.stage1Offset, -96 + animation.stage2Offset + animation.stage1Offset,
); );
ctx.drawImage(assets.settings.topScreen.user.userActive, 0, 0); assets.images.settings.topScreen.user.userActive.draw(ctx, 0, 0);
} else if (isAnyOtherMenuOpen.value) { } else if (isAnyOtherMenuOpen.value) {
ctx.drawImage(assets.settings.topScreen.user.userDisabled, 0, 0); assets.images.settings.topScreen.user.userDisabled.draw(ctx, 0, 0);
} else { } else {
ctx.drawImage(assets.settings.topScreen.user.user, 0, 0); assets.images.settings.topScreen.user.user.draw(ctx, 0, 0);
} }
}); });

View File

@@ -49,33 +49,34 @@ const { onRender, onClick } = useScreen();
const Y = 30; const Y = 30;
const SQUARE_HEIGHT = 49; const SQUARE_HEIGHT = 49;
const ARROW_IMAGE_HEIGHT = assets.settings.bottomScreen.numberInputUp.height; const ARROW_IMAGE_HEIGHT =
assets.images.settings.bottomScreen.numberInputUp.rect.height;
const upImage = computed(() => { const upImage = computed(() => {
if (props.digits === 2) { if (props.digits === 2) {
return props.disabled return props.disabled
? assets.settings.bottomScreen.numberInputUpDisabled ? assets.images.settings.bottomScreen.numberInputUpDisabled
: assets.settings.bottomScreen.numberInputUp; : assets.images.settings.bottomScreen.numberInputUp;
} else { } else {
return props.disabled return props.disabled
? assets.settings.bottomScreen.numberInputUpXlDisabled ? assets.images.settings.bottomScreen.numberInputUpXlDisabled
: assets.settings.bottomScreen.numberInputUpXl; : assets.images.settings.bottomScreen.numberInputUpXl;
} }
}); });
const downImage = computed(() => { const downImage = computed(() => {
if (props.digits === 2) { if (props.digits === 2) {
return props.disabled return props.disabled
? assets.settings.bottomScreen.numberInputDownDisabled ? assets.images.settings.bottomScreen.numberInputDownDisabled
: assets.settings.bottomScreen.numberInputDown; : assets.images.settings.bottomScreen.numberInputDown;
} else { } else {
return props.disabled return props.disabled
? assets.settings.bottomScreen.numberInputDownXlDisabled ? assets.images.settings.bottomScreen.numberInputDownXlDisabled
: assets.settings.bottomScreen.numberInputDownXl; : assets.images.settings.bottomScreen.numberInputDownXl;
} }
}); });
const squareWidth = computed(() => upImage.value.width); const squareWidth = computed(() => upImage.value.rect.width);
const increase = () => { const increase = () => {
const newValue = value.value + 1; const newValue = value.value + 1;
@@ -89,7 +90,7 @@ const decrease = () => {
onRender((ctx) => { onRender((ctx) => {
// arrow up // arrow up
ctx.drawImage(upImage.value, props.x, Y); upImage.value.draw(ctx, props.x, Y);
// outline // outline
ctx.fillStyle = "#515151"; ctx.fillStyle = "#515151";
@@ -123,11 +124,7 @@ onRender((ctx) => {
); );
// arrow down // arrow down
ctx.drawImage( downImage.value.draw(ctx, props.x, Y + ARROW_IMAGE_HEIGHT + SQUARE_HEIGHT);
downImage.value,
props.x,
Y + ARROW_IMAGE_HEIGHT + SQUARE_HEIGHT,
);
// title // title
ctx.font = "10px NDS10"; ctx.font = "10px NDS10";
@@ -140,7 +137,7 @@ onRender((ctx) => {
// TODO: -10 is needed because fillTextCentered isn't using top baseline // TODO: -10 is needed because fillTextCentered isn't using top baseline
// i will change that in the future (maybe) // i will change that in the future (maybe)
Y + ARROW_IMAGE_HEIGHT * 2 + SQUARE_HEIGHT - 6, Y + ARROW_IMAGE_HEIGHT * 2 + SQUARE_HEIGHT - 6,
downImage.value.width, downImage.value.rect.width,
); );
}); });
@@ -159,7 +156,10 @@ useKeyDown((key) => {
onClick((x, y) => { onClick((x, y) => {
if (props.disabled) return; if (props.disabled) return;
if ( if (
rectContains([props.x, Y, upImage.value.width, ARROW_IMAGE_HEIGHT], [x, y]) rectContains(
[props.x, Y, upImage.value.rect.width, ARROW_IMAGE_HEIGHT],
[x, y],
)
) { ) {
increase(); increase();
emit("select"); emit("select");
@@ -175,7 +175,7 @@ onClick((x, y) => {
[ [
props.x, props.x,
Y + ARROW_IMAGE_HEIGHT + SQUARE_HEIGHT + 2, Y + ARROW_IMAGE_HEIGHT + SQUARE_HEIGHT + 2,
downImage.value.width, downImage.value.rect.width,
ARROW_IMAGE_HEIGHT, ARROW_IMAGE_HEIGHT,
], ],
[x, y], [x, y],

View File

@@ -4,7 +4,7 @@ const { onRender } = useScreen();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
ctx.drawImage(assets.home.topScreen.background, 0, 0); assets.images.home.topScreen.background.draw(ctx, 0, 0);
}); });
defineOptions({ defineOptions({

View File

@@ -1,7 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
const { onRender } = useScreen(); const { onRender } = useScreen();
const app = useAppStore();
const { assets } = useAssets(); const { assets } = useAssets();
onRender((ctx) => { onRender((ctx) => {
@@ -14,7 +13,6 @@ onRender((ctx) => {
const CALENDAR_ROWS = 5; const CALENDAR_ROWS = 5;
const CALENDAR_LEFT = 128; const CALENDAR_LEFT = 128;
const CALENDAR_TOP = 64; const CALENDAR_TOP = 64;
const SELECTOR_SIZE = 13;
ctx.fillStyle = "#343434"; ctx.fillStyle = "#343434";
@@ -25,16 +23,16 @@ onRender((ctx) => {
const firstDay = new Date(year, month, 1).getDay(); const firstDay = new Date(year, month, 1).getDay();
const daysInMonth = new Date(year, month + 1, 0).getDate(); const daysInMonth = new Date(year, month + 1, 0).getDate();
ctx.drawImage( assets.images.home.topScreen.calendar.calendar.draw(
assets.home.topScreen.calendar.calendar, ctx,
CALENDAR_LEFT - 3, CALENDAR_LEFT - 3,
CALENDAR_TOP - 33, CALENDAR_TOP - 33,
); );
const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0; const extraRow = CALENDAR_COLS * CALENDAR_ROWS - daysInMonth - firstDay < 0;
if (extraRow) { if (extraRow) {
ctx.drawImage( assets.images.home.topScreen.calendar.lastRow.draw(
assets.home.topScreen.calendar.lastRow, ctx,
CALENDAR_LEFT - 3, CALENDAR_LEFT - 3,
CALENDAR_TOP + 79, CALENDAR_TOP + 79,
); );
@@ -53,16 +51,11 @@ onRender((ctx) => {
const cellTop = CALENDAR_TOP + col * 16; const cellTop = CALENDAR_TOP + col * 16;
if (now.getDate() === day) { if (now.getDate() === day) {
ctx.drawImage( assets.images.home.topScreen.calendar.daySelectorsSheet.draw(
assets.home.topScreen.calendar.daySelectorsSheet, ctx,
0,
(app.color.row * 4 + app.color.col) * SELECTOR_SIZE,
SELECTOR_SIZE,
SELECTOR_SIZE,
cellLeft + 1, cellLeft + 1,
cellTop + 1, cellTop + 1,
SELECTOR_SIZE, { colored: true },
SELECTOR_SIZE,
); );
} }

View File

@@ -55,7 +55,7 @@ function drawLine(
onRender((ctx) => { onRender((ctx) => {
ctx.translate(0, -16); ctx.translate(0, -16);
ctx.drawImage(assets.home.topScreen.clock, 13, 45); assets.images.home.topScreen.clock.draw(ctx, 13, 45);
const now = new Date(); const now = new Date();

View File

@@ -6,12 +6,12 @@ const { assets } = useAssets();
const renderNotification = ( const renderNotification = (
ctx: CanvasRenderingContext2D, ctx: CanvasRenderingContext2D,
image: HTMLImageElement, image: AtlasImage,
title: string, title: string,
description: string, description: string,
) => { ) => {
ctx.drawImage(assets.settings.topScreen.notification, 0, 0); assets.images.settings.topScreen.notification.draw(ctx, 0, 0);
ctx.drawImage(image, 2, 2); image.draw(ctx, 2, 2);
ctx.font = "10px NDS10"; ctx.font = "10px NDS10";
ctx.fillStyle = "#282828"; ctx.fillStyle = "#282828";
@@ -26,29 +26,29 @@ const renderNotification = (
}; };
const mainNotification = computed(() => ({ const mainNotification = computed(() => ({
image: assets.settings.topScreen.settings, image: assets.images.settings.topScreen.settings,
title: $t("settings.title"), title: $t("settings.title"),
description: $t("settings.description"), description: $t("settings.description"),
})); }));
const IMAGES_MAP: Record<string, HTMLImageElement> = { const IMAGES_MAP: Record<string, AtlasImage> = {
options: assets.settings.topScreen.options.options, options: assets.images.settings.topScreen.options.options,
optionsStartUp: assets.settings.topScreen.options.startUp, optionsStartUp: assets.images.settings.topScreen.options.startUp,
optionsLanguage: assets.settings.topScreen.options.language, optionsLanguage: assets.images.settings.topScreen.options.language,
optionsGbaMode: assets.settings.topScreen.options.gbaMode, optionsGbaMode: assets.images.settings.topScreen.options.gbaMode,
clock: assets.settings.topScreen.clock.clock, clock: assets.images.settings.topScreen.clock.clock,
clockTime: assets.settings.topScreen.clock.time, clockTime: assets.images.settings.topScreen.clock.time,
clockDate: assets.settings.topScreen.clock.date, clockDate: assets.images.settings.topScreen.clock.date,
clockAlarm: assets.settings.topScreen.clock.alarm, clockAlarm: assets.images.settings.topScreen.clock.alarm,
user: assets.settings.topScreen.user.user, user: assets.images.settings.topScreen.user.user,
userUserName: assets.settings.topScreen.user.userName, userUserName: assets.images.settings.topScreen.user.userName,
userBirthday: assets.settings.topScreen.user.birthday, userBirthday: assets.images.settings.topScreen.user.birthday,
userMessage: assets.settings.topScreen.user.message, userMessage: assets.images.settings.topScreen.user.message,
userColor: assets.settings.topScreen.user.color, userColor: assets.images.settings.topScreen.user.color,
touchScreen: assets.settings.topScreen.touchScreen.touchScreen, touchScreen: assets.images.settings.topScreen.touchScreen.touchScreen,
}; };
const menuNotification = computed(() => { const menuNotification = computed(() => {

View File

@@ -1,26 +1,14 @@
<script setup lang="ts"> <script setup lang="ts">
const { onRender } = useScreen(); const { onRender } = useScreen();
const app = useAppStore();
const { assets } = useAssets(); const { assets } = useAssets();
const BAR_WIDTH = 256;
const BAR_HEIGHT = 16;
onRender((ctx) => { onRender((ctx) => {
const TEXT_Y = 11; const TEXT_Y = 11;
ctx.drawImage( assets.images.home.topScreen.statusBar.statusBarsSheet.draw(ctx, 0, 0, {
assets.home.topScreen.statusBar.statusBarsSheet, colored: true,
0, });
(app.color.row * 4 + app.color.col) * BAR_HEIGHT,
BAR_WIDTH,
BAR_HEIGHT,
0,
0,
BAR_WIDTH,
BAR_HEIGHT,
);
ctx.fillStyle = "#ffffff"; ctx.fillStyle = "#ffffff";
ctx.font = "7px NDS7"; ctx.font = "7px NDS7";
@@ -50,9 +38,9 @@ onRender((ctx) => {
fillNumberCell(now.getMonth() + 1, 12, -1); fillNumberCell(now.getMonth() + 1, 12, -1);
// icons // icons
ctx.drawImage(assets.home.topScreen.statusBar.gbaDisplay, 210, 2); assets.images.home.topScreen.statusBar.gbaDisplay.draw(ctx, 210, 2);
ctx.drawImage(assets.home.topScreen.statusBar.startupMode, 226, 2); assets.images.home.topScreen.statusBar.startupMode.draw(ctx, 226, 2);
ctx.drawImage(assets.home.topScreen.statusBar.battery, 242, 4); assets.images.home.topScreen.statusBar.battery.draw(ctx, 242, 4);
}); });
defineOptions({ defineOptions({

View File

@@ -1,29 +1,44 @@
import * as THREE from "three"; import * as THREE from "three";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"; import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";
const imageCache = new Map<string, HTMLImageElement>(); type Rect = [number, number, number, number];
let atlasImage: HTMLImageElement | null = null;
const modelCache = new Map<string, THREE.Group>(); const modelCache = new Map<string, THREE.Group>();
const loaded = ref(0); const loaded = ref(0);
const total = ref({{TOTAL}}); const total = ref({{TOTAL}});
const isReady = computed(() => loaded.value === total.value); const isReady = computed(() => loaded.value === total.value);
const createImage = (path: string) => { if (import.meta.client) {
if (imageCache.has(path)) { atlasImage = document.createElement('img');
return imageCache.get(path)!; atlasImage.src = '/nds/atlas.webp';
}
const img = document.createElement('img'); if (atlasImage.complete) {
img.src = path;
imageCache.set(path, img);
if (img.complete) {
loaded.value += 1; loaded.value += 1;
} else { } else {
img.onload = () => { loaded.value += 1 }; atlasImage.onload = () => {
loaded.value += 1;
};
}
}
const drawAtlasImage = (
ctx: CanvasRenderingContext2D,
[sx, sy, sw, sh]: Rect,
[dx, dy, dw, dh]: Rect,
opts?: Partial<{ colored: boolean }>,
): void => {
if (!atlasImage) return;
if (opts?.colored) {
const app = useAppStore();
sh /= 16;
sy += (app.color.row * 4 + app.color.col) * sh;
dh = sh;
} }
return img; ctx.drawImage(atlasImage, sx, sy, sw, sh, dx, dy, dw, dh);
}; };
const createModel = (path: string) => { const createModel = (path: string) => {
@@ -54,7 +69,28 @@ const createModel = (path: string) => {
return model; return model;
}; };
const assets = {{ASSETS}}; export type AtlasImage = {
draw: (
ctx: CanvasRenderingContext2D,
x: number,
y: number,
opts?: Partial<{ colored: boolean }>,
) => void;
rect: { x: number; y: number; width: number; height: number };
};
type ImageTree = {
[key: string]: AtlasImage | ImageTree;
};
type ModelTree = {
[key: string]: THREE.Group | ModelTree;
};
const assets = {
images: {{IMAGES}} as const satisfies ImageTree,
models: {{MODELS}} as const satisfies ModelTree,
};
export const useAssets = () => { export const useAssets = () => {
return { return {

View File

@@ -2,14 +2,56 @@ import { defineNuxtModule, useLogger } from "@nuxt/kit";
import { readdir, readFile, writeFile } from "fs/promises"; import { readdir, readFile, writeFile } from "fs/promises";
import { join, relative, parse } from "path"; import { join, relative, parse } from "path";
import { existsSync, watch } from "fs"; import { existsSync, watch } from "fs";
import sharp from "sharp";
type AssetsTree = { type AtlasRect = {
[key: string]: string | AssetsTree; x: number;
y: number;
width: number;
height: number;
};
type ImageTree = {
[key: string]: AtlasRect | ImageTree;
};
type ModelTree = {
[key: string]: string | ModelTree;
};
type ImageData = {
path: string;
buffer: Buffer;
width: number;
height: number;
}; };
const IMAGE_EXTENSIONS = [".png", ".webp"]; const IMAGE_EXTENSIONS = [".png", ".webp"];
const MODELS_EXTENSIONS = [".gltf"]; const MODEL_EXTENSIONS = [".gltf"];
const ASSETS_EXTENSIONS = [...IMAGE_EXTENSIONS, ...MODELS_EXTENSIONS]; const MAX_WIDTH = 1024;
const toCamelCase = (str: string) =>
str.replace(/[-_](.)/g, (_, c) => c.toUpperCase()).replaceAll(".", "_");
const scanByExt = async (
dir: string,
extensions: string[],
): Promise<string[]> => {
if (!existsSync(dir)) return [];
const entries = await readdir(dir, { withFileTypes: true });
const results: string[] = [];
for (const entry of entries) {
const fullPath = join(dir, entry.name);
if (entry.isDirectory()) {
results.push(...(await scanByExt(fullPath, extensions)));
} else if (extensions.includes(parse(fullPath).ext.toLowerCase())) {
results.push(fullPath);
}
}
return results;
};
export default defineNuxtModule({ export default defineNuxtModule({
meta: { meta: {
@@ -19,125 +61,237 @@ export default defineNuxtModule({
defaults: {}, defaults: {},
async setup(_, nuxt) { async setup(_, nuxt) {
const logger = useLogger("asset-generator"); const logger = useLogger("asset-generator");
const publicDir = join(nuxt.options.rootDir, "public/nds"); const rootDir = nuxt.options.rootDir;
const templateFile = join( const imagesDir = join(rootDir, "public/nds/images");
nuxt.options.rootDir, const modelsDir = join(rootDir, "public/nds/models");
"app/composables/useAssets.ts.in", const templateFile = join(rootDir, "app/composables/useAssets.ts.in");
); const outputFile = join(rootDir, "app/composables/useAssets.ts");
const outputFile = join( const atlasOutputPath = join(rootDir, "public/nds/atlas.webp");
nuxt.options.rootDir,
"app/composables/useAssets.ts", const loadImages = async (
paths: string[],
): Promise<Map<string, ImageData>> => {
const images = new Map<string, ImageData>();
await Promise.all(
paths.map(async (path) => {
try {
const [metadata, buffer] = await Promise.all([
sharp(path).metadata(),
sharp(path).png().toBuffer(),
]);
if (metadata.width && metadata.height) {
images.set(path, {
path,
buffer,
width: metadata.width,
height: metadata.height,
});
}
} catch {
logger.warn(`Failed to load ${path}, skipping...`);
}
}),
); );
const hasExt = (filename: string, exts: string[]): boolean => { return images;
const ext = parse(filename).ext.toLowerCase();
return exts.includes(ext);
}; };
const scanDirectory = async (dir: string): Promise<string[]> => { const packImages = (images: ImageData[]) => {
const assets: string[] = []; const sorted = images.toSorted((a, b) => b.height - a.height);
const entries = await readdir(dir, { withFileTypes: true }); const rects = new Map<string, AtlasRect>();
let x = 0;
let y = 0;
let rowHeight = 0;
let maxWidth = 0;
for (const entry of entries) { for (const img of sorted) {
const fullPath = join(dir, entry.name); if (x + img.width > MAX_WIDTH) {
if (entry.isDirectory()) { x = 0;
const assetFiles = await scanDirectory(fullPath); y += rowHeight;
assets.push(...assetFiles); rowHeight = 0;
} else if (hasExt(fullPath, ASSETS_EXTENSIONS)) {
assets.push(fullPath);
}
} }
return assets; rects.set(img.path, {
x,
y,
width: img.width,
height: img.height,
});
x += img.width;
rowHeight = Math.max(rowHeight, img.height);
maxWidth = Math.max(maxWidth, x);
}
return { rects, width: maxWidth, height: y + rowHeight };
}; };
const toCamelCase = (str: string): string => { const buildImageTree = (
return str imagePaths: string[],
.replace(/[-_](.)/g, (_, c) => c.toUpperCase()) rects: Map<string, AtlasRect>,
.replaceAll(".", "_"); ): ImageTree => {
}; const tree: ImageTree = {};
const buildAssetsTree = (assets: string[], baseDir: string): AssetsTree => { for (const path of imagePaths) {
const tree: AssetsTree = {}; const parts = relative(imagesDir, path).split("/");
const fileName = parse(parts.at(-1)!).name;
for (const assetPath of assets) { let node = tree;
const relativePath = relative(baseDir, assetPath); for (const part of parts.slice(0, -1)) {
const parts = relativePath.split("/"); const key = toCamelCase(part);
const filename = parse(parts[parts.length - 1]!).name; node[key] ??= {};
node = node[key] as ImageTree;
let current = tree;
// start at 1 to skip images/, models/
for (let i = 1; i < parts.length - 1; i += 1) {
const key = toCamelCase(parts[i]!);
current[key] ??= {};
current = current[key] as AssetsTree;
} }
current[toCamelCase(filename)] = `/${relativePath}`; const rect = rects.get(path);
if (rect) node[toCamelCase(fileName)] = rect;
} }
return tree; return tree;
}; };
const generatorFn = (filePath: string): string => { const buildModelTree = (modelPaths: string[]): ModelTree => {
if (hasExt(filePath, IMAGE_EXTENSIONS)) return "createImage"; const tree: ModelTree = {};
if (hasExt(filePath, MODELS_EXTENSIONS)) return "createModel";
throw new Error(`No matching generator for '${filePath}'`); for (const path of modelPaths) {
const parts = relative(modelsDir, path).split("/");
const fileName = parse(parts.at(-1)!).name;
let node = tree;
for (const part of parts.slice(0, -1)) {
const key = toCamelCase(part);
node[key] ??= {};
node = node[key] as ModelTree;
}
node[toCamelCase(fileName)] =
`/nds/models/${relative(modelsDir, path)}`;
}
return tree;
}; };
const generateAssetsObject = (tree: AssetsTree, indent = 0): string => { const generateImageCode = (tree: ImageTree, indent = 0): string => {
const spaces = " ".repeat(indent);
const entries = Object.entries(tree); const entries = Object.entries(tree);
if (!entries.length) return "{}"; if (!entries.length) return "{}";
const lines = entries.map(([key, value]) => const sp = " ".repeat(indent);
typeof value === "string" const lines = entries.map(([key, value]) => {
? `${spaces} ${key}: ${generatorFn(value)}("/nds${value}"),` if (value && typeof value === "object" && "x" in value) {
: `${spaces} ${key}: ${generateAssetsObject(value, indent + 1)},`, const { x, y, width, height } = value;
); return `${sp} ${key}: {
${sp} draw: (ctx, x, y, opts?) => drawAtlasImage(ctx, [${x}, ${y}, ${width}, ${height}], [x, y, ${width}, ${height}], opts),
${sp} rect: { x: ${x}, y: ${y}, width: ${width}, height: ${height} },
${sp} }`;
}
return `${sp} ${key}: ${generateImageCode(value, indent + 1)}`;
});
return `{\n${lines.join("\n")}\n${spaces}}`; return `{\n${lines.join(",\n")}\n${sp}}`;
}; };
const generateAssetsFile = async () => { const generateModelCode = (tree: ModelTree, indent = 0): string => {
const entries = Object.entries(tree);
if (!entries.length) return "{}";
const sp = " ".repeat(indent);
const lines = entries.map(([key, value]) => {
if (typeof value === "string") {
return `${sp} ${key}: createModel("${value}")`;
}
return `${sp} ${key}: ${generateModelCode(value, indent + 1)}`;
});
return `{\n${lines.join(",\n")},\n${sp}}`;
};
const generateAssets = async () => {
try { try {
if (!existsSync(publicDir)) { const imagePaths = await scanByExt(imagesDir, IMAGE_EXTENSIONS);
logger.warn("No public directory found"); const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS);
return;
const totalAssets = (imagePaths.length > 0 ? 1 : 0) + modelPaths.length;
let imageCode = "{}";
let atlasWidth = 0;
let atlasHeight = 0;
if (imagePaths.length > 0) {
const imageMap = await loadImages(imagePaths);
const images = Array.from(imageMap.values());
const { rects, width, height } = packImages(images);
atlasWidth = width;
atlasHeight = height;
const atlasBuffer = await sharp({
create: {
width,
height,
channels: 4,
background: { r: 0, g: 0, b: 0, alpha: 0 },
},
})
.composite(
images.map((img) => {
const rect = rects.get(img.path)!;
return { input: img.buffer, left: rect.x, top: rect.y };
}),
)
.webp({ lossless: true })
.toBuffer();
await writeFile(atlasOutputPath, atlasBuffer);
const imageTree = buildImageTree(imagePaths, rects);
imageCode = generateImageCode(imageTree);
} }
const assets = await scanDirectory(publicDir); const modelTree = buildModelTree(modelPaths);
const assetsTree = buildAssetsTree(assets, publicDir); const modelCode = generateModelCode(modelTree);
const assetsObject = generateAssetsObject(assetsTree);
const template = await readFile(templateFile, "utf-8"); const template = await readFile(templateFile, "utf-8");
const fileContent = template const code = template
.replace("{{TOTAL}}", assets.length.toString()) .replace("{{TOTAL}}", totalAssets.toString())
.replace("{{ASSETS}}", assetsObject); .replace("{{IMAGES}}", imageCode)
.replace("{{MODELS}}", modelCode);
await writeFile(outputFile, fileContent, "utf-8"); await writeFile(outputFile, code, "utf-8");
logger.success(`Generated useAssets.ts with ${assets.length} assets`); logger.success(
`Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}) and ${modelPaths.length} models`,
);
} catch (error) { } catch (error) {
logger.error("Error generating assets file:", error); logger.error("Error generating assets:", error);
} }
}; };
nuxt.hook("build:before", async () => { nuxt.hook("build:before", generateAssets);
await generateAssetsFile();
});
if (nuxt.options.dev) { if (nuxt.options.dev) {
nuxt.hook("ready", () => { const watchAndRegenerate = (
watch(publicDir, { recursive: true }, async (_, filePath) => { dir: string,
if (filePath && hasExt(filePath, ASSETS_EXTENSIONS)) { extensions: string[],
logger.info(`Detected change: ${filePath}`); type: string,
await generateAssetsFile(); ) => {
watch(dir, { recursive: true }, (_, filePath) => {
if (
filePath &&
extensions.includes(parse(filePath).ext.toLowerCase())
) {
logger.info(`Detected ${type} change: ${filePath}`);
generateAssets();
} }
}); });
};
watch(templateFile, async () => { nuxt.hook("ready", () => {
watchAndRegenerate(imagesDir, IMAGE_EXTENSIONS, "image");
watchAndRegenerate(modelsDir, MODEL_EXTENSIONS, "model");
watch(templateFile, () => {
logger.info("Template file changed"); logger.info("Template file changed");
await generateAssetsFile(); generateAssets();
}); });
}); });
} }

View File

@@ -40,6 +40,7 @@
"eslint": "9.39.1", "eslint": "9.39.1",
"nuxt": "4.2.1", "nuxt": "4.2.1",
"prettier": "3.6.2", "prettier": "3.6.2",
"sharp": "^0.34.5",
"typescript": "5.9.3" "typescript": "5.9.3"
} }
} }

7
pnpm-lock.yaml generated
View File

@@ -86,6 +86,9 @@ importers:
prettier: prettier:
specifier: 3.6.2 specifier: 3.6.2
version: 3.6.2 version: 3.6.2
sharp:
specifier: ^0.34.5
version: 0.34.5
typescript: typescript:
specifier: 5.9.3 specifier: 5.9.3
version: 5.9.3 version: 5.9.3
@@ -11047,8 +11050,7 @@ snapshots:
"@iconify/types": 2.0.0 "@iconify/types": 2.0.0
vue: 3.5.25(typescript@5.9.3) vue: 3.5.25(typescript@5.9.3)
"@img/colour@1.0.0": "@img/colour@1.0.0": {}
optional: true
"@img/sharp-darwin-arm64@0.34.5": "@img/sharp-darwin-arm64@0.34.5":
optionalDependencies: optionalDependencies:
@@ -17020,7 +17022,6 @@ snapshots:
"@img/sharp-win32-arm64": 0.34.5 "@img/sharp-win32-arm64": 0.34.5
"@img/sharp-win32-ia32": 0.34.5 "@img/sharp-win32-ia32": 0.34.5
"@img/sharp-win32-x64": 0.34.5 "@img/sharp-win32-x64": 0.34.5
optional: true
shebang-command@2.0.0: shebang-command@2.0.0:
dependencies: dependencies: