Compare commits
7 Commits
05eb29ad8b
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 79479f681d | |||
| 822a66a20f | |||
| f1ee6922b1 | |||
| 06b96fe5ba | |||
| 1dc342d5c1 | |||
| 17d66211ac | |||
| bc3d5cd703 |
@@ -95,11 +95,9 @@ const handleTouchEnd = (event: TouchEvent) => {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
const ndsScale = computed(() => {
|
const ndsScale = computed(() =>
|
||||||
const scaleX = (windowSize.width.value - 40) / 235;
|
getNDS2DScale(windowSize.width.value, windowSize.height.value),
|
||||||
const scaleY = (windowSize.height.value - 40) / 431;
|
);
|
||||||
return Math.min(scaleX, scaleY);
|
|
||||||
});
|
|
||||||
|
|
||||||
const gallery = useGalleryStore();
|
const gallery = useGalleryStore();
|
||||||
const TOP_SCREEN_OFFSET = 170;
|
const TOP_SCREEN_OFFSET = 170;
|
||||||
|
|||||||
@@ -151,12 +151,15 @@ onClick((x, y) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useKeyDown(({ key }) => {
|
useKeyDown(
|
||||||
|
({ key }) => {
|
||||||
if (isAnimating.value) return;
|
if (isAnimating.value) return;
|
||||||
if (key === "NDS_X") {
|
if (key === "NDS_X") {
|
||||||
handleVisitAll();
|
handleVisitAll();
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{ ignoreWhenModalOpen: true },
|
||||||
|
);
|
||||||
|
|
||||||
onRender((ctx) => {
|
onRender((ctx) => {
|
||||||
// slash divider
|
// slash divider
|
||||||
|
|||||||
@@ -141,7 +141,7 @@ const cellX = (col: number) => BORDER_SIZE + col * (TILE_SIZE + BORDER_SIZE);
|
|||||||
const cellY = (row: number) => BORDER_SIZE + row * (TILE_SIZE + BORDER_SIZE);
|
const cellY = (row: number) => BORDER_SIZE + row * (TILE_SIZE + BORDER_SIZE);
|
||||||
|
|
||||||
let tiles: VisualTile[] = [];
|
let tiles: VisualTile[] = [];
|
||||||
let animating = false;
|
let moveTimeline: gsap.core.Timeline | null = null;
|
||||||
|
|
||||||
const showRestartModal = () => {
|
const showRestartModal = () => {
|
||||||
confirmationModal.open({
|
confirmationModal.open({
|
||||||
@@ -415,14 +415,14 @@ if (board.every((r) => r.every((c) => c === 0))) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const slide = (rowDir: number, colDir: number) => {
|
const slide = (rowDir: number, colDir: number) => {
|
||||||
if (isDead() && !confirmationModal.isOpen) {
|
if (isDead()) {
|
||||||
showRestartModal();
|
showRestartModal();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (animating) {
|
if (moveTimeline) {
|
||||||
gsap.globalTimeline.clear();
|
moveTimeline.kill();
|
||||||
animating = false;
|
moveTimeline = null;
|
||||||
buildTilesFromBoard();
|
buildTilesFromBoard();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -553,14 +553,14 @@ const slide = (rowDir: number, colDir: number) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
tiles = animTiles;
|
tiles = animTiles;
|
||||||
animating = true;
|
|
||||||
|
|
||||||
const tl = gsap.timeline({
|
const tl = gsap.timeline({
|
||||||
onComplete: () => {
|
onComplete: () => {
|
||||||
animating = false;
|
moveTimeline = null;
|
||||||
buildTilesFromBoard();
|
buildTilesFromBoard();
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
moveTimeline = tl;
|
||||||
|
|
||||||
for (const { tile, toX, toY } of tweens) {
|
for (const { tile, toX, toY } of tweens) {
|
||||||
tl.to(tile, { x: toX, y: toY, duration: ANIM_DURATION, ease: "none" }, 0);
|
tl.to(tile, { x: toX, y: toY, duration: ANIM_DURATION, ease: "none" }, 0);
|
||||||
@@ -600,7 +600,8 @@ const slide = (rowDir: number, colDir: number) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
useKeyDown(({ key }) => {
|
useKeyDown(
|
||||||
|
({ key }) => {
|
||||||
if (isAnimating.value) return;
|
if (isAnimating.value) return;
|
||||||
switch (key) {
|
switch (key) {
|
||||||
case "NDS_UP":
|
case "NDS_UP":
|
||||||
@@ -620,7 +621,9 @@ useKeyDown(({ key }) => {
|
|||||||
slide(0, 1);
|
slide(0, 1);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
});
|
},
|
||||||
|
{ ignoreWhenModalOpen: true },
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
|
|||||||
@@ -5,10 +5,6 @@ import gsap from "gsap";
|
|||||||
const store = useSettingsStore();
|
const store = useSettingsStore();
|
||||||
const confirmationModal = useConfirmationModal();
|
const confirmationModal = useConfirmationModal();
|
||||||
|
|
||||||
const BIRTHDAY_DAY = 25;
|
|
||||||
const BIRTHDAY_MONTH = 4;
|
|
||||||
const BIRTHDAY_YEAR = 2002;
|
|
||||||
|
|
||||||
const isAnimating = ref(true);
|
const isAnimating = ref(true);
|
||||||
|
|
||||||
const bLabel = ref($t("common.goBack"));
|
const bLabel = ref($t("common.goBack"));
|
||||||
|
|||||||
@@ -6,11 +6,17 @@ export type KeyDownCallback = (params: {
|
|||||||
repeated: boolean;
|
repeated: boolean;
|
||||||
}) => void;
|
}) => void;
|
||||||
|
|
||||||
export const useKeyDown = (callback: KeyDownCallback) => {
|
export const useKeyDown = (
|
||||||
|
callback: KeyDownCallback,
|
||||||
|
// the confirmation modal's own buttons rely on useKeyDown, so this is opt-in
|
||||||
|
options: { ignoreWhenModalOpen?: boolean } = {},
|
||||||
|
) => {
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
|
const confirmationModal = useConfirmationModal();
|
||||||
|
|
||||||
const handleKeyDown = (event: KeyboardEvent) => {
|
const handleKeyDown = (event: KeyboardEvent) => {
|
||||||
if (app.lagModalOpen) return;
|
if (app.lagModalOpen) return;
|
||||||
|
if (options.ignoreWhenModalOpen && confirmationModal.isOpen) return;
|
||||||
|
|
||||||
const ndsButton = mapCodeToNDS(event.code);
|
const ndsButton = mapCodeToNDS(event.code);
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -6,7 +6,17 @@ import gsap from "gsap";
|
|||||||
|
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
useHead({
|
useHead({
|
||||||
title: computed(() => `${t("screens.gallery")} | Pihkaal - Developper`),
|
title: computed(() => `${t("screens.gallery")} | Pihkaal - Developer`),
|
||||||
|
link: [{ rel: "canonical", href: "https://pihkaal.me/gallery" }],
|
||||||
|
});
|
||||||
|
const GALLERY_DESCRIPTION =
|
||||||
|
"Macro photography by Pihkaal: plants, insects and arachnids from Brittany, France.";
|
||||||
|
useSeoMeta({
|
||||||
|
description: GALLERY_DESCRIPTION,
|
||||||
|
ogDescription: GALLERY_DESCRIPTION,
|
||||||
|
twitterDescription: GALLERY_DESCRIPTION,
|
||||||
|
ogUrl: "https://pihkaal.me/gallery",
|
||||||
|
ogTitle: computed(() => `${t("screens.gallery")} | Pihkaal - Developer`),
|
||||||
});
|
});
|
||||||
|
|
||||||
const { data: images } = await useAsyncData(
|
const { data: images } = await useAsyncData(
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { useWindowSize } from "@vueuse/core";
|
|
||||||
import gsap from "gsap";
|
import gsap from "gsap";
|
||||||
import { LazyLagModal } from "#components";
|
import { LazyLagModal } from "#components";
|
||||||
|
|
||||||
@@ -30,20 +29,6 @@ const bottomScreenCanvas = computed(() => bottomScreen.value?.canvas ?? null);
|
|||||||
const isTouchDevice = () =>
|
const isTouchDevice = () =>
|
||||||
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
"ontouchstart" in window || navigator.maxTouchPoints > 0;
|
||||||
|
|
||||||
const windowSize = useWindowSize();
|
|
||||||
|
|
||||||
watch(
|
|
||||||
[windowSize.width, windowSize.height],
|
|
||||||
([width, height]) => {
|
|
||||||
if (width / height > 614 / 667) {
|
|
||||||
if (app.booted) app.allowHints();
|
|
||||||
} else {
|
|
||||||
app.disallowHints();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{ immediate: true },
|
|
||||||
);
|
|
||||||
|
|
||||||
const helpButton = useTemplateRef("helpButton");
|
const helpButton = useTemplateRef("helpButton");
|
||||||
let helpAnimation: gsap.core.Timeline | null = null;
|
let helpAnimation: gsap.core.Timeline | null = null;
|
||||||
|
|
||||||
@@ -105,11 +90,16 @@ const hideHelpLabels = () => {
|
|||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// help button is removed when hints are disallowed, no need to animate it out
|
||||||
watch(
|
watch(
|
||||||
() => app.hintsAllowed,
|
() => app.hintsAllowed,
|
||||||
(allowed) => {
|
(allowed) => {
|
||||||
if (!allowed) hideHelpLabels();
|
if (allowed) return;
|
||||||
|
helpAnimation?.kill();
|
||||||
|
helpAnimation = null;
|
||||||
|
app.hintsVisible = false;
|
||||||
},
|
},
|
||||||
|
{ immediate: true },
|
||||||
);
|
);
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
@@ -129,9 +119,7 @@ onMounted(async () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const scaleX = (window.innerWidth - 40) / 235;
|
const scale = getNDS2DScale(window.innerWidth, window.innerHeight);
|
||||||
const scaleY = (window.innerHeight - 40) / 431;
|
|
||||||
const scale = Math.min(scaleX, scaleY);
|
|
||||||
|
|
||||||
if (app.settings.renderingMode === "2d" && scale < 1) return;
|
if (app.settings.renderingMode === "2d" && scale < 1) return;
|
||||||
|
|
||||||
|
|||||||
39
app/spa-loading-template.html
Normal file
39
app/spa-loading-template.html
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
<main
|
||||||
|
style="
|
||||||
|
position: absolute;
|
||||||
|
width: 1px;
|
||||||
|
height: 1px;
|
||||||
|
margin: -1px;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
clip: rect(0, 0, 0, 0);
|
||||||
|
white-space: nowrap;
|
||||||
|
border: 0;
|
||||||
|
"
|
||||||
|
>
|
||||||
|
<h1>Stevan Corre (pihkaal) - Full-Stack Developer</h1>
|
||||||
|
<p>
|
||||||
|
Developer from Brittany, France. I love programming and taking photos. This
|
||||||
|
portfolio is an interactive 3D Nintendo DS.
|
||||||
|
</p>
|
||||||
|
<h2>Projects and experiences</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="https://s3pweb.com">S3PWeb</a> - Apprenticeship</li>
|
||||||
|
<li><a href="https://biobleud.fr/">Biobleud</a> - Temporary assignments</li>
|
||||||
|
<li><a href="https://simple-qr.com">Simple QR</a> - QR code generator</li>
|
||||||
|
<li><a href="https://henvel.fun">Henvel</a> - Online word game</li>
|
||||||
|
<li><a href="https://lilou.cat">lilou.cat</a> - Lilou's website</li>
|
||||||
|
<li><a href="https://git.pihkaal.me/tlock">tlock</a> - Terminal based clock</li>
|
||||||
|
<li><a href="https://git.pihkaal.me/lbf-bot">LBF Bot</a> - Custom Discord bot</li>
|
||||||
|
<li><a href="https://git.pihkaal.me/raylib-speedruns">Raylib Speedruns</a></li>
|
||||||
|
<li><a href="https://git.pihkaal.me/pihkaal-me">pihkaal.me</a> - My personal website</li>
|
||||||
|
</ul>
|
||||||
|
<h2>Contact</h2>
|
||||||
|
<ul>
|
||||||
|
<li><a href="mailto:hello@pihkaal.me">hello@pihkaal.me</a></li>
|
||||||
|
<li><a href="https://git.pihkaal.me">Git</a></li>
|
||||||
|
<li><a href="https://linkedin.com/in/stevancorre/">LinkedIn</a></li>
|
||||||
|
<li><a href="/cv.pdf">CV</a></li>
|
||||||
|
<li><a href="/gallery">Photo gallery</a></li>
|
||||||
|
</ul>
|
||||||
|
</main>
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { z } from "zod/mini";
|
import { z } from "zod/mini";
|
||||||
|
import { useWindowSize } from "@vueuse/core";
|
||||||
import type * as THREE from "three";
|
import type * as THREE from "three";
|
||||||
|
|
||||||
export const LOGICAL_WIDTH = 256;
|
export const LOGICAL_WIDTH = 256;
|
||||||
@@ -18,6 +19,24 @@ export const APP_COLORS = [
|
|||||||
|
|
||||||
const STORAGE_ID = "nds-settings";
|
const STORAGE_ID = "nds-settings";
|
||||||
|
|
||||||
|
// 2d nds is 414x431, scaled so the screens fit, it overflows horizontally on narrow viewports
|
||||||
|
export const getNDS2DScale = (width: number, height: number) =>
|
||||||
|
Math.min((width - 40) / 235, (height - 40) / 431);
|
||||||
|
|
||||||
|
// 3d: fixed vertical fov, so buttons clip at a fixed aspect ratio (measured: 810px wide at 865px tall)
|
||||||
|
const HINTS_MIN_ASPECT_RATIO_3D = 810 / 865;
|
||||||
|
// 2d: horizontal span of the physical buttons (A button is ~14px from the edge)
|
||||||
|
const NDS2D_BUTTONS_SPAN = 386;
|
||||||
|
|
||||||
|
const screenFitsHints = (
|
||||||
|
width: number,
|
||||||
|
height: number,
|
||||||
|
renderingMode: "3d" | "2d",
|
||||||
|
) =>
|
||||||
|
renderingMode === "3d"
|
||||||
|
? width / height > HINTS_MIN_ASPECT_RATIO_3D
|
||||||
|
: NDS2D_BUTTONS_SPAN * getNDS2DScale(width, height) <= width;
|
||||||
|
|
||||||
const settingsSchema = z.object({
|
const settingsSchema = z.object({
|
||||||
color: z.object({
|
color: z.object({
|
||||||
col: z.number(),
|
col: z.number(),
|
||||||
@@ -55,7 +74,9 @@ export const useAppStore = defineStore("app", {
|
|||||||
visitedGallery: false,
|
visitedGallery: false,
|
||||||
camera: null as THREE.Camera | null,
|
camera: null as THREE.Camera | null,
|
||||||
hintsVisible: false,
|
hintsVisible: false,
|
||||||
hintsAllowed: false,
|
// hints wanted by the current app state (intro done, not in gallery)
|
||||||
|
hintsRequested: false,
|
||||||
|
windowSize: useWindowSize(),
|
||||||
lagDetected: false,
|
lagDetected: false,
|
||||||
lagModalOpen: false,
|
lagModalOpen: false,
|
||||||
};
|
};
|
||||||
@@ -96,12 +117,11 @@ export const useAppStore = defineStore("app", {
|
|||||||
},
|
},
|
||||||
|
|
||||||
allowHints() {
|
allowHints() {
|
||||||
this.hintsAllowed = true;
|
this.hintsRequested = true;
|
||||||
},
|
},
|
||||||
|
|
||||||
disallowHints() {
|
disallowHints() {
|
||||||
this.hintsAllowed = false;
|
this.hintsRequested = false;
|
||||||
this.hintsVisible = false;
|
|
||||||
},
|
},
|
||||||
|
|
||||||
showHints() {
|
showHints() {
|
||||||
@@ -128,5 +148,14 @@ export const useAppStore = defineStore("app", {
|
|||||||
row: state.settings.color.row,
|
row: state.settings.color.row,
|
||||||
hex: APP_COLORS[state.settings.color.row]![state.settings.color.col]!,
|
hex: APP_COLORS[state.settings.color.row]![state.settings.color.col]!,
|
||||||
}),
|
}),
|
||||||
|
|
||||||
|
// hints wanted and screen large enough
|
||||||
|
hintsAllowed: (state) =>
|
||||||
|
state.hintsRequested &&
|
||||||
|
screenFitsHints(
|
||||||
|
state.windowSize.width,
|
||||||
|
state.windowSize.height,
|
||||||
|
state.settings.renderingMode,
|
||||||
|
),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,9 +67,7 @@ export const useIntroStore = defineStore("intro", {
|
|||||||
)
|
)
|
||||||
.call(() => {
|
.call(() => {
|
||||||
const app = useAppStore();
|
const app = useAppStore();
|
||||||
if (window.innerWidth / window.innerHeight > 614 / 667) {
|
|
||||||
app.allowHints();
|
app.allowHints();
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
10
app/utils/birthday.ts
Normal file
10
app/utils/birthday.ts
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
export const BIRTHDAY_DAY = 25;
|
||||||
|
export const BIRTHDAY_MONTH = 4;
|
||||||
|
export const BIRTHDAY_YEAR = 2002;
|
||||||
|
|
||||||
|
export const getAge = (now = new Date()) => {
|
||||||
|
const hadBirthdayThisYear =
|
||||||
|
now.getMonth() > BIRTHDAY_MONTH - 1 ||
|
||||||
|
(now.getMonth() === BIRTHDAY_MONTH - 1 && now.getDate() >= BIRTHDAY_DAY);
|
||||||
|
return now.getFullYear() - BIRTHDAY_YEAR - (hadBirthdayThisYear ? 0 : 1);
|
||||||
|
};
|
||||||
BIN
content/projects/henvel-fun/index.webp
Normal file
BIN
content/projects/henvel-fun/index.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 758 B |
25
content/projects/henvel-fun/index.yml
Normal file
25
content/projects/henvel-fun/index.yml
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
order: 15
|
||||||
|
scope: hobby
|
||||||
|
title: Henvel
|
||||||
|
link: https://henvel.fun
|
||||||
|
|
||||||
|
technologies:
|
||||||
|
- Svelte
|
||||||
|
- TypeScript
|
||||||
|
- SQLite
|
||||||
|
|
||||||
|
en:
|
||||||
|
description: Two-player word game
|
||||||
|
summary: Online word game
|
||||||
|
tasks:
|
||||||
|
- "Real-time online\\nmultiplayer"
|
||||||
|
- "Animations and\\nsound effects"
|
||||||
|
- "Available in French\\nand English"
|
||||||
|
|
||||||
|
fr:
|
||||||
|
description: Jeu de mots à deux
|
||||||
|
summary: Jeu de mots en ligne
|
||||||
|
tasks:
|
||||||
|
- "Multijoueur en ligne\\nen temps réel"
|
||||||
|
- "Animations et\\neffets sonores"
|
||||||
|
- "Disponible en français\\net en anglais"
|
||||||
@@ -1,10 +1,14 @@
|
|||||||
import { defineNuxtModule, useLogger } from "@nuxt/kit";
|
import { defineNuxtModule, useLogger } from "@nuxt/kit";
|
||||||
import { readdir, readFile, writeFile } from "fs/promises";
|
import { mkdir, readdir, readFile, writeFile } from "fs/promises";
|
||||||
import { join, relative, parse } from "path";
|
import { dirname, join, relative, parse } from "path";
|
||||||
import { existsSync, watch } from "fs";
|
import { existsSync, watch } from "fs";
|
||||||
import { execSync } from "child_process";
|
import { execFile } from "child_process";
|
||||||
|
import { createHash } from "crypto";
|
||||||
|
import { promisify } from "util";
|
||||||
import sharp from "sharp";
|
import sharp from "sharp";
|
||||||
|
|
||||||
|
const execFileAsync = promisify(execFile);
|
||||||
|
|
||||||
type AtlasRect = {
|
type AtlasRect = {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
@@ -79,6 +83,7 @@ export default defineNuxtModule({
|
|||||||
const templateFile = join(rootDir, "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(rootDir, "app/composables/useAssets.ts");
|
||||||
const atlasOutputPath = join(rootDir, "public/nds/atlas.webp");
|
const atlasOutputPath = join(rootDir, "public/nds/atlas.webp");
|
||||||
|
const atlasHashFile = join(rootDir, "node_modules/.cache/asset-generator/atlas-hash");
|
||||||
|
|
||||||
const loadImages = async (
|
const loadImages = async (
|
||||||
paths: string[],
|
paths: string[],
|
||||||
@@ -88,10 +93,8 @@ export default defineNuxtModule({
|
|||||||
await Promise.all(
|
await Promise.all(
|
||||||
paths.map(async (path) => {
|
paths.map(async (path) => {
|
||||||
try {
|
try {
|
||||||
const [metadata, buffer] = await Promise.all([
|
const buffer = await readFile(path);
|
||||||
sharp(path).metadata(),
|
const metadata = await sharp(buffer).metadata();
|
||||||
sharp(path).png().toBuffer(),
|
|
||||||
]);
|
|
||||||
|
|
||||||
if (metadata.width && metadata.height) {
|
if (metadata.width && metadata.height) {
|
||||||
images.set(path, {
|
images.set(path, {
|
||||||
@@ -256,7 +259,10 @@ ${sp} }`;
|
|||||||
|
|
||||||
const generateAssets = async () => {
|
const generateAssets = async () => {
|
||||||
try {
|
try {
|
||||||
const imagePaths = await scanByExt(imagesDir, IMAGE_EXTENSIONS);
|
// sorted so packing (and therefore the atlas hash) is deterministic
|
||||||
|
const imagePaths = (
|
||||||
|
await scanByExt(imagesDir, IMAGE_EXTENSIONS)
|
||||||
|
).sort();
|
||||||
const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS);
|
const modelPaths = await scanByExt(modelsDir, MODEL_EXTENSIONS);
|
||||||
const audioPaths = await scanByExt(audioDir, AUDIO_EXTENSIONS);
|
const audioPaths = await scanByExt(audioDir, AUDIO_EXTENSIONS);
|
||||||
|
|
||||||
@@ -266,15 +272,30 @@ ${sp} }`;
|
|||||||
let atlasHash = "";
|
let atlasHash = "";
|
||||||
let atlasWidth = 0;
|
let atlasWidth = 0;
|
||||||
let atlasHeight = 0;
|
let atlasHeight = 0;
|
||||||
|
let atlasCached = false;
|
||||||
|
|
||||||
if (imagePaths.length > 0) {
|
if (imagePaths.length > 0) {
|
||||||
const imageMap = await loadImages(imagePaths);
|
const imageMap = await loadImages(imagePaths);
|
||||||
const images = Array.from(imageMap.values());
|
const images = imagePaths.flatMap((path) => imageMap.get(path) ?? []);
|
||||||
|
|
||||||
const { rects, width, height } = packImages(images);
|
const { rects, width, height } = packImages(images);
|
||||||
atlasWidth = width;
|
atlasWidth = width;
|
||||||
atlasHeight = height;
|
atlasHeight = height;
|
||||||
|
|
||||||
|
const imageTree = buildImageTree(imagePaths, rects);
|
||||||
|
imageCode = generateImageCode(imageTree);
|
||||||
|
|
||||||
|
// imageCode covers names and layout, buffers cover pixels
|
||||||
|
const hash = createHash("sha256").update(imageCode);
|
||||||
|
for (const img of images) hash.update(img.buffer);
|
||||||
|
atlasHash = hash.digest("hex").slice(0, 8);
|
||||||
|
|
||||||
|
const cachedHash = await readFile(atlasHashFile, "utf-8").catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
atlasCached = cachedHash === atlasHash && existsSync(atlasOutputPath);
|
||||||
|
|
||||||
|
if (!atlasCached) {
|
||||||
const atlasBuffer = await sharp({
|
const atlasBuffer = await sharp({
|
||||||
create: {
|
create: {
|
||||||
width,
|
width,
|
||||||
@@ -294,15 +315,18 @@ ${sp} }`;
|
|||||||
|
|
||||||
await writeFile(atlasOutputPath, atlasBuffer);
|
await writeFile(atlasOutputPath, atlasBuffer);
|
||||||
|
|
||||||
execSync(
|
await execFileAsync("magick", [
|
||||||
`magick "${atlasOutputPath}" -define webp:lossless=true -define webp:method=6 "${atlasOutputPath}"`,
|
atlasOutputPath,
|
||||||
{ stdio: "pipe" },
|
"-define",
|
||||||
);
|
"webp:lossless=true",
|
||||||
|
"-define",
|
||||||
|
"webp:method=6",
|
||||||
|
atlasOutputPath,
|
||||||
|
]);
|
||||||
|
|
||||||
atlasHash = Math.random().toString(36).slice(2, 10);
|
await mkdir(dirname(atlasHashFile), { recursive: true });
|
||||||
|
await writeFile(atlasHashFile, atlasHash, "utf-8");
|
||||||
const imageTree = buildImageTree(imagePaths, rects);
|
}
|
||||||
imageCode = generateImageCode(imageTree);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelTree = buildModelTree(modelPaths);
|
const modelTree = buildModelTree(modelPaths);
|
||||||
@@ -319,9 +343,16 @@ ${sp} }`;
|
|||||||
.replace("{{MODELS}}", modelCode)
|
.replace("{{MODELS}}", modelCode)
|
||||||
.replace("{{AUDIO}}", audioCode);
|
.replace("{{AUDIO}}", audioCode);
|
||||||
|
|
||||||
|
// rewriting an identical file would still invalidate it in Vite
|
||||||
|
const previousCode = await readFile(outputFile, "utf-8").catch(
|
||||||
|
() => null,
|
||||||
|
);
|
||||||
|
if (previousCode !== code) {
|
||||||
await writeFile(outputFile, code, "utf-8");
|
await writeFile(outputFile, code, "utf-8");
|
||||||
|
}
|
||||||
|
|
||||||
logger.success(
|
logger.success(
|
||||||
`Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}), ${modelPaths.length} models and ${audioPaths.length} audio files`,
|
`Generated assets with ${imagePaths.length} images (${atlasWidth}x${atlasHeight}${atlasCached ? ", cached" : ""}), ${modelPaths.length} models and ${audioPaths.length} audio files`,
|
||||||
);
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error("Error generating assets:", error);
|
logger.error("Error generating assets:", error);
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
|
import { getAge } from "./app/utils/birthday";
|
||||||
|
|
||||||
const TITLE = "Pihkaal - Developer";
|
const TITLE = "Pihkaal - Developer";
|
||||||
const DESCRIPTION =
|
const DESCRIPTION = `${getAge()}yo developer from Brittany. I love programming and taking photos.`;
|
||||||
"23yo developer from Brittany. I love programming and taking photos.";
|
|
||||||
const URL = "https://pihkaal.me";
|
const URL = "https://pihkaal.me";
|
||||||
const IMAGE = `${URL}/thumbnail.png`;
|
const IMAGE = `${URL}/thumbnail.png`;
|
||||||
|
|
||||||
@@ -27,7 +28,7 @@ export default defineNuxtConfig({
|
|||||||
],
|
],
|
||||||
meta: [
|
meta: [
|
||||||
{ name: "description", content: DESCRIPTION },
|
{ name: "description", content: DESCRIPTION },
|
||||||
{ name: "author", content: "pihkaal" },
|
{ name: "author", content: "Stevan Corre" },
|
||||||
{ name: "robots", content: "index, follow" },
|
{ name: "robots", content: "index, follow" },
|
||||||
{ name: "theme-color", content: "#181818" },
|
{ name: "theme-color", content: "#181818" },
|
||||||
{ property: "og:type", content: "website" },
|
{ property: "og:type", content: "website" },
|
||||||
@@ -56,7 +57,8 @@ export default defineNuxtConfig({
|
|||||||
innerHTML: JSON.stringify({
|
innerHTML: JSON.stringify({
|
||||||
"@context": "https://schema.org",
|
"@context": "https://schema.org",
|
||||||
"@type": "Person",
|
"@type": "Person",
|
||||||
name: "pihkaal",
|
name: "Stevan Corre",
|
||||||
|
alternateName: "pihkaal",
|
||||||
url: URL,
|
url: URL,
|
||||||
email: "hello@pihkaal.me",
|
email: "hello@pihkaal.me",
|
||||||
jobTitle: "Full-Stack Developer",
|
jobTitle: "Full-Stack Developer",
|
||||||
@@ -76,7 +78,7 @@ export default defineNuxtConfig({
|
|||||||
name: TITLE,
|
name: TITLE,
|
||||||
url: URL,
|
url: URL,
|
||||||
description: DESCRIPTION,
|
description: DESCRIPTION,
|
||||||
author: { "@type": "Person", name: "pihkaal", url: URL },
|
author: { "@type": "Person", name: "Stevan Corre", url: URL },
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
@@ -104,6 +106,13 @@ export default defineNuxtConfig({
|
|||||||
redirectOn: "root",
|
redirectOn: "root",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
ui: {
|
||||||
|
// JetBrains Mono is loaded from bunny.net in app.css
|
||||||
|
fonts: false,
|
||||||
|
experimental: {
|
||||||
|
componentDetection: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
image: {
|
image: {
|
||||||
quality: 80,
|
quality: 80,
|
||||||
format: ["webp"],
|
format: ["webp"],
|
||||||
|
|||||||
26
package.json
26
package.json
@@ -20,29 +20,29 @@
|
|||||||
"exifr": "^7.1.3",
|
"exifr": "^7.1.3",
|
||||||
"gsap": "3.13.0",
|
"gsap": "3.13.0",
|
||||||
"pinia": "3.0.4",
|
"pinia": "3.0.4",
|
||||||
"tailwindcss": "^4.1.18",
|
"tailwindcss": "^4.3.3",
|
||||||
"three": "^0.182.0",
|
"three": "^0.182.0",
|
||||||
"vue": "3.5.25",
|
"vue": "3.5.43",
|
||||||
"vue-router": "4.6.3",
|
"vue-router": "5.3.1",
|
||||||
"zod": "^4.2.1"
|
"zod": "^4.2.1"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.30.2",
|
"packageManager": "pnpm@10.30.2",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@nuxt/content": "3.8.2",
|
"@nuxt/content": "3.16.1",
|
||||||
"@nuxt/eslint": "1.10.0",
|
"@nuxt/eslint": "1.17.0",
|
||||||
"@nuxt/image": "2.0.0",
|
"@nuxt/image": "2.1.0",
|
||||||
"@nuxt/kit": "4.2.1",
|
"@nuxt/kit": "4.6.0",
|
||||||
"@nuxt/ui": "4.3.0",
|
"@nuxt/ui": "4.11.3",
|
||||||
"@nuxtjs/i18n": "10.2.1",
|
"@nuxtjs/i18n": "10.6.0",
|
||||||
"@nuxtjs/mdc": "0.18.4",
|
"@nuxtjs/mdc": "0.23.2",
|
||||||
"@pinia/nuxt": "0.11.3",
|
"@pinia/nuxt": "0.11.3",
|
||||||
"@types/node": "24.10.1",
|
"@types/node": "24.10.1",
|
||||||
"@types/three": "^0.182.0",
|
"@types/three": "^0.182.0",
|
||||||
"better-sqlite3": "12.4.1",
|
"better-sqlite3": "12.11.1",
|
||||||
"eslint": "9.39.1",
|
"eslint": "9.39.1",
|
||||||
"nuxt": "4.2.1",
|
"nuxt": "4.6.0",
|
||||||
"prettier": "3.6.2",
|
"prettier": "3.6.2",
|
||||||
"sharp": "^0.34.5",
|
"sharp": "^0.35.5",
|
||||||
"typescript": "5.9.3"
|
"typescript": "5.9.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
19603
pnpm-lock.yaml
generated
19603
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
BIN
public/cv.pdf
BIN
public/cv.pdf
Binary file not shown.
@@ -1,2 +1,4 @@
|
|||||||
User-Agent: *
|
User-Agent: *
|
||||||
Disallow:
|
Disallow:
|
||||||
|
|
||||||
|
Sitemap: https://pihkaal.me/sitemap.xml
|
||||||
|
|||||||
9
public/sitemap.xml
Normal file
9
public/sitemap.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
|
||||||
|
<url>
|
||||||
|
<loc>https://pihkaal.me/</loc>
|
||||||
|
</url>
|
||||||
|
<url>
|
||||||
|
<loc>https://pihkaal.me/gallery</loc>
|
||||||
|
</url>
|
||||||
|
</urlset>
|
||||||
Reference in New Issue
Block a user