Compare commits
No commits in common. "43645eb78212318a88992a5254ff8a7b948fce11" and "5b498430c829fd8de0560e5ce2afd5db01c73c33" have entirely different histories.
43645eb782
...
5b498430c8
|
|
@ -24,7 +24,6 @@
|
||||||
"downshift": "^9.3.2",
|
"downshift": "^9.3.2",
|
||||||
"embla-carousel-react": "^8.6.0",
|
"embla-carousel-react": "^8.6.0",
|
||||||
"file-type": "^22.0.1",
|
"file-type": "^22.0.1",
|
||||||
"fzstd": "^0.1.1",
|
|
||||||
"jsqr": "^1.4.0",
|
"jsqr": "^1.4.0",
|
||||||
"next": "16.2.3",
|
"next": "16.2.3",
|
||||||
"next-auth": "5.0.0-beta.30",
|
"next-auth": "5.0.0-beta.30",
|
||||||
|
|
|
||||||
|
|
@ -47,9 +47,6 @@ importers:
|
||||||
file-type:
|
file-type:
|
||||||
specifier: ^22.0.1
|
specifier: ^22.0.1
|
||||||
version: 22.0.1
|
version: 22.0.1
|
||||||
fzstd:
|
|
||||||
specifier: ^0.1.1
|
|
||||||
version: 0.1.1
|
|
||||||
jsqr:
|
jsqr:
|
||||||
specifier: ^1.4.0
|
specifier: ^1.4.0
|
||||||
version: 1.4.0
|
version: 1.4.0
|
||||||
|
|
@ -2205,9 +2202,6 @@ packages:
|
||||||
functions-have-names@1.2.3:
|
functions-have-names@1.2.3:
|
||||||
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==}
|
||||||
|
|
||||||
fzstd@0.1.1:
|
|
||||||
resolution: {integrity: sha512-dkuVSOKKwh3eas5VkJy1AW1vFpet8TA/fGmVA5krThl8YcOVE/8ZIoEA1+U1vEn5ckxxhLirSdY837azmbaNHA==}
|
|
||||||
|
|
||||||
generator-function@2.0.1:
|
generator-function@2.0.1:
|
||||||
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
resolution: {integrity: sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==}
|
||||||
engines: {node: '>= 0.4'}
|
engines: {node: '>= 0.4'}
|
||||||
|
|
@ -5602,8 +5596,6 @@ snapshots:
|
||||||
|
|
||||||
functions-have-names@1.2.3: {}
|
functions-have-names@1.2.3: {}
|
||||||
|
|
||||||
fzstd@0.1.1: {}
|
|
||||||
|
|
||||||
generator-function@2.0.1: {}
|
generator-function@2.0.1: {}
|
||||||
|
|
||||||
gensync@1.0.0-beta.2: {}
|
gensync@1.0.0-beta.2: {}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,2 @@
|
||||||
|
-- AlterTable
|
||||||
|
ALTER TABLE "miis" ADD COLUMN "miiData" BYTEA;
|
||||||
|
|
@ -1,2 +0,0 @@
|
||||||
-- AlterTable
|
|
||||||
ALTER TABLE "miis" ADD COLUMN "isFromSaveFile" BOOLEAN NOT NULL DEFAULT false;
|
|
||||||
|
|
@ -83,7 +83,7 @@ model Mii {
|
||||||
gender MiiGender?
|
gender MiiGender?
|
||||||
makeup MiiMakeup?
|
makeup MiiMakeup?
|
||||||
|
|
||||||
isFromSaveFile Boolean @default(false)
|
miiData Bytes?
|
||||||
|
|
||||||
firstName String?
|
firstName String?
|
||||||
lastName String?
|
lastName String?
|
||||||
|
|
|
||||||
|
Before Width: | Height: | Size: 13 KiB |
|
Before Width: | Height: | Size: 126 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 47 KiB |
|
Before Width: | Height: | Size: 7.5 KiB |
|
Before Width: | Height: | Size: 233 KiB After Width: | Height: | Size: 233 KiB |
|
Before Width: | Height: | Size: 97 KiB After Width: | Height: | Size: 97 KiB |
|
Before Width: | Height: | Size: 229 KiB After Width: | Height: | Size: 229 KiB |
|
Before Width: | Height: | Size: 151 KiB After Width: | Height: | Size: 151 KiB |
|
|
@ -23,7 +23,6 @@ import { SwitchMiiInstructions } from "@/types";
|
||||||
import { minifyInstructions } from "@/lib/switch";
|
import { minifyInstructions } from "@/lib/switch";
|
||||||
import { settings } from "@/lib/settings";
|
import { settings } from "@/lib/settings";
|
||||||
import { CharInfoEx } from "charinfo-ex";
|
import { CharInfoEx } from "charinfo-ex";
|
||||||
import { SwitchTomodachiLifeMii } from "@/lib/switch-tomodachi-life-mii";
|
|
||||||
|
|
||||||
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
const uploadsDirectory = path.join(process.cwd(), "uploads", "mii");
|
||||||
|
|
||||||
|
|
@ -49,7 +48,7 @@ const submitSchema = z.object({
|
||||||
// Save data way
|
// Save data way
|
||||||
miiDataFile: z
|
miiDataFile: z
|
||||||
.instanceof(File)
|
.instanceof(File)
|
||||||
.refine((blob) => blob.size < 1024 * 1024 * 1.5, "File too large") // TODO: actual size
|
.refine((blob) => blob.size < 1024 * 30, "File too large") // TODO: actual size
|
||||||
.optional(),
|
.optional(),
|
||||||
|
|
||||||
// Manual way
|
// Manual way
|
||||||
|
|
@ -204,15 +203,182 @@ export async function POST(request: NextRequest) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const miiDataFileBuffer = miiDataFile ? await miiDataFile.arrayBuffer() : undefined;
|
const miiDataFileBuffer = miiDataFile ? await miiDataFile.arrayBuffer() : undefined;
|
||||||
|
const miiDataFileArray = miiDataFileBuffer ? new Uint8Array(miiDataFileBuffer) : undefined;
|
||||||
const miiData = miiDataFileBuffer ? CharInfoEx.FromShareMiiFileArrayBuffer(miiDataFileBuffer) : undefined;
|
const miiData = miiDataFileBuffer ? CharInfoEx.FromShareMiiFileArrayBuffer(miiDataFileBuffer) : undefined;
|
||||||
|
|
||||||
let parsedSwitchMii: SwitchTomodachiLifeMii | undefined = undefined;
|
|
||||||
|
|
||||||
if (way === "savedata") {
|
if (way === "savedata") {
|
||||||
if (!miiData || !miiDataFileBuffer) return rateLimit.sendResponse({ error: "No valid Mii data provided" }, 400);
|
if (!miiData) return rateLimit.sendResponse({ error: "No mii data provided" }, 400);
|
||||||
|
|
||||||
parsedSwitchMii = new SwitchTomodachiLifeMii(miiDataFileBuffer, miiData);
|
const instructions: Partial<SwitchMiiInstructions> = {
|
||||||
minifiedInstructions = parsedSwitchMii.toInstructions();
|
head: {
|
||||||
|
type: miiData.facelineType,
|
||||||
|
skinColor: miiData.facelineColor,
|
||||||
|
},
|
||||||
|
hair: {
|
||||||
|
set: miiData.hairType,
|
||||||
|
bangs: miiData.hairTypeFront,
|
||||||
|
back: miiData.hairTypeBack,
|
||||||
|
color: miiData.hairColor0,
|
||||||
|
subColor: miiData.hairColor1,
|
||||||
|
subColor2: miiData.hairColor0, // TODO: check
|
||||||
|
style: miiData.hairStyle,
|
||||||
|
// uh oh, no flipped
|
||||||
|
isFlipped: false,
|
||||||
|
},
|
||||||
|
eyebrows: {
|
||||||
|
type: miiData.eyebrowType,
|
||||||
|
color: miiData.eyebrowColor,
|
||||||
|
height: miiData.eyebrowY - 10,
|
||||||
|
distance: miiData.eyebrowX - 4,
|
||||||
|
rotation: miiData.eyebrowRotate - 6,
|
||||||
|
size: miiData.eyebrowScale - 4,
|
||||||
|
stretch: miiData.eyebrowAspect - 3,
|
||||||
|
},
|
||||||
|
eyes: {
|
||||||
|
main: {
|
||||||
|
type: miiData.eyeType,
|
||||||
|
color: miiData.eyeColor,
|
||||||
|
height: miiData.eyeY - 12,
|
||||||
|
distance: miiData.eyeX - 2,
|
||||||
|
rotation: miiData.eyeRotate - 4,
|
||||||
|
size: miiData.eyeScale - 4,
|
||||||
|
stretch: miiData.eyeAspect - 3,
|
||||||
|
},
|
||||||
|
eyelashesTop: {
|
||||||
|
type: miiData.eyelashUpperType,
|
||||||
|
height: miiData.eyelashUpperY,
|
||||||
|
distance: miiData.eyelashUpperX,
|
||||||
|
rotation: miiData.eyelashUpperRotate,
|
||||||
|
size: miiData.eyelashUpperScale,
|
||||||
|
stretch: miiData.eyelashUpperAspect,
|
||||||
|
},
|
||||||
|
eyelashesBottom: {
|
||||||
|
type: miiData.eyelashLowerType,
|
||||||
|
height: miiData.eyelashLowerY,
|
||||||
|
distance: miiData.eyelashLowerX,
|
||||||
|
rotation: miiData.eyelashLowerRotate,
|
||||||
|
size: miiData.eyelashLowerScale,
|
||||||
|
stretch: miiData.eyelashLowerAspect,
|
||||||
|
},
|
||||||
|
eyelidTop: {
|
||||||
|
type: miiData.eyelidUpperType,
|
||||||
|
height: miiData.eyelidUpperY,
|
||||||
|
distance: miiData.eyelidUpperX,
|
||||||
|
rotation: miiData.eyelidUpperRotate,
|
||||||
|
size: miiData.eyelidUpperScale,
|
||||||
|
stretch: miiData.eyelidUpperAspect,
|
||||||
|
},
|
||||||
|
eyelidBottom: {
|
||||||
|
type: miiData.eyelidLowerType,
|
||||||
|
height: miiData.eyelidLowerY,
|
||||||
|
distance: miiData.eyelidLowerX,
|
||||||
|
rotation: miiData.eyelidLowerRotate,
|
||||||
|
size: miiData.eyelidLowerScale,
|
||||||
|
stretch: miiData.eyelidLowerAspect,
|
||||||
|
},
|
||||||
|
eyeliner: {
|
||||||
|
type: miiData.eyeShadowColor != 0,
|
||||||
|
color: miiData.eyeShadowColor,
|
||||||
|
},
|
||||||
|
pupil: {
|
||||||
|
type: miiData.eyeHighlightType,
|
||||||
|
height: miiData.eyeHighlightY,
|
||||||
|
distance: miiData.eyeHighlightX,
|
||||||
|
rotation: miiData.eyeHighlightRotate,
|
||||||
|
size: miiData.eyeHighlightScale,
|
||||||
|
stretch: miiData.eyeHighlightAspect,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
nose: {
|
||||||
|
type: miiData.noseType,
|
||||||
|
height: miiData.noseY - 9,
|
||||||
|
size: miiData.noseScale - 4,
|
||||||
|
},
|
||||||
|
lips: {
|
||||||
|
type: miiData.mouthType,
|
||||||
|
color: miiData.mouthColor,
|
||||||
|
height: miiData.mouthY - 13,
|
||||||
|
rotation: miiData.mouthRotate,
|
||||||
|
size: miiData.mouthScale - 4,
|
||||||
|
stretch: miiData.mouthAspect - 3,
|
||||||
|
// uh oh, no lipstick
|
||||||
|
hasLipstick: false,
|
||||||
|
},
|
||||||
|
ears: {
|
||||||
|
type: miiData.earType,
|
||||||
|
height: miiData.earY - 4,
|
||||||
|
size: miiData.earScale - 2,
|
||||||
|
},
|
||||||
|
glasses: {
|
||||||
|
type: miiData.glassType1,
|
||||||
|
type2: miiData.glassType2,
|
||||||
|
ringColor: miiData.glassColor1,
|
||||||
|
shadesColor: miiData.glassColor2,
|
||||||
|
height: miiData.glassY - 11,
|
||||||
|
size: miiData.glassScale - 4,
|
||||||
|
stretch: miiData.glassAspect - 3,
|
||||||
|
},
|
||||||
|
other: {
|
||||||
|
wrinkles1: {
|
||||||
|
type: miiData.wrinkleLowerType,
|
||||||
|
height: miiData.wrinkleLowerY - 15,
|
||||||
|
distance: miiData.wrinkleLowerX - 2,
|
||||||
|
size: miiData.wrinkleLowerScale - 6,
|
||||||
|
stretch: miiData.wrinkleLowerAspect - 3,
|
||||||
|
},
|
||||||
|
wrinkles2: {
|
||||||
|
type: miiData.wrinkleUpperType,
|
||||||
|
height: miiData.wrinkleUpperY - 23,
|
||||||
|
distance: miiData.wrinkleUpperX - 7,
|
||||||
|
size: miiData.wrinkleUpperScale - 6,
|
||||||
|
stretch: miiData.wrinkleUpperAspect - 3,
|
||||||
|
},
|
||||||
|
beard: {
|
||||||
|
type: miiData.beardType,
|
||||||
|
color: miiData.beardColor,
|
||||||
|
},
|
||||||
|
moustache: {
|
||||||
|
type: miiData.mustacheType,
|
||||||
|
color: miiData.mustacheColor,
|
||||||
|
height: miiData.mustacheY - 10,
|
||||||
|
// uh oh, no flipped
|
||||||
|
isFlipped: false,
|
||||||
|
size: miiData.mustacheScale - 4,
|
||||||
|
stretch: miiData.mustacheAspect - 3,
|
||||||
|
},
|
||||||
|
goatee: {
|
||||||
|
type: miiData.beardShortType,
|
||||||
|
color: miiData.beardShortColor,
|
||||||
|
},
|
||||||
|
mole: {
|
||||||
|
type: miiData.moleX != 0,
|
||||||
|
height: miiData.moleY - 20,
|
||||||
|
distance: miiData.moleX - 2,
|
||||||
|
size: miiData.moleScale - 4,
|
||||||
|
},
|
||||||
|
eyeShadow: {
|
||||||
|
type: miiData.makeup0,
|
||||||
|
color: miiData.makeup0Color,
|
||||||
|
height: miiData.makeup0Y - 12,
|
||||||
|
distance: miiData.makeup0X - 1,
|
||||||
|
size: miiData.makeup0Scale - 6,
|
||||||
|
stretch: miiData.makeup0Aspect - 3,
|
||||||
|
},
|
||||||
|
blush: {
|
||||||
|
type: miiData.makeup1,
|
||||||
|
color: miiData.makeup1Color,
|
||||||
|
height: miiData.makeup1Y - 19,
|
||||||
|
distance: miiData.makeup1X - 6,
|
||||||
|
size: miiData.makeup1Scale - 5,
|
||||||
|
stretch: miiData.makeup1Aspect - 3,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
height: miiData.height,
|
||||||
|
weight: miiData.build,
|
||||||
|
// uh oh, no dating prefs, birthday, voice, personality
|
||||||
|
};
|
||||||
|
|
||||||
|
minifiedInstructions = minifyInstructions(instructions);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create Mii in database
|
// Create Mii in database
|
||||||
|
|
@ -239,7 +405,7 @@ export async function POST(request: NextRequest) {
|
||||||
youtubeId,
|
youtubeId,
|
||||||
makeup: makeup ?? "PARTIAL",
|
makeup: makeup ?? "PARTIAL",
|
||||||
instructions: minifiedInstructions,
|
instructions: minifiedInstructions,
|
||||||
...(way === "savedata" && { isFromSaveFile: true }),
|
...(way === "savedata" && { miiData: miiDataFileArray }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
@ -277,20 +443,6 @@ export async function POST(request: NextRequest) {
|
||||||
.toBuffer();
|
.toBuffer();
|
||||||
const fileLocation = path.join(miiUploadsDirectory, "features.png");
|
const fileLocation = path.join(miiUploadsDirectory, "features.png");
|
||||||
await fs.writeFile(fileLocation, pngBuffer);
|
await fs.writeFile(fileLocation, pngBuffer);
|
||||||
} else if (way === "savedata" && miiDataFileBuffer) {
|
|
||||||
const fileLocation = path.join(miiUploadsDirectory, "data.ltd");
|
|
||||||
await fs.writeFile(fileLocation, Buffer.from(miiDataFileBuffer));
|
|
||||||
|
|
||||||
// Save face paint image
|
|
||||||
if (parsedSwitchMii) {
|
|
||||||
const pngBuffer = await parsedSwitchMii.extractFacePaintImage();
|
|
||||||
if (pngBuffer) {
|
|
||||||
const fileLocation = path.join(miiUploadsDirectory, "features.png"); // Save as features because it isn't used
|
|
||||||
await fs.writeFile(fileLocation, pngBuffer);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
return rateLimit.sendResponse({ error: "Failed to extract Switch Mii data" }, 500);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,36 +0,0 @@
|
||||||
import { NextRequest, NextResponse } from "next/server";
|
|
||||||
|
|
||||||
import fs from "fs/promises";
|
|
||||||
import path from "path";
|
|
||||||
|
|
||||||
import { prisma } from "@/lib/prisma";
|
|
||||||
import { RateLimit } from "@/lib/rate-limit";
|
|
||||||
import { idSchema } from "@/lib/schemas";
|
|
||||||
|
|
||||||
export async function GET(request: NextRequest, { params }: { params: { id: string } }) {
|
|
||||||
const rateLimit = new RateLimit(request, 4, "/mii/download");
|
|
||||||
const check = await rateLimit.handle();
|
|
||||||
if (check) return check;
|
|
||||||
|
|
||||||
const { id: slugId } = await params;
|
|
||||||
const parsed = idSchema.safeParse(slugId);
|
|
||||||
if (!parsed.success) return rateLimit.sendResponse({ error: parsed.error.issues[0].message }, 400);
|
|
||||||
const miiId = parsed.data;
|
|
||||||
|
|
||||||
const mii = await prisma.mii.findUnique({
|
|
||||||
where: { id: miiId },
|
|
||||||
});
|
|
||||||
if (!mii) return new NextResponse("Not found", { status: 404 });
|
|
||||||
|
|
||||||
try {
|
|
||||||
const buffer = await fs.readFile(path.join(process.cwd(), "uploads", "mii", miiId.toString(), "data.ltd"));
|
|
||||||
return new NextResponse(buffer, {
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/octet-stream",
|
|
||||||
"Content-Disposition": `attachment; filename="${mii.name}.ltd"`,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} catch {
|
|
||||||
return rateLimit.sendResponse({ error: "File not found" }, 404);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -164,15 +164,7 @@ export default async function MiiPage({ params }: Props) {
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
!mii.miiData && (
|
||||||
{mii.isFromSaveFile && (
|
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mb-4 w-full">
|
|
||||||
<hr className="grow border-zinc-300" />
|
|
||||||
<span>Face Paint Texture</span>
|
|
||||||
<hr className="grow border-zinc-300" />
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<ImageViewer
|
<ImageViewer
|
||||||
src={`/mii/${mii.id}/image?type=features`}
|
src={`/mii/${mii.id}/image?type=features`}
|
||||||
alt="mii features"
|
alt="mii features"
|
||||||
|
|
@ -180,7 +172,7 @@ export default async function MiiPage({ params }: Props) {
|
||||||
height={300}
|
height={300}
|
||||||
className="rounded-lg hover:brightness-90 mb-4 transition-all"
|
className="rounded-lg hover:brightness-90 mb-4 transition-all"
|
||||||
/>
|
/>
|
||||||
</>
|
)
|
||||||
)}
|
)}
|
||||||
<hr className="w-full border-t-2 border-t-amber-400" />
|
<hr className="w-full border-t-2 border-t-amber-400" />
|
||||||
|
|
||||||
|
|
@ -379,15 +371,9 @@ export default async function MiiPage({ params }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Buttons */}
|
{/* Buttons */}
|
||||||
<div className="flex gap-4 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
|
<div className="flex gap-3 w-fit bg-amber-50 border-2 border-amber-500 rounded-2xl shadow-lg p-4 text-3xl text-orange-400 max-md:place-self-center *:size-12 *:flex *:flex-col *:items-center *:gap-1 **:transition-discrete **:duration-150 *:hover:brightness-75 *:hover:scale-[1.08] *:[&_span]:text-xs">
|
||||||
<AuthorButtons mii={mii} />
|
<AuthorButtons mii={mii} />
|
||||||
|
|
||||||
{mii.isFromSaveFile && (
|
|
||||||
<Link aria-label="Download Mii" href={`/mii/${mii.id}/download`} download>
|
|
||||||
<Icon icon="material-symbols:download" />
|
|
||||||
<span>Download</span>
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
<ShareMiiButton miiId={mii.id} />
|
<ShareMiiButton miiId={mii.id} />
|
||||||
<Link aria-label="Report Mii" href={`/report/mii/${mii.id}`}>
|
<Link aria-label="Report Mii" href={`/report/mii/${mii.id}`}>
|
||||||
<Icon icon="material-symbols:flag-rounded" />
|
<Icon icon="material-symbols:flag-rounded" />
|
||||||
|
|
@ -405,11 +391,7 @@ export default async function MiiPage({ params }: Props) {
|
||||||
Instructions
|
Instructions
|
||||||
</h2>
|
</h2>
|
||||||
|
|
||||||
<p className="text-xs text-amber-800">
|
<p className="text-xs text-amber-800">All instructions are based off of the default Male Mii.</p>
|
||||||
All instructions are based off of the default Male Mii.
|
|
||||||
<br />
|
|
||||||
{mii.isFromSaveFile && "If you're on modded/emulator, you can download the .ltd file above."}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{mii.youtubeId && (
|
{mii.youtubeId && (
|
||||||
|
|
@ -424,7 +406,7 @@ export default async function MiiPage({ params }: Props) {
|
||||||
></iframe>
|
></iframe>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} isUsingSaveFile={mii.isFromSaveFile} />
|
<MiiInstructions instructions={mii.instructions as Partial<SwitchMiiInstructions>} isUsingSaveFile={mii.miiData !== null} />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -252,7 +252,6 @@ export default function MiiInstructions({ instructions, isUsingSaveFile }: Props
|
||||||
{(height || weight || datingPreferences || voice || personality) && (
|
{(height || weight || datingPreferences || voice || personality) && (
|
||||||
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max mb-4">
|
<div className="p-3 border-l-4 border-amber-400 bg-amber-100/50 rounded-r-lg py-2.5 text-amber-950 w-max mb-4">
|
||||||
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
<h3 className="font-semibold text-xl text-amber-800 mb-1">Misc</h3>
|
||||||
<p className="text-xs text-amber-800 mb-4">These contain sliders: 0 is middle, positive is to the right, negative is to the left</p>
|
|
||||||
|
|
||||||
<table className="w-full">
|
<table className="w-full">
|
||||||
<tbody>
|
<tbody>
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import { Icon } from "@iconify/react";
|
||||||
import LikeButton from "@/components/like-button";
|
import LikeButton from "@/components/like-button";
|
||||||
import DeleteMiiButton from "../delete-mii-button";
|
import DeleteMiiButton from "../delete-mii-button";
|
||||||
import Carousel from "@/components/carousel";
|
import Carousel from "@/components/carousel";
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
|
miis: Prisma.MiiGetPayload<{ include: { user: { select: { id: true; name: true } }; _count: { select: { likedBy: true } } } }>[];
|
||||||
|
|
@ -45,16 +44,20 @@ export default function MiiGrid({ miis, userId, parentPage }: Props) {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link href={`/mii/${mii.id}`} className="overflow-hidden rounded-xl bg-zinc-300 shrink-0">
|
<Carousel
|
||||||
<Image src={`/mii/${mii.id}/image?type=mii`} width={240} height={160} alt="mii image" className="w-full h-auto aspect-3/2 object-contain" />
|
images={[
|
||||||
</Link>
|
`/mii/${mii.id}/image?type=mii`,
|
||||||
|
...(mii.platform === "THREE_DS" ? [`/mii/${mii.id}/image?type=qr-code`] : [`/mii/${mii.id}/image?type=features`]),
|
||||||
|
...Array.from({ length: mii.imageCount }, (_, index) => `/mii/${mii.id}/image?type=image${index}`),
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
|
||||||
<div className="p-4 flex flex-col gap-1 h-full">
|
<div className="p-4 flex flex-col gap-1 h-full">
|
||||||
<div className="flex justify-between items-start">
|
<div className="flex justify-between items-center">
|
||||||
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
|
<Link href={`/mii/${mii.id}`} className="relative font-bold text-2xl line-clamp-1 w-full text-ellipsis wrap-break-word" title={mii.name}>
|
||||||
{mii.name}
|
{mii.name}
|
||||||
</Link>
|
</Link>
|
||||||
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="text-[1.25rem] opacity-25">
|
<div title={mii.platform === "SWITCH" ? "Switch" : "3DS"} className="-mr-3 text-[1.25rem] opacity-25">
|
||||||
{mii.platform === "SWITCH" ? (
|
{mii.platform === "SWITCH" ? (
|
||||||
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
<Icon icon="cib:nintendo-switch" className="text-red-400" />
|
||||||
) : (
|
) : (
|
||||||
|
|
|
||||||
|
|
@ -429,14 +429,16 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
|
||||||
<div className="grid grid-cols-2 gap-4 w-full">
|
<div className="grid grid-cols-2 gap-4 w-full">
|
||||||
<button
|
<button
|
||||||
onClick={() => setWay("savedata")}
|
onClick={() => setWay("savedata")}
|
||||||
|
// aria-label={tutorial.title + " tutorial"}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "savedata" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
|
className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "savedata" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
|
||||||
>
|
>
|
||||||
ShareMii file (.ltd) (Modded)
|
.ltd file
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
onClick={() => setWay("manual")}
|
onClick={() => setWay("manual")}
|
||||||
|
// aria-label={tutorial.title + " tutorial"}
|
||||||
type="button"
|
type="button"
|
||||||
className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "manual" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
|
className={`flex flex-col justify-center items-center rounded-xl p-4 shadow-md border-2 cursor-pointer text-center text-sm transition hover:scale-[1.03] ${way === "manual" ? "bg-cyan-100 border-cyan-600" : "bg-zinc-50 border-zinc-300 hover:bg-cyan-100 hover:border-cyan-600"}`}
|
||||||
>
|
>
|
||||||
|
|
@ -444,7 +446,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<p className="text-xs text-zinc-400 text-center mt-2">Select a method above and click 'How to?' to view the tutorial.</p>
|
<p className="text-xs text-zinc-400 text-center mt-2">Click on a way to see tutorials for them</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* (Switch Only) Mii Screenshots */}
|
{/* (Switch Only) Mii Screenshots */}
|
||||||
|
|
@ -501,7 +503,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
|
||||||
|
|
||||||
{way === "manual" && (
|
{way === "manual" && (
|
||||||
<>
|
<>
|
||||||
<SwitchSubmitTutorialButton type="manual" />
|
<SwitchSubmitTutorialButton />
|
||||||
<p className="text-xs text-zinc-400 text-center">A tutorial on how to screenshot the features is above.</p>
|
<p className="text-xs text-zinc-400 text-center">A tutorial on how to screenshot the features is above.</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
@ -536,13 +538,12 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
|
||||||
<div className={`${platform === "SWITCH" && way === "savedata" ? "" : "hidden"}`}>
|
<div className={`${platform === "SWITCH" && way === "savedata" ? "" : "hidden"}`}>
|
||||||
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
<div className="flex items-center gap-4 text-zinc-500 text-sm font-medium mt-8 mb-2">
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
<span>ShareMii File</span>
|
<span>Save Data</span>
|
||||||
<hr className="grow border-zinc-300" />
|
<hr className="grow border-zinc-300" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col items-center gap-2">
|
<div className="flex flex-col items-center gap-2">
|
||||||
<SwitchFileUpload type="file" text="your Mii's .ltd file" file={miiDataFile} setFile={setMiiDataFile} />
|
<SwitchFileUpload type="file" text="your Mii's .ltd file" file={miiDataFile} setFile={setMiiDataFile} />
|
||||||
<SwitchSubmitTutorialButton type="savedata" />
|
|
||||||
|
|
||||||
{/* YouTube */}
|
{/* YouTube */}
|
||||||
<div className="w-full grid grid-cols-3 items-center">
|
<div className="w-full grid grid-cols-3 items-center">
|
||||||
|
|
@ -598,7 +599,7 @@ export default function SubmitForm({ inQueueMiisCount }: Props) {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<MiiEditor instructions={instructions} />
|
<MiiEditor instructions={instructions} />
|
||||||
<SwitchSubmitTutorialButton type="manual" />
|
<SwitchSubmitTutorialButton />
|
||||||
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
|
<span className="text-xs text-zinc-400 text-center px-32 max-sm:px-8">
|
||||||
Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction.
|
Mii editor may be inaccurate. Instructions are recommended, but not required - you do not have to add every instruction.
|
||||||
</span>
|
</span>
|
||||||
|
|
|
||||||
|
|
@ -5,13 +5,11 @@ import { useEffect, useState } from "react";
|
||||||
import useEmblaCarousel from "embla-carousel-react";
|
import useEmblaCarousel from "embla-carousel-react";
|
||||||
import { Icon } from "@iconify/react";
|
import { Icon } from "@iconify/react";
|
||||||
import confetti from "canvas-confetti";
|
import confetti from "canvas-confetti";
|
||||||
import Link from "next/link";
|
|
||||||
|
|
||||||
interface Slide {
|
interface Slide {
|
||||||
// step is never used, undefined is assumed as a step
|
// step is never used, undefined is assumed as a step
|
||||||
type?: "start" | "step" | "finish";
|
type?: "start" | "step" | "finish";
|
||||||
text?: string;
|
text?: string;
|
||||||
link?: string;
|
|
||||||
imageSrc?: string;
|
imageSrc?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -161,13 +159,7 @@ export default function Tutorial({ tutorials, isOpen, setIsOpen }: Props) {
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{slide.link ? (
|
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
||||||
<Link href={slide.link} className="text-sm text-blue-600 mb-2 text-center">
|
|
||||||
{slide.text}
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<p className="text-sm text-zinc-500 mb-2 text-center">{slide.text}</p>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Image
|
<Image
|
||||||
src={slide.imageSrc ?? "/missing.svg"}
|
src={slide.imageSrc ?? "/missing.svg"}
|
||||||
|
|
|
||||||
|
|
@ -20,47 +20,23 @@ export default function SwitchAddMiiTutorialButton() {
|
||||||
<Tutorial
|
<Tutorial
|
||||||
tutorials={[
|
tutorials={[
|
||||||
{
|
{
|
||||||
title: "ShareMii (Modded)",
|
title: "Adding Mii",
|
||||||
thumbnail: "/tutorial/switch/adding-mii/modded/thumbnail.png",
|
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "start" },
|
|
||||||
{
|
|
||||||
text: "1. Download ShareMii - click here for link",
|
|
||||||
link: "https://gamebanana.com/tools/22305",
|
|
||||||
imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "2. Download the .ltd file, it is above the instructions next to all the other buttons",
|
|
||||||
imageSrc: "/tutorial/switch/adding-mii/modded/step2.png",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "3. Follow the instructions by the creator (scroll down to importing) - click here for link",
|
|
||||||
link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
|
|
||||||
imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
|
|
||||||
},
|
|
||||||
{ type: "finish" },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
title: "Manual",
|
|
||||||
thumbnail: "/tutorial/switch/adding-mii/manual/thumbnail.png",
|
|
||||||
steps: [
|
|
||||||
{ type: "start" },
|
|
||||||
{
|
{
|
||||||
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
text: "1. Press X to open the menu, then select 'Add a Mii'",
|
||||||
imageSrc: "/tutorial/switch/adding-mii/manual/step1.jpg",
|
imageSrc: "/tutorial/switch/adding-mii/step1.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "2. Press 'From scratch' and choose the Male template",
|
text: "2. Press 'From scratch' and choose the Male template",
|
||||||
imageSrc: "/tutorial/switch/adding-mii/manual/step2.jpg",
|
imageSrc: "/tutorial/switch/adding-mii/step2.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor (This won't work if the Mii is from a save file! You can see the icons in the instructions)",
|
text: "3. Click on the features image on this page to zoom it in and add all features on the mii editor",
|
||||||
imageSrc: "/tutorial/switch/adding-mii/manual/step3.png",
|
imageSrc: "/tutorial/switch/adding-mii/step3.png",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "4. If the Mii has instructions, follow them (not all instructions will be there if not from save data, check next slide for more)",
|
text: "4. If the author added instructions, follow them (not all instructions will be there, check next slide for more)",
|
||||||
imageSrc: "/tutorial/switch/adding-mii/manual/step4.jpg",
|
imageSrc: "/tutorial/switch/adding-mii/step4.jpg",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
text: "5. For instructions like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||||
|
|
|
||||||
|
|
@ -4,11 +4,7 @@ import { useState } from "react";
|
||||||
import { createPortal } from "react-dom";
|
import { createPortal } from "react-dom";
|
||||||
import Tutorial from ".";
|
import Tutorial from ".";
|
||||||
|
|
||||||
interface Props {
|
export default function SwitchSubmitTutorialButton() {
|
||||||
type: "savedata" | "manual";
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function SwitchSubmitTutorialButton({ type }: Props) {
|
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
const [isOpen, setIsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -23,40 +19,25 @@ export default function SwitchSubmitTutorialButton({ type }: Props) {
|
||||||
tutorials={[
|
tutorials={[
|
||||||
{
|
{
|
||||||
title: "Submitting",
|
title: "Submitting",
|
||||||
steps:
|
steps: [
|
||||||
type === "savedata"
|
{
|
||||||
? [
|
text: "1. Press X to open the menu, then select 'Residents'",
|
||||||
{
|
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
||||||
text: "1. Download ShareMii - click here for link",
|
},
|
||||||
link: "https://gamebanana.com/tools/22305",
|
{
|
||||||
imageSrc: "/tutorial/switch/adding-mii/modded/step1.jpg",
|
text: "2. Find the Mii you want to submit and edit it",
|
||||||
},
|
imageSrc: "/tutorial/switch/submitting/step2.jpg",
|
||||||
{
|
},
|
||||||
text: "2. Follow the instructions by the creator (scroll down to exporting) - click here for link",
|
{
|
||||||
link: "https://docs.google.com/document/d/e/2PACX-1vRSaPbTe0pijDSETzdeGhvQ7zYHlx9Qnxn7WdUqG9cveZYyk405A0LSbYnl8ygTNI_ZZqMrIZLeHenr/pub",
|
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
|
||||||
imageSrc: "/tutorial/switch/adding-mii/modded/step3.jpg",
|
imageSrc: "/tutorial/switch/submitting/step3.jpg",
|
||||||
},
|
},
|
||||||
{ type: "finish" },
|
{
|
||||||
]
|
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
||||||
: [
|
imageSrc: "/tutorial/switch/step4.jpg",
|
||||||
{
|
},
|
||||||
text: "1. Press X to open the menu, then select 'Residents'",
|
{ type: "finish" },
|
||||||
imageSrc: "/tutorial/switch/submitting/step1.jpg",
|
],
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "2. Find the Mii you want to submit and edit it",
|
|
||||||
imageSrc: "/tutorial/switch/submitting/step2.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "3. Press Y to open the features list, then take a screenshot and upload to this submit form",
|
|
||||||
imageSrc: "/tutorial/switch/submitting/step3.jpg",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
text: "4. Adding Mii colors and settings is recommended. All instructions are optional; for values like height or distance, use the number of button clicks (positive for buttons on right, negative for buttons on left)",
|
|
||||||
imageSrc: "/tutorial/switch/step4.jpg",
|
|
||||||
},
|
|
||||||
{ type: "finish" },
|
|
||||||
],
|
|
||||||
},
|
},
|
||||||
]}
|
]}
|
||||||
isOpen={isOpen}
|
isOpen={isOpen}
|
||||||
|
|
|
||||||
|
|
@ -1,133 +0,0 @@
|
||||||
// TypeScript implementation of https://github.com/Aclios/pyswizzle/blob/main/src/pyswizzle/pyswizzle.py
|
|
||||||
type Grid = Uint8Array[][];
|
|
||||||
|
|
||||||
export class BytesDeswizzle {
|
|
||||||
private data: Uint8Array;
|
|
||||||
private deswizzleDataList: [number, 0 | 1][];
|
|
||||||
private readSize: number;
|
|
||||||
private readPerTileCount: number;
|
|
||||||
private tileCount: number;
|
|
||||||
private tilePerWidth: number;
|
|
||||||
private dataReadIdx: number;
|
|
||||||
|
|
||||||
constructor(data: Uint8Array, imSize: [number, number], blockSize: [number, number], bytesPerBlock: number, swizzleMode: number) {
|
|
||||||
this.data = data;
|
|
||||||
const datasize = data.length;
|
|
||||||
const [imWidth, imHeight] = imSize;
|
|
||||||
const [blockWidth, blockHeight] = blockSize;
|
|
||||||
|
|
||||||
const expectedDataSize = Math.floor((imWidth * imHeight) / (blockWidth * blockHeight)) * bytesPerBlock;
|
|
||||||
|
|
||||||
if (expectedDataSize !== datasize)
|
|
||||||
throw new Error(
|
|
||||||
`Error: Invalid data size.\nExpected datasize (according to image and format specifications): ${expectedDataSize}\nActual datasize: ${datasize}`,
|
|
||||||
);
|
|
||||||
|
|
||||||
const tileDatasize = 512 * 2 ** swizzleMode;
|
|
||||||
const tileWidth = Math.floor(64 / bytesPerBlock) * blockWidth;
|
|
||||||
const tileHeight = 8 * blockHeight * 2 ** swizzleMode;
|
|
||||||
this.deswizzleDataList = [
|
|
||||||
[2, 0],
|
|
||||||
[2, 1],
|
|
||||||
[4, 0],
|
|
||||||
[2, 1],
|
|
||||||
[2 ** swizzleMode, 0],
|
|
||||||
];
|
|
||||||
this.readSize = 16;
|
|
||||||
this.readPerTileCount = 32 * 2 ** swizzleMode;
|
|
||||||
|
|
||||||
if (datasize % tileDatasize !== 0)
|
|
||||||
throw new Error(
|
|
||||||
`Error: Invalid data size. The data size should be a multiple of ${tileDatasize}, while the given datasize is ${datasize}. Height and/or width padding may be required in the original image.`,
|
|
||||||
);
|
|
||||||
|
|
||||||
this.tileCount = Math.floor(datasize / tileDatasize);
|
|
||||||
|
|
||||||
if (imWidth % tileWidth !== 0)
|
|
||||||
throw new Error(`Error: with the current parameters, image width should be a multiple of ${tileWidth}, but the given width is ${imWidth}`);
|
|
||||||
if (imHeight % tileHeight !== 0)
|
|
||||||
throw new Error(`Error: with the current parameters, image height should be a multiple of ${tileHeight}, but the given height is ${imHeight}`);
|
|
||||||
|
|
||||||
this.tilePerWidth = Math.floor(imWidth / tileWidth);
|
|
||||||
this.dataReadIdx = 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
private getTileData(): Grid[] {
|
|
||||||
const arrayList: Grid[] = [];
|
|
||||||
for (let i = 0; i < this.readPerTileCount; i++) {
|
|
||||||
arrayList.push([[this.data.slice(this.dataReadIdx, this.dataReadIdx + this.readSize)]]);
|
|
||||||
this.dataReadIdx += this.readSize;
|
|
||||||
}
|
|
||||||
return arrayList;
|
|
||||||
}
|
|
||||||
|
|
||||||
private concatArrays(arrayList: Grid[], sectionNumber: number, axis: 0 | 1): Grid[] {
|
|
||||||
const newArrayList: Grid[] = [];
|
|
||||||
let idx = 0;
|
|
||||||
|
|
||||||
for (let i = 0; i < Math.floor(arrayList.length / sectionNumber); i++) {
|
|
||||||
const slice = arrayList.slice(idx, idx + sectionNumber);
|
|
||||||
let newGrid: Grid;
|
|
||||||
|
|
||||||
if (axis === 0) {
|
|
||||||
// np.concatenate(..., axis=0)
|
|
||||||
newGrid = [];
|
|
||||||
for (const grid of slice) {
|
|
||||||
newGrid.push(...grid);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// np.concatenate(..., axis=1)
|
|
||||||
newGrid = [];
|
|
||||||
for (let r = 0; r < slice[0].length; r++) {
|
|
||||||
const newRow: Uint8Array[] = [];
|
|
||||||
for (const grid of slice) {
|
|
||||||
newRow.push(...grid[r]);
|
|
||||||
}
|
|
||||||
newGrid.push(newRow);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
newArrayList.push(newGrid);
|
|
||||||
idx += sectionNumber;
|
|
||||||
}
|
|
||||||
|
|
||||||
return newArrayList;
|
|
||||||
}
|
|
||||||
|
|
||||||
private deswizzleTile(): Grid {
|
|
||||||
let arrayList = this.getTileData();
|
|
||||||
for (const deswizzleData of this.deswizzleDataList) {
|
|
||||||
arrayList = this.concatArrays(arrayList, deswizzleData[0], deswizzleData[1]);
|
|
||||||
}
|
|
||||||
return arrayList[0];
|
|
||||||
}
|
|
||||||
|
|
||||||
public deswizzle(): Uint8Array {
|
|
||||||
const tileList: Grid[] = [];
|
|
||||||
for (let i = 0; i < this.tileCount; i++) {
|
|
||||||
tileList.push(this.deswizzleTile());
|
|
||||||
}
|
|
||||||
|
|
||||||
const tileListWidthConcat = this.concatArrays(tileList, this.tilePerWidth, 1);
|
|
||||||
const deswizzledGrid = this.concatArrays(tileListWidthConcat, tileListWidthConcat.length, 0)[0];
|
|
||||||
|
|
||||||
// tobytes()
|
|
||||||
const deswizzledData = new Uint8Array(this.data.length);
|
|
||||||
let offset = 0;
|
|
||||||
|
|
||||||
for (const row of deswizzledGrid) {
|
|
||||||
for (const chunk of row) {
|
|
||||||
deswizzledData.set(chunk, offset);
|
|
||||||
offset += chunk.length;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (deswizzledData.length !== this.data.length) {
|
|
||||||
throw new Error(
|
|
||||||
`An unknown error occurred while deswizzling bytes: output data length is (somehow) different than input data length. Input data: ${this.data.length}, Output data: ${deswizzledData.length}`,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return deswizzledData;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,256 +0,0 @@
|
||||||
import { MiiGender } from "@prisma/client";
|
|
||||||
import sharp from "sharp";
|
|
||||||
|
|
||||||
import { CharInfoEx } from "charinfo-ex";
|
|
||||||
import * as fzstd from "fzstd";
|
|
||||||
import { BytesDeswizzle } from "./deswizzle";
|
|
||||||
|
|
||||||
import { minifyInstructions } from "./switch";
|
|
||||||
import { SwitchMiiInstructions } from "@/types";
|
|
||||||
|
|
||||||
export class SwitchTomodachiLifeMii {
|
|
||||||
buffer: ArrayBuffer;
|
|
||||||
data: CharInfoEx;
|
|
||||||
|
|
||||||
datingPreferences: MiiGender[];
|
|
||||||
birthday: { month: number; day: number; age: number; dontAge: boolean };
|
|
||||||
voice: { speed: number; pitch: number; depth: number; delivery: number; tone: number };
|
|
||||||
personality: { movement: number; speech: number; energy: number; thinking: number; overall: number };
|
|
||||||
|
|
||||||
constructor(buffer: ArrayBuffer, data: CharInfoEx) {
|
|
||||||
this.buffer = buffer;
|
|
||||||
this.data = data;
|
|
||||||
|
|
||||||
const view = new DataView(buffer);
|
|
||||||
const bytes = new Uint8Array(buffer);
|
|
||||||
const parse = (index: number): number => view.getUint8(161 + index * 4);
|
|
||||||
|
|
||||||
const age = view.getUint32(0x00e1, true);
|
|
||||||
const year = view.getUint32(0x00d9, true);
|
|
||||||
const dontAge = age !== 0xffffffff;
|
|
||||||
|
|
||||||
this.datingPreferences = ([MiiGender.MALE, MiiGender.FEMALE, MiiGender.NONBINARY] as const).filter((_, i) => bytes[0x01a9 + i] === 1);
|
|
||||||
this.birthday = {
|
|
||||||
month: parse(17),
|
|
||||||
day: parse(15),
|
|
||||||
age: dontAge ? age : new Date().getFullYear() - year,
|
|
||||||
dontAge,
|
|
||||||
};
|
|
||||||
this.voice = {
|
|
||||||
speed: parse(6),
|
|
||||||
pitch: parse(8),
|
|
||||||
depth: parse(5),
|
|
||||||
delivery: Math.max(0, view.getInt8(0xc5)), // why is this an integer??
|
|
||||||
tone: parse(7) + 1,
|
|
||||||
// TODO: add voice preset to instructions type?
|
|
||||||
};
|
|
||||||
this.personality = {
|
|
||||||
movement: parse(4) - 1,
|
|
||||||
speech: parse(2) - 1,
|
|
||||||
energy: parse(1) - 1,
|
|
||||||
thinking: parse(0) - 1,
|
|
||||||
overall: parse(3) - 1,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// There's a UGC Texture image but we're ignoring it
|
|
||||||
public async extractFacePaintImage(): Promise<Buffer | null> {
|
|
||||||
try {
|
|
||||||
const buf = Buffer.from(this.buffer);
|
|
||||||
|
|
||||||
const canvasMarker = Buffer.from([0xa3, 0xa3, 0xa3, 0xa3]);
|
|
||||||
const ugcMarker = Buffer.from([0xa4, 0xa4, 0xa4, 0xa4]);
|
|
||||||
|
|
||||||
const canvasStart = buf.indexOf(canvasMarker);
|
|
||||||
if (canvasStart === -1) return null;
|
|
||||||
|
|
||||||
const ugcStart = buf.indexOf(ugcMarker);
|
|
||||||
const canvasData = buf.subarray(canvasStart + 4, ugcStart === -1 ? undefined : ugcStart);
|
|
||||||
|
|
||||||
const decompressed = Buffer.from(fzstd.decompress(canvasData));
|
|
||||||
const deswizzled = new BytesDeswizzle(decompressed, [256, 256], [1, 1], 4, 4).deswizzle();
|
|
||||||
|
|
||||||
return await sharp(deswizzled, {
|
|
||||||
raw: { width: 256, height: 256, channels: 4 },
|
|
||||||
})
|
|
||||||
.png()
|
|
||||||
.toBuffer();
|
|
||||||
} catch (err) {
|
|
||||||
console.error("extractFacePaintImage failed:", err);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public toInstructions() {
|
|
||||||
const instructions: Partial<SwitchMiiInstructions> = {
|
|
||||||
head: {
|
|
||||||
type: this.data.facelineType,
|
|
||||||
skinColor: this.data.facelineColor,
|
|
||||||
},
|
|
||||||
hair: {
|
|
||||||
set: this.data.hairType,
|
|
||||||
bangs: this.data.hairTypeFront,
|
|
||||||
back: this.data.hairTypeBack,
|
|
||||||
color: this.data.hairColor0,
|
|
||||||
subColor: this.data.hairColor1,
|
|
||||||
subColor2: this.data.hairColor0, // TODO: check
|
|
||||||
style: this.data.hairStyle,
|
|
||||||
isFlipped: (this.data.faceFlags & (1 << 2)) !== 0, // bangsSide
|
|
||||||
},
|
|
||||||
eyebrows: {
|
|
||||||
type: this.data.eyebrowType,
|
|
||||||
color: this.data.eyebrowColor,
|
|
||||||
height: this.data.eyebrowY - 10,
|
|
||||||
distance: this.data.eyebrowX - 4,
|
|
||||||
rotation: this.data.eyebrowRotate - 6,
|
|
||||||
size: this.data.eyebrowScale - 4,
|
|
||||||
stretch: this.data.eyebrowAspect - 3,
|
|
||||||
},
|
|
||||||
eyes: {
|
|
||||||
main: {
|
|
||||||
type: this.data.eyeType,
|
|
||||||
color: this.data.eyeColor,
|
|
||||||
height: this.data.eyeY - 12,
|
|
||||||
distance: this.data.eyeX - 2,
|
|
||||||
rotation: this.data.eyeRotate - 4,
|
|
||||||
size: this.data.eyeScale - 4,
|
|
||||||
stretch: this.data.eyeAspect - 3,
|
|
||||||
},
|
|
||||||
eyelashesTop: {
|
|
||||||
type: this.data.eyelashUpperType,
|
|
||||||
height: this.data.eyelashUpperY,
|
|
||||||
distance: this.data.eyelashUpperX,
|
|
||||||
rotation: this.data.eyelashUpperRotate,
|
|
||||||
size: this.data.eyelashUpperScale,
|
|
||||||
stretch: this.data.eyelashUpperAspect,
|
|
||||||
},
|
|
||||||
eyelashesBottom: {
|
|
||||||
type: this.data.eyelashLowerType,
|
|
||||||
height: this.data.eyelashLowerY,
|
|
||||||
distance: this.data.eyelashLowerX,
|
|
||||||
rotation: this.data.eyelashLowerRotate,
|
|
||||||
size: this.data.eyelashLowerScale,
|
|
||||||
stretch: this.data.eyelashLowerAspect,
|
|
||||||
},
|
|
||||||
eyelidTop: {
|
|
||||||
type: this.data.eyelidUpperType,
|
|
||||||
height: this.data.eyelidUpperY,
|
|
||||||
distance: this.data.eyelidUpperX,
|
|
||||||
rotation: this.data.eyelidUpperRotate,
|
|
||||||
size: this.data.eyelidUpperScale,
|
|
||||||
stretch: this.data.eyelidUpperAspect,
|
|
||||||
},
|
|
||||||
eyelidBottom: {
|
|
||||||
type: this.data.eyelidLowerType,
|
|
||||||
height: this.data.eyelidLowerY,
|
|
||||||
distance: this.data.eyelidLowerX,
|
|
||||||
rotation: this.data.eyelidLowerRotate,
|
|
||||||
size: this.data.eyelidLowerScale,
|
|
||||||
stretch: this.data.eyelidLowerAspect,
|
|
||||||
},
|
|
||||||
eyeliner: {
|
|
||||||
type: (this.data.faceFlags & (1 << 4)) !== 0, // eyeShadowEnabled
|
|
||||||
color: this.data.eyeShadowColor,
|
|
||||||
},
|
|
||||||
pupil: {
|
|
||||||
type: this.data.eyeHighlightType,
|
|
||||||
height: this.data.eyeHighlightY,
|
|
||||||
distance: this.data.eyeHighlightX,
|
|
||||||
rotation: this.data.eyeHighlightRotate,
|
|
||||||
size: this.data.eyeHighlightScale,
|
|
||||||
stretch: this.data.eyeHighlightAspect,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
nose: {
|
|
||||||
type: this.data.noseType,
|
|
||||||
height: this.data.noseY - 9,
|
|
||||||
size: this.data.noseScale - 4,
|
|
||||||
},
|
|
||||||
lips: {
|
|
||||||
type: this.data.mouthType,
|
|
||||||
color: this.data.mouthColor,
|
|
||||||
height: this.data.mouthY - 13,
|
|
||||||
rotation: this.data.mouthRotate,
|
|
||||||
size: this.data.mouthScale - 4,
|
|
||||||
stretch: this.data.mouthAspect - 3,
|
|
||||||
hasLipstick: (this.data.faceFlags & (1 << 5)) !== 0, // mouthInvert
|
|
||||||
},
|
|
||||||
ears: {
|
|
||||||
type: this.data.earType,
|
|
||||||
height: this.data.earY - 4,
|
|
||||||
size: this.data.earScale - 2,
|
|
||||||
},
|
|
||||||
glasses: {
|
|
||||||
type: this.data.glassType1,
|
|
||||||
type2: this.data.glassType2,
|
|
||||||
ringColor: this.data.glassColor1,
|
|
||||||
shadesColor: this.data.glassColor2,
|
|
||||||
height: this.data.glassY - 11,
|
|
||||||
size: this.data.glassScale - 4,
|
|
||||||
stretch: this.data.glassAspect - 3,
|
|
||||||
},
|
|
||||||
other: {
|
|
||||||
wrinkles1: {
|
|
||||||
type: this.data.wrinkleLowerType,
|
|
||||||
height: this.data.wrinkleLowerY - 15,
|
|
||||||
distance: this.data.wrinkleLowerX - 2,
|
|
||||||
size: this.data.wrinkleLowerScale - 6,
|
|
||||||
stretch: this.data.wrinkleLowerAspect - 3,
|
|
||||||
},
|
|
||||||
wrinkles2: {
|
|
||||||
type: this.data.wrinkleUpperType,
|
|
||||||
height: this.data.wrinkleUpperY - 23,
|
|
||||||
distance: this.data.wrinkleUpperX - 7,
|
|
||||||
size: this.data.wrinkleUpperScale - 6,
|
|
||||||
stretch: this.data.wrinkleUpperAspect - 3,
|
|
||||||
},
|
|
||||||
beard: {
|
|
||||||
type: this.data.beardType,
|
|
||||||
color: this.data.beardColor,
|
|
||||||
},
|
|
||||||
moustache: {
|
|
||||||
type: this.data.mustacheType,
|
|
||||||
color: this.data.mustacheColor,
|
|
||||||
height: this.data.mustacheY - 10,
|
|
||||||
isFlipped: (this.data.faceFlags & (1 << 6)) !== 0, // mustacheInverted
|
|
||||||
size: this.data.mustacheScale - 4,
|
|
||||||
stretch: this.data.mustacheAspect - 3,
|
|
||||||
},
|
|
||||||
goatee: {
|
|
||||||
type: this.data.beardShortType,
|
|
||||||
color: this.data.beardShortColor,
|
|
||||||
},
|
|
||||||
mole: {
|
|
||||||
type: this.data.moleX != 0,
|
|
||||||
height: this.data.moleY - 20,
|
|
||||||
distance: this.data.moleX - 2,
|
|
||||||
size: this.data.moleScale - 4,
|
|
||||||
},
|
|
||||||
eyeShadow: {
|
|
||||||
type: this.data.makeup0,
|
|
||||||
color: this.data.makeup0Color,
|
|
||||||
height: this.data.makeup0Y - 12,
|
|
||||||
distance: this.data.makeup0X - 1,
|
|
||||||
size: this.data.makeup0Scale - 6,
|
|
||||||
stretch: this.data.makeup0Aspect - 3,
|
|
||||||
},
|
|
||||||
blush: {
|
|
||||||
type: this.data.makeup1,
|
|
||||||
color: this.data.makeup1Color,
|
|
||||||
height: this.data.makeup1Y - 19,
|
|
||||||
distance: this.data.makeup1X - 6,
|
|
||||||
size: this.data.makeup1Scale - 5,
|
|
||||||
stretch: this.data.makeup1Aspect - 3,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
height: this.data.height,
|
|
||||||
weight: this.data.build,
|
|
||||||
datingPreferences: this.datingPreferences,
|
|
||||||
birthday: this.birthday,
|
|
||||||
voice: this.voice,
|
|
||||||
personality: this.personality,
|
|
||||||
};
|
|
||||||
|
|
||||||
return minifyInstructions(instructions);
|
|
||||||
}
|
|
||||||
}
|
|
||||||