Skip to content
Open
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
23 changes: 23 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "web-demo (Next.js)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev", "-w", "apps/web-demo"],
"port": 3000
},
{
"name": "web-demo (Next.js, debug mode)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev:debug", "-w", "apps/web-demo"],
"port": 3000
},
{
"name": "PartyKit (multiplayer server)",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "party:dev", "-w", "apps/web-demo"],
"port": 1999
}
]
}
4 changes: 4 additions & 0 deletions apps/web-demo/app/components/GameTouchCanvas.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ const EMPTY_TRANSITIONS: GameSceneTransition[] = [];
type GameTouchCanvasProps = {
debug?: boolean;
onRuntimeEvent?: (event: RuntimeEvent) => void;
/** Contenido extra dentro del mundo físico (p.ej. <RemotePlayers/> en /multiplayer). */
extraCanvasChildren?: React.ReactNode;
};

/**
Expand All @@ -69,6 +71,7 @@ function SceneReadyReporter({ onReady }: { onReady: () => void }) {
export default function GameTouchCanvas({
debug: debugOverride,
onRuntimeEvent,
extraCanvasChildren,
}: GameTouchCanvasProps = {}) {
const selectedCharacter: GameCharacterName = "Dave";
const [sceneReady, setSceneReady] = useState(false);
Expand Down Expand Up @@ -451,6 +454,7 @@ export default function GameTouchCanvas({
debug={runtimeDebug}
onTransitionTriggered={handleTransitionTriggered}
/>
{extraCanvasChildren}
</Physics>
</Canvas>

Expand Down
69 changes: 69 additions & 0 deletions apps/web-demo/app/components/net/RoomLobby.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"use client";
import { useState } from "react";

export interface RoomLobbyProps {
code: string | null;
remoteCount: number; // otros jugadores en mi escena
capacity: number; // 4
displayName: string;
onCreate: () => void;
onJoin: (code: string) => void;
onReset: () => void;
onRename: (name: string) => void;
}

export function RoomLobby(p: RoomLobbyProps) {
const [joinCode, setJoinCode] = useState("");
const total = p.remoteCount + 1;
const missing = p.capacity - total;
return (
<div
style={{
position: "fixed",
top: 12,
left: 12,
zIndex: 1000,
background: "#0f1220cc",
color: "#e6e9ff",
padding: 12,
borderRadius: 8,
font: "12px monospace",
minWidth: 200,
}}
>
{!p.code ? (
<div style={{ display: "grid", gap: 6 }}>
<button onClick={p.onCreate}>Crear partida</button>
<input
placeholder="código"
value={joinCode}
onChange={(e) => setJoinCode(e.target.value)}
/>
<button onClick={() => p.onJoin(joinCode)} disabled={joinCode.length < 6}>
Unirse
</button>
</div>
) : (
<div style={{ display: "grid", gap: 6 }}>
<div>
Room: <b>{p.code}</b>{" "}
<button onClick={() => navigator.clipboard?.writeText(p.code!)}>
copiar
</button>
</div>
<div>
Jugadores:{" "}
<b>
{total}/{p.capacity}
</b>
</div>
{missing > 0 && (
<div style={{ color: "#ffd166" }}>Faltan {missing} jugador(es)…</div>
)}
<input value={p.displayName} onChange={(e) => p.onRename(e.target.value)} />
<button onClick={p.onReset}>Reset room (nueva)</button>
</div>
)}
</div>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,10 @@ export function useInventoryRuntimeController({
const [draggedStack, setDraggedStack] = useState<RuntimeDraggedStack | null>(
null,
);
const [placedItems, setPlacedItems] = useState<PlacedSceneItem[]>([]);
// placedItems lives in the store so remote updates (via applyRemoteEvent) propagate reactively.
const placedItems = usePlacedItemsStore((s) => s.items);
const pickupLockRef = useRef<Set<string>>(new Set());
const inventorySlotsRef = useRef(inventorySlots);
const setPlacedItemsInStore = usePlacedItemsStore((s) => s.setItems);

// Keep ref in sync with current state (avoids stale closure in callbacks)
useEffect(() => {
Expand All @@ -150,39 +150,20 @@ export function useInventoryRuntimeController({
}
}, [placedItems]);

// Manage placed items: maintain all items in state (persisted to store)
// Seed initial items for personalRoom once per device.
useEffect(() => {
const { items: storedItems, initialItemsCreated } = usePlacedItemsStore.getState();

setPlacedItems((prev) => {
// Start with all stored items (already persisted in localStorage)
const allItems = storedItems.length > 0 ? storedItems : prev;

// For personalRoom, create initial spawn items only once per session
// Only create if the item doesn't exist in ANY scene (not just this one)
if (sceneId === "personalRoom" && !initialItemsCreated) {
const trophyTakenOrMovedElsewhere = allItems.some((i) => i.itemId === "trophy");
// Only recreate the trophy if it has never left this room
// If it exists anywhere, assume it was taken/moved by the player
if (!trophyTakenOrMovedElsewhere) {
return [...allItems, ...createInitialPlacedItems(sceneId)];
}
}

return allItems;
});

// Mark initial items as created AFTER setState
const { items, initialItemsCreated } = usePlacedItemsStore.getState();
if (sceneId === "personalRoom" && !initialItemsCreated) {
const trophyExists = items.some((i) => i.itemId === "trophy");
if (!trophyExists) {
createInitialPlacedItems(sceneId).forEach((item) =>
usePlacedItemsStore.getState().addItem(item),
);
}
usePlacedItemsStore.getState().markInitialItemsCreated();
}
}, [sceneId]);

// Sync all placed items to localStorage via store
useEffect(() => {
setPlacedItemsInStore(placedItems);
}, [placedItems, setPlacedItemsInStore]);

const handleBoundaryHit = useCallback(
(phrase: string) => {
showDialog(phrase, "boundaryHit");
Expand Down Expand Up @@ -251,19 +232,18 @@ export function useInventoryRuntimeController({
}

if (decision.kind === "place") {
const placedItemWithScene = { ...decision.placedItem, sceneId };
emitRuntimeEvent(onRuntimeEvent, {
type: "onDrop",
outcome: "place",
itemId: payload.stack.id,
interactionId: interaction.id,
placedItem: placedItemWithScene,
});
setInventorySlots((currentSlots) =>
removeOneFromSlot(currentSlots, decision.fromSlotIndex),
);
setPlacedItems((currentPlaced) => [
...currentPlaced,
{ ...decision.placedItem, sceneId }, // Add sceneId to track which scene this item belongs to
]);
usePlacedItemsStore.getState().addItem(placedItemWithScene);
showSpeechBubble(getRandomPhrase(decision.dialogKey), {
dialogKey: decision.dialogKey,
});
Expand Down Expand Up @@ -394,11 +374,7 @@ export function useInventoryRuntimeController({
setInventorySlots(inventoryResult.slots);

// 2. Remove from placed items
setPlacedItems((currentPlaced) =>
currentPlaced.filter(
(currentItem) => currentItem.id !== decision.placedItemId,
),
);
usePlacedItemsStore.getState().removeItemById(decision.placedItemId);

emitRuntimeEvent(onRuntimeEvent, {
type: "onDrop",
Expand All @@ -413,14 +389,11 @@ export function useInventoryRuntimeController({

const updatePlacedItemPosition = useCallback(
(id: string, axis: 0 | 1 | 2, value: number) => {
setPlacedItems((currentPlaced) =>
currentPlaced.map((item) => {
const current = usePlacedItemsStore.getState().items;
usePlacedItemsStore.getState().setItems(
current.map((item) => {
if (item.id !== id) return item;
const worldPosition = [...item.worldPosition] as [
number,
number,
number,
];
const worldPosition = [...item.worldPosition] as [number, number, number];
worldPosition[axis] = value;
return { ...item, worldPosition };
}),
Expand All @@ -431,16 +404,13 @@ export function useInventoryRuntimeController({

const movePlacedItemToPlayer = useCallback(
(id: string) => {
setPlacedItems((currentPlaced) =>
currentPlaced.map((item) => {
const current = usePlacedItemsStore.getState().items;
usePlacedItemsStore.getState().setItems(
current.map((item) => {
if (item.id !== id) return item;
return {
...item,
worldPosition: [
playerPosition[0],
item.worldPosition[1],
playerPosition[2],
],
worldPosition: [playerPosition[0], item.worldPosition[1], playerPosition[2]],
};
}),
);
Expand All @@ -449,9 +419,7 @@ export function useInventoryRuntimeController({
);

const removePlacedItemById = useCallback((id: string) => {
setPlacedItems((currentPlaced) =>
currentPlaced.filter((item) => item.id !== id),
);
usePlacedItemsStore.getState().removeItemById(id);
}, []);

const handleStartInventoryDrag = useCallback(
Expand Down
47 changes: 47 additions & 0 deletions apps/web-demo/app/lib/net/__tests__/roomSession.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import { describe, it, expect } from "vitest";
import { createRoomSession, MAX_PLAYERS } from "../roomSession";
import { generateRoomCode, isValidRoomCode } from "../roomCode";
import { NoopStorageAdapter } from "../../platform-web";

describe("roomSession", () => {
it("generates valid codes without ambiguous chars", () => {
const c = generateRoomCode(() => 0);
expect(isValidRoomCode(c)).toBe(true);
expect(c).not.toMatch(/[0O1I]/);
});
it("create persists and restore returns it", () => {
const s1 = new NoopStorageAdapter();
const a = createRoomSession(s1);
const code = a.create();
const b = createRoomSession(s1);
expect(b.restore()).toBe(code);
});
it("reset produces a fresh code", () => {
let calls = 0;
// rng determinista que cambia entre llamadas para garantizar códigos distintos
const rng = () => {
calls += 1;
return (calls % 31) / 31;
};
const s = createRoomSession(new NoopStorageAdapter(), rng);
const first = s.create();
const second = s.reset();
expect(second).not.toBe(first);
});
it("capacity helpers reflect N/4", () => {
const s = createRoomSession(new NoopStorageAdapter());
expect(s.freeSlots(0)).toBe(MAX_PLAYERS - 1); // solo yo → 3 libres
expect(s.isFull(3)).toBe(true); // 3 remotos + yo = 4
});
it("join rejects invalid codes", () => {
const s = createRoomSession(new NoopStorageAdapter());
expect(() => s.join("xx")).toThrow();
});
it("forget clears persisted code", () => {
const storage = new NoopStorageAdapter();
const s = createRoomSession(storage);
s.create();
s.forget();
expect(createRoomSession(storage).restore()).toBeNull();
});
});
Loading