Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion src/lib/cards/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ import { KichRecipeCardDefinition } from './social/KichRecipeCard';
import { KichRecipeCollectionCardDefinition } from './social/KichRecipeCollectionCard';
import { KichCookingLogCardDefinition } from './social/KichCookingLogCard';
import { SecretImageCardDefinition } from './media/SecretImageCard';
import { RPGActorCardDefinition } from './social/RPGActorCard';
// import { Model3DCardDefinition } from './visual/Model3DCard';

export const AllCardDefinitions = [
Expand Down Expand Up @@ -131,7 +132,8 @@ export const AllCardDefinitions = [
KichRecipeCardDefinition,
KichRecipeCollectionCardDefinition,
KichCookingLogCardDefinition,
SecretImageCardDefinition
SecretImageCardDefinition,
RPGActorCardDefinition
] as const;

export const CardDefinitionsByType = AllCardDefinitions.reduce(
Expand Down
152 changes: 152 additions & 0 deletions src/lib/cards/social/RPGActorCard/RPGActorCard.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,152 @@
<script lang="ts">
import type { ContentComponentProps } from '../../types';
import { getAdditionalUserData, getDidContext, getHandleContext } from '$lib/website/context';
import { CardDefinitionsByType } from '../..';
import { onMount } from 'svelte';
import type { RpgActorData } from '.';

let { item, isEditing }: ContentComponentProps = $props();

const data = getAdditionalUserData();
const did = getDidContext();
const handle = getHandleContext();

// svelte-ignore state_referenced_locally
let actor = $state(data[item.cardType] as RpgActorData);
// svelte-ignore state_referenced_locally
let loaded = $state(actor !== undefined);

onMount(async () => {
if (actor === undefined) {
actor = (await CardDefinitionsByType[item.cardType]?.loadData?.([item], {
did,
handle
})) as RpgActorData;
data[item.cardType] = actor;
}
loaded = true;
});

let containerWidth = $state(0);
let containerHeight = $state(0);

const WALK_IN = 2200;
const IDLE = 1800;
const WALK_OUT = 2200;
const PAUSE = 800;
const TOTAL = WALK_IN + IDLE + WALK_OUT + PAUSE;
const FRAME_MS = 180;

let elapsed = $state(0);
let raf: number | undefined;

$effect(() => {
if (!actor) return;
const start = performance.now();
const tick = (now: number) => {
elapsed = now - start;
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => {
if (raf !== undefined) cancelAnimationFrame(raf);
};
});

const cycleT = $derived(elapsed % TOTAL);
const cycleIndex = $derived(Math.floor(elapsed / TOTAL));
const phase = $derived.by(() => {
if (cycleT < WALK_IN) return 'walkIn';
if (cycleT < WALK_IN + IDLE) return 'idle';
if (cycleT < WALK_IN + IDLE + WALK_OUT) return 'walkOut';
return 'pause';
});

// Pseudo-random direction per cycle so consecutive loops can flip sides.
const goingRight = $derived(((cycleIndex * 2654435761) >>> 0) & 1 ? true : false);

// Integer pixel scale so pixel art stays crisp.
const spriteScale = $derived(
actor ? Math.max(1, Math.floor((containerHeight * 0.78) / actor.sprite.frameHeight)) : 1
);
const spriteW = $derived(actor ? actor.sprite.frameWidth * spriteScale : 0);
const spriteH = $derived(actor ? actor.sprite.frameHeight * spriteScale : 0);

// 0 = sprite right edge at container left edge; 1 = sprite left edge at container right edge.
const travel = $derived(containerWidth + spriteW);
const x = $derived.by(() => {
if (!actor) return 0;
switch (phase) {
case 'walkIn': {
const t = (cycleT / WALK_IN) * 0.5;
return goingRight ? t : 1 - t;
}
case 'idle':
return 0.5;
case 'walkOut': {
const t = ((cycleT - WALK_IN - IDLE) / WALK_OUT) * 0.5;
return goingRight ? 0.5 + t : 0.5 - t;
}
default:
return goingRight ? 1 : 0;
}
});
const translateX = $derived(x * travel - spriteW);

// Standard 4×3 RPG layout: row 0 down, row 1 left, row 2 right, row 3 up.
const DOWN_ROW = 0;
const LEFT_ROW = 1;
const RIGHT_ROW = 2;
// 3-frame walk cycle: stand → step → stand → step.
const WALK_FRAMES = [1, 0, 1, 2];

const isStill = $derived(phase === 'idle' || phase === 'pause');
const row = $derived(isStill ? DOWN_ROW : goingRight ? RIGHT_ROW : LEFT_ROW);
const col = $derived.by(() => {
if (isStill) return 1;
const i = Math.floor(cycleT / FRAME_MS) % WALK_FRAMES.length;
return WALK_FRAMES[i];
});

const sheetW = $derived(actor ? actor.sprite.columns * spriteW : 0);
const sheetH = $derived(actor ? actor.sprite.rows * spriteH : 0);
const bgX = $derived(-col * spriteW);
const bgY = $derived(-row * spriteH);
</script>

<div
bind:clientWidth={containerWidth}
bind:clientHeight={containerHeight}
class="relative h-full w-full overflow-hidden"
>
{#if loaded && actor}
<div
class="absolute bottom-0"
style:transform="translateX({translateX}px)"
style:width="{spriteW}px"
style:height="{spriteH}px"
style:background-image="url({actor.url})"
style:background-size="{sheetW}px {sheetH}px"
style:background-position="{bgX}px {bgY}px"
style:image-rendering="pixelated"
aria-hidden="true"
></div>
{:else if loaded && !actor && isEditing}
<div class="flex h-full w-full items-center justify-center p-3">
<a
href="https://rpg.actor"
target="_blank"
rel="noopener noreferrer"
class="rounded-lg border border-current/15 bg-current/5 px-3 py-2 text-center text-sm font-medium text-current/80 transition hover:bg-current/10 hover:text-current"
>
Create your character on rpg.actor
</a>
</div>
{:else if loaded && !actor}
<div
class="flex h-full w-full items-center justify-center p-3 text-center text-sm text-current/60"
>
This person hasn't created a character yet
</div>
{/if}
</div>
68 changes: 68 additions & 0 deletions src/lib/cards/social/RPGActorCard/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import type { CardDefinition } from '../../types';
import { getRecord, getBlobURL } from '$lib/atproto';
import RPGActorCard from './RPGActorCard.svelte';
import type { Did } from '@atcute/lexicons';

export type RpgSpriteRecord = {
rows: number;
columns: number;
frames: number;
width: number;
height: number;
frameWidth: number;
frameHeight: number;
isCustom?: boolean;
createdAt?: string;
spriteSheet: {
$type: 'blob';
ref: { $link: string };
mimeType: string;
size: number;
};
};

export type RpgActorData = {
sprite: RpgSpriteRecord;
url: string;
} | null;

export const RPGActorCardDefinition = {
type: 'rpgActor',
contentComponent: RPGActorCard,

createNew: (item) => {
item.w = 4;
item.h = 2;
item.mobileW = 8;
item.mobileH = 2;
},

loadData: async (_items, { did }) => {
try {
const record = await getRecord({
did: did as Did,
collection: 'actor.rpg.sprite',
rkey: 'self'
});
const value = record?.value as RpgSpriteRecord | undefined;
if (!value?.spriteSheet) return null;
const url = await getBlobURL({
did: did as Did,
blob: value.spriteSheet
});
return { sprite: value, url } satisfies RpgActorData;
} catch {
return null;
}
},
cacheLoadData: true,

minW: 2,
minH: 1,

name: 'RPG Character',
keywords: ['rpg', 'sprite', 'character', 'actor', 'avatar', 'pixel', 'game'],
groups: ['Social'],
canHaveLabel: true,
icon: `<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="2" stroke="currentColor" class="size-4"><path stroke-linecap="round" stroke-linejoin="round" d="M15.59 14.37a6 6 0 0 1-5.84 7.38v-4.8m5.84-2.58a14.98 14.98 0 0 0 6.16-12.12A14.98 14.98 0 0 0 9.631 8.41m5.96 5.96a14.926 14.926 0 0 1-5.841 2.58m-.119-8.54a6 6 0 0 0-7.381 5.84h4.8m2.581-5.84a14.927 14.927 0 0 0-2.58 5.84m2.699 2.7c-.103.021-.207.041-.311.06a15.09 15.09 0 0 1-2.448-2.448 14.9 14.9 0 0 1 .06-.312m-2.24 2.39a4.493 4.493 0 0 0-1.757 4.306 4.493 4.493 0 0 0 4.306-1.758M16.5 9a1.5 1.5 0 1 1-3 0 1.5 1.5 0 0 1 3 0Z" /></svg>`
} as CardDefinition & { type: 'rpgActor' };
Loading