Stable version with additional styling
This commit is contained in:
@@ -16,6 +16,11 @@ import {
|
||||
NONE_VARIANT_ID,
|
||||
} from "@/lib/character/presets";
|
||||
import type { LayerColorState, LayerVariantState } from "@/lib/character/types";
|
||||
import {
|
||||
DEFAULT_ROOM_BACKGROUND,
|
||||
normalizeRoomBackgroundId,
|
||||
type RoomBackgroundId,
|
||||
} from "@/lib/room-backgrounds";
|
||||
import { createClient } from "@/lib/supabase/client";
|
||||
|
||||
export type AvatarCustomization = {
|
||||
@@ -24,6 +29,7 @@ export type AvatarCustomization = {
|
||||
variants: LayerVariantState;
|
||||
customized: boolean;
|
||||
equippedItems: string[];
|
||||
roomBackground: RoomBackgroundId;
|
||||
};
|
||||
|
||||
type AvatarStateRow = {
|
||||
@@ -41,6 +47,7 @@ type AvatarStateRow = {
|
||||
head_color: string | null;
|
||||
avatar_customized: boolean | null;
|
||||
equipped_items: string[] | null;
|
||||
room_background: string | null;
|
||||
};
|
||||
|
||||
export const DEFAULT_AVATAR_NAME = "Pixel Me";
|
||||
@@ -53,11 +60,19 @@ export const defaultAvatarCustomization: AvatarCustomization = {
|
||||
variants: buildDefaultVariants(),
|
||||
customized: false,
|
||||
equippedItems: [],
|
||||
roomBackground: DEFAULT_ROOM_BACKGROUND,
|
||||
};
|
||||
|
||||
const avatarSelectFields =
|
||||
"avatar_name, skin_color, pants_style, pants_color, shoe_style, shoe_color, torso_style, torso_color, eye_type, eye_color, head_style, head_color, avatar_customized, equipped_items, room_background";
|
||||
|
||||
const avatarSelectFieldsLegacy =
|
||||
"avatar_name, skin_color, pants_style, pants_color, shoe_style, shoe_color, torso_style, torso_color, eye_type, eye_color, head_style, head_color, avatar_customized, equipped_items";
|
||||
|
||||
function isMissingRoomBackgroundColumn(message: string) {
|
||||
return /room_background/i.test(message);
|
||||
}
|
||||
|
||||
function colorFromHex(hex: string | null | undefined, fallback: HSL): HSL {
|
||||
if (!hex?.trim() || !HEX_COLOR_PATTERN.test(hex)) {
|
||||
return { ...fallback };
|
||||
@@ -109,6 +124,7 @@ export function mapRowToAvatarCustomization(
|
||||
},
|
||||
customized: row.avatar_customized === true,
|
||||
equippedItems: row.equipped_items ?? [],
|
||||
roomBackground: normalizeRoomBackgroundId(row.room_background),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -132,6 +148,7 @@ export function mapAvatarCustomizationToRow(customization: AvatarCustomization)
|
||||
eye_color: normalizeHexColor(colors.eyes, defaults.eyes),
|
||||
head_style: normalizedVariants.head,
|
||||
head_color: normalizeHexColor(colors.head, defaults.head),
|
||||
room_background: normalizeRoomBackgroundId(customization.roomBackground),
|
||||
avatar_customized: true,
|
||||
updated_at: new Date().toISOString(),
|
||||
};
|
||||
@@ -159,13 +176,28 @@ export async function getAvatarCustomization(): Promise<AvatarCustomization> {
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
let { data, error } = await supabase
|
||||
.from("avatar_state")
|
||||
.select(avatarSelectFields)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
if (error && isMissingRoomBackgroundColumn(error.message)) {
|
||||
const legacy = await supabase
|
||||
.from("avatar_state")
|
||||
.select(avatarSelectFieldsLegacy)
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (legacy.error) {
|
||||
throw new Error(legacy.error.message);
|
||||
}
|
||||
|
||||
data = legacy.data
|
||||
? ({ ...legacy.data, room_background: null } as AvatarStateRow)
|
||||
: null;
|
||||
error = null;
|
||||
} else if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
@@ -209,11 +241,22 @@ export async function saveAvatarCustomization(
|
||||
const supabase = createClient();
|
||||
const payload = mapAvatarCustomizationToRow(customization);
|
||||
|
||||
const { error } = await supabase
|
||||
let { error } = await supabase
|
||||
.from("avatar_state")
|
||||
.update(payload)
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (error && isMissingRoomBackgroundColumn(error.message)) {
|
||||
const { room_background, ...legacyPayload } = payload;
|
||||
void room_background;
|
||||
const retry = await supabase
|
||||
.from("avatar_state")
|
||||
.update(legacyPayload)
|
||||
.eq("user_id", userId);
|
||||
|
||||
error = retry.error;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
@@ -152,6 +152,16 @@ export const CHARACTER_LAYERS: CharacterLayer[] = [
|
||||
},
|
||||
];
|
||||
|
||||
export type CharacterCreatorTabId = CharacterLayerId | "room";
|
||||
|
||||
export const CHARACTER_CUSTOMIZATION_TABS: {
|
||||
id: CharacterCreatorTabId;
|
||||
label: string;
|
||||
}[] = [
|
||||
...CHARACTER_LAYERS.map((layer) => ({ id: layer.id, label: layer.label })),
|
||||
{ id: "room", label: "Room" },
|
||||
];
|
||||
|
||||
export const LAYER_DEFAULT_COLORS: Record<CharacterLayerId, HSL> = {
|
||||
skin: DEFAULT_GRAY_COLOR.hsl,
|
||||
pants: DEFAULT_GRAY_COLOR.hsl,
|
||||
|
||||
245
lib/room-backgrounds.ts
Normal file
245
lib/room-backgrounds.ts
Normal file
@@ -0,0 +1,245 @@
|
||||
export type RoomBackgroundId =
|
||||
|
||||
| "room-day"
|
||||
|
||||
| "room-dusk"
|
||||
|
||||
| "room-night"
|
||||
|
||||
| "room-forest";
|
||||
|
||||
|
||||
|
||||
export type RoomBackgroundLayer = {
|
||||
|
||||
id: string;
|
||||
|
||||
className: string;
|
||||
|
||||
speed: number;
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
export type RoomBackground = {
|
||||
|
||||
id: RoomBackgroundId;
|
||||
|
||||
name: string;
|
||||
|
||||
free: boolean;
|
||||
|
||||
previewClassName: string;
|
||||
|
||||
layers: RoomBackgroundLayer[];
|
||||
|
||||
};
|
||||
|
||||
|
||||
|
||||
export const DEFAULT_ROOM_BACKGROUND: RoomBackgroundId = "room-day";
|
||||
|
||||
|
||||
|
||||
export const ROOM_BACKGROUNDS: RoomBackground[] = [
|
||||
|
||||
{
|
||||
|
||||
id: "room-day",
|
||||
|
||||
name: "Sunny Field",
|
||||
|
||||
free: true,
|
||||
|
||||
previewClassName: "bg-gradient-to-b from-sky-400 via-sky-300 to-lime-400",
|
||||
|
||||
layers: [
|
||||
|
||||
{ id: "sky", className: "bg-gradient-to-b from-sky-500 via-sky-300 to-sky-200", speed: 0.15 },
|
||||
|
||||
{ id: "clouds", className: "bg-[radial-gradient(circle_at_20%_30%,rgba(255,255,255,0.85)_0%,transparent_35%),radial-gradient(circle_at_70%_20%,rgba(255,255,255,0.7)_0%,transparent_30%)]", speed: 0.35 },
|
||||
|
||||
{ id: "hills", className: "bg-[linear-gradient(to_top,#3d8b37_0%,#3d8b37_40%,transparent_40%),radial-gradient(ellipse_120%_80%_at_50%_100%,#2f6f2c_0%,transparent_70%)]", speed: 0.55 },
|
||||
|
||||
{ id: "grass", className: "bg-[repeating-linear-gradient(90deg,rgba(0,0,0,0.06)_0_2px,transparent_2px_6px)] opacity-40", speed: 0.75 },
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
id: "room-dusk",
|
||||
|
||||
name: "Golden Dusk",
|
||||
|
||||
free: true,
|
||||
|
||||
previewClassName: "bg-gradient-to-b from-orange-400 via-pink-400 to-indigo-700",
|
||||
|
||||
layers: [
|
||||
|
||||
{ id: "sky", className: "bg-gradient-to-b from-orange-500 via-rose-400 to-indigo-800", speed: 0.12 },
|
||||
|
||||
{ id: "sun", className: "bg-[radial-gradient(circle_at_50%_85%,rgba(255,220,120,0.95)_0%,rgba(255,160,80,0.35)_18%,transparent_45%)]", speed: 0.25 },
|
||||
|
||||
{ id: "hills", className: "bg-[radial-gradient(ellipse_130%_70%_at_50%_100%,#1f2937_0%,transparent_72%)]", speed: 0.5 },
|
||||
|
||||
{ id: "stars", className: "bg-[radial-gradient(circle,#fff_1px,transparent_1px)] bg-[length:18px_18px] opacity-20", speed: 0.65 },
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
id: "room-night",
|
||||
|
||||
name: "Starlit Night",
|
||||
|
||||
free: false,
|
||||
|
||||
previewClassName: "bg-gradient-to-b from-indigo-950 via-violet-900 to-slate-900",
|
||||
|
||||
layers: [
|
||||
|
||||
{ id: "sky", className: "bg-gradient-to-b from-indigo-950 via-violet-900 to-slate-900", speed: 0.1 },
|
||||
|
||||
{ id: "stars", className: "bg-[radial-gradient(circle,#fff_1.5px,transparent_1.5px)] bg-[length:22px_22px] opacity-80", speed: 0.3 },
|
||||
|
||||
{ id: "moon", className: "bg-[radial-gradient(circle_at_78%_18%,rgba(255,255,220,0.95)_0%,rgba(255,255,220,0.15)_8%,transparent_20%)]", speed: 0.2 },
|
||||
|
||||
{ id: "ground", className: "bg-[linear-gradient(to_top,#111827_0%,#111827_35%,transparent_35%)]", speed: 0.6 },
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
{
|
||||
|
||||
id: "room-forest",
|
||||
|
||||
name: "Mystic Forest",
|
||||
|
||||
free: false,
|
||||
|
||||
previewClassName: "bg-gradient-to-b from-emerald-900 via-green-900 to-lime-950",
|
||||
|
||||
layers: [
|
||||
|
||||
{ id: "sky", className: "bg-gradient-to-b from-emerald-950 via-green-900 to-lime-950", speed: 0.14 },
|
||||
|
||||
{ id: "mist", className: "bg-[radial-gradient(circle_at_30%_60%,rgba(180,255,200,0.25)_0%,transparent_45%),radial-gradient(circle_at_70%_40%,rgba(180,255,200,0.18)_0%,transparent_40%)]", speed: 0.4 },
|
||||
|
||||
{ id: "trees", className: "bg-[repeating-linear-gradient(90deg,rgba(0,0,0,0.35)_0_14px,transparent_14px_28px)] opacity-50", speed: 0.55 },
|
||||
|
||||
{ id: "floor", className: "bg-[linear-gradient(to_top,#14532d_0%,#14532d_30%,transparent_30%)]", speed: 0.7 },
|
||||
|
||||
],
|
||||
|
||||
},
|
||||
|
||||
];
|
||||
|
||||
|
||||
|
||||
const ROOM_MAP = new Map(ROOM_BACKGROUNDS.map((room) => [room.id, room]));
|
||||
|
||||
|
||||
|
||||
const LEGACY_ROOM_IDS = new Set(["room-space", "room-neon"]);
|
||||
|
||||
|
||||
|
||||
export function getRoomBackground(id: string | null | undefined): RoomBackground {
|
||||
|
||||
if (id && LEGACY_ROOM_IDS.has(id)) {
|
||||
|
||||
return ROOM_MAP.get(DEFAULT_ROOM_BACKGROUND)!;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return ROOM_MAP.get((id as RoomBackgroundId) ?? DEFAULT_ROOM_BACKGROUND) ?? ROOM_MAP.get(DEFAULT_ROOM_BACKGROUND)!;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isValidRoomBackgroundId(id: string): id is RoomBackgroundId {
|
||||
|
||||
return ROOM_MAP.has(id as RoomBackgroundId);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function normalizeRoomBackgroundId(id: string | null | undefined): RoomBackgroundId {
|
||||
|
||||
if (id && LEGACY_ROOM_IDS.has(id)) {
|
||||
|
||||
return DEFAULT_ROOM_BACKGROUND;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (id && isValidRoomBackgroundId(id)) {
|
||||
|
||||
return id;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return DEFAULT_ROOM_BACKGROUND;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function isRoomBackgroundUnlocked(
|
||||
|
||||
roomId: RoomBackgroundId,
|
||||
|
||||
ownedItemIds: readonly string[],
|
||||
|
||||
): boolean {
|
||||
|
||||
const room = getRoomBackground(roomId);
|
||||
|
||||
if (room.free) {
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
return ownedItemIds.includes(roomId);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
export function getShopRoomBackgrounds() {
|
||||
|
||||
return ROOM_BACKGROUNDS.filter((room) => !room.free).map((room) => ({
|
||||
|
||||
id: room.id,
|
||||
|
||||
name: room.name,
|
||||
|
||||
type: "room" as const,
|
||||
|
||||
price: room.id === "room-night" ? 40 : 50,
|
||||
|
||||
image_path: "",
|
||||
|
||||
}));
|
||||
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ export const routes = {
|
||||
onboardingQuiz: "/onboarding/quiz",
|
||||
onboardingCustomize: "/onboarding/customize",
|
||||
avatar: "/avatar",
|
||||
customize: "/customize",
|
||||
habits: "/habits",
|
||||
dailyQuiz: "/daily/quiz",
|
||||
shop: "/shop",
|
||||
@@ -16,6 +17,7 @@ export type AppRoute = (typeof routes)[keyof typeof routes];
|
||||
|
||||
export const appNavItems = [
|
||||
{ href: routes.avatar, label: "Pet", shortLabel: "Pet" },
|
||||
{ href: routes.customize, label: "Customize", shortLabel: "Style" },
|
||||
{ href: routes.habits, label: "Habits", shortLabel: "Habits" },
|
||||
{ href: routes.dailyQuiz, label: "Daily Quiz", shortLabel: "Quiz" },
|
||||
{ href: routes.shop, label: "Shop", shortLabel: "Shop" },
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { isShopOnlyStyleVariant } from "@/lib/character/variant-access";
|
||||
import type { CharacterLayerId } from "@/lib/character/presets";
|
||||
import { CHARACTER_LAYERS, NONE_VARIANT_ID } from "@/lib/character/presets";
|
||||
import { getShopRoomBackgrounds } from "@/lib/room-backgrounds";
|
||||
|
||||
export type ShopLayerId = Exclude<CharacterLayerId, "skin">;
|
||||
|
||||
export type ShopItemType = ShopLayerId | "room";
|
||||
|
||||
export type ShopItemRecord = {
|
||||
id: string;
|
||||
name: string;
|
||||
type: ShopLayerId;
|
||||
type: ShopItemType;
|
||||
price: number;
|
||||
image_path: string;
|
||||
};
|
||||
@@ -52,7 +55,7 @@ export function parseShopStyleItem(
|
||||
}
|
||||
|
||||
export function getShopCatalogFromPresets(): ShopItemRecord[] {
|
||||
return CHARACTER_LAYERS.flatMap((layer) => {
|
||||
const styleItems = CHARACTER_LAYERS.flatMap((layer) => {
|
||||
if (layer.id === "skin") {
|
||||
return [];
|
||||
}
|
||||
@@ -70,6 +73,8 @@ export function getShopCatalogFromPresets(): ShopItemRecord[] {
|
||||
image_path: variant.src,
|
||||
}));
|
||||
});
|
||||
|
||||
return [...styleItems, ...getShopRoomBackgrounds()];
|
||||
}
|
||||
|
||||
function getDefaultPriceForLayer(layerId: ShopLayerId, variantId: string) {
|
||||
|
||||
@@ -128,6 +128,31 @@ export async function purchaseShopItem(itemId: string) {
|
||||
};
|
||||
}
|
||||
|
||||
export async function getEquippedRoomBackground(): Promise<string> {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
if (!userId) {
|
||||
return "room-day";
|
||||
}
|
||||
|
||||
const supabase = createClient();
|
||||
const { data, error } = await supabase
|
||||
.from("avatar_state")
|
||||
.select("room_background")
|
||||
.eq("user_id", userId)
|
||||
.maybeSingle();
|
||||
|
||||
if (error) {
|
||||
if (/room_background/i.test(error.message)) {
|
||||
return "room-day";
|
||||
}
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
return (data?.room_background as string | null) ?? "room-day";
|
||||
}
|
||||
|
||||
export async function equipShopItem(itemId: string) {
|
||||
const userId = await getAuthenticatedUserId();
|
||||
|
||||
@@ -152,6 +177,28 @@ export async function equipShopItem(itemId: string) {
|
||||
throw new Error("Could not find that shop item.");
|
||||
}
|
||||
|
||||
if (item.type === "room") {
|
||||
const { error } = await supabase
|
||||
.from("avatar_state")
|
||||
.update({
|
||||
room_background: itemId,
|
||||
updated_at: new Date().toISOString(),
|
||||
})
|
||||
.eq("user_id", userId);
|
||||
|
||||
if (error) {
|
||||
if (/room_background/i.test(error.message)) {
|
||||
notifyHabitPetDataUpdated();
|
||||
return [itemId];
|
||||
}
|
||||
|
||||
throw new Error(error.message);
|
||||
}
|
||||
|
||||
notifyHabitPetDataUpdated();
|
||||
return [itemId];
|
||||
}
|
||||
|
||||
const current = await getEquippedItems();
|
||||
const nextStyle = parseVariantId(itemId);
|
||||
|
||||
@@ -216,12 +263,14 @@ export async function unequipShopItem(itemId: string) {
|
||||
}
|
||||
|
||||
export async function getShopInventory() {
|
||||
const [items, ownedItemIds, equippedItems, coins] = await Promise.all([
|
||||
getShopItems(),
|
||||
getOwnedItemIds(),
|
||||
getEquippedItems(),
|
||||
getCoins(),
|
||||
]);
|
||||
const [items, ownedItemIds, equippedItems, equippedRoomBackground, coins] =
|
||||
await Promise.all([
|
||||
getShopItems(),
|
||||
getOwnedItemIds(),
|
||||
getEquippedItems(),
|
||||
getEquippedRoomBackground(),
|
||||
getCoins(),
|
||||
]);
|
||||
|
||||
return { items, ownedItemIds, equippedItems, coins };
|
||||
return { items, ownedItemIds, equippedItems, equippedRoomBackground, coins };
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user