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
177 changes: 177 additions & 0 deletions src/VoxelWorld.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import TouchCameraControl from './components/TouchCameraControl';
import SettingsMenu from './components/SettingsMenu';
import ChangelogModal from './components/ChangelogModal';
import EmoteWheel from './components/EmoteWheel';
import TeleportWheel from './components/TeleportWheel';
import GameManager from './engine/GameManager';
import Puffle from './engine/Puffle';
import { createPenguinBuilder, cacheAnimatedParts, animateCosmeticsFromCache } from './engine/PenguinBuilder';
Expand Down Expand Up @@ -232,6 +233,55 @@ const VoxelWorld = ({
const emoteSelectionRef = useRef(-1); // Sticky selection - persists until changed
const emoteWheelKeyHeld = useRef(false); // Track if T is currently held
const emoteRef = useRef({ type: null, startTime: 0 });

// Teleport Wheel State
const [teleportWheelOpen, setTeleportWheelOpen] = useState(false);
const [teleportWheelSelection, setTeleportWheelSelection] = useState(-1);
const teleportSelectionRef = useRef(-1); // Sticky selection - persists until changed
const teleportWheelKeyHeld = useRef(false); // Track if M is currently held

// Teleport Locations - Maps room IDs to their destinations
const TELEPORT_LOCATIONS = [
{
id: 'fishing',
emoji: '🎣',
label: 'Ice Fishing',
color: 'bg-blue-500',
position: { x: CENTER_X - 70, z: CENTER_Z + 78 }, // Near fishing holes
disabledInRooms: [] // Always available (fishing is outdoors in town)
},
{
id: 'casino',
emoji: '🎰',
label: 'Casino',
color: 'bg-purple-500',
position: { x: CENTER_X - 42, z: CENTER_Z + 3 }, // Casino entrance
disabledInRooms: ['casino_game_room'] // Disabled when inside casino
},
{
id: 'nightclub',
emoji: '🎵',
label: 'Nightclub',
color: 'bg-pink-500',
position: { x: CENTER_X, z: CENTER_Z - 60 }, // Nightclub entrance
disabledInRooms: ['nightclub'] // Disabled when inside nightclub
},
{
id: 'pizza',
emoji: '🍕',
label: 'Pizza Parlor',
color: 'bg-orange-500',
position: { x: CENTER_X - 38, z: CENTER_Z + 35 }, // Pizza parlor entrance
disabledInRooms: ['pizza'] // Disabled when inside pizza parlor
}
];

// Filter locations based on current room (disable if you're already there)
const availableTeleportLocations = TELEPORT_LOCATIONS.map(loc => ({
...loc,
disabled: loc.disabledInRooms.includes(room)
}));

const [showPufflePanel, setShowPufflePanel] = useState(false);
const playerPuffleRef = useRef(null);
const aiPufflesRef = useRef([]); // { id, puffle }
Expand Down Expand Up @@ -1675,6 +1725,16 @@ const VoxelWorld = ({
emoteSelectionRef.current = -1;
}
}
if(e.code === 'KeyM') {
// DEV ONLY: Teleport wheel for debugging/testing
// Only open once when M is first pressed (not on repeat)
if (!teleportWheelKeyHeld.current && import.meta.env.MODE !== 'production') {
teleportWheelKeyHeld.current = true;
setTeleportWheelOpen(true);
setTeleportWheelSelection(-1);
teleportSelectionRef.current = -1;
}
}
if(e.code === 'F3') {
// F3 toggles debug position panel (like Minecraft) - DEV ONLY
e.preventDefault();
Expand Down Expand Up @@ -1708,6 +1768,23 @@ const VoxelWorld = ({
setEmoteWheelSelection(-1);
emoteSelectionRef.current = -1;
}
if(e.code === 'KeyM') {
// M released - close wheel and teleport to selection if any
teleportWheelKeyHeld.current = false;

const idx = teleportSelectionRef.current;
if (idx >= 0 && idx < availableTeleportLocations.length) {
const location = availableTeleportLocations[idx];
if (!location.disabled) {
triggerTeleport(location);
}
}

// Always close the wheel on M release
setTeleportWheelOpen(false);
setTeleportWheelSelection(-1);
teleportSelectionRef.current = -1;
}
};
window.addEventListener('keydown', handleDown);
window.addEventListener('keyup', handleUp);
Expand Down Expand Up @@ -3838,6 +3915,56 @@ const VoxelWorld = ({
}
};

// ==================== TELEPORT FUNCTION ====================
const triggerTeleport = (location) => {
if (!location.position || location.disabled) return;

// If not in town, save target position to localStorage, then transition to town
// The spawn logic will load from localStorage automatically
if (roomRef.current !== 'town' && onChangeRoom) {
// Save target position to localStorage so room change spawns us there
try {
localStorage.setItem('player_position', JSON.stringify({
x: location.position.x,
y: 1,
z: location.position.z,
room: 'town',
savedAt: Date.now()
}));
} catch (e) {
console.warn('Failed to save teleport position:', e);
}

// Change to town (will spawn at saved position)
onChangeRoom('town', null);
setActiveBubble(`✨ Teleported to ${location.label}!`);
return;
}

// Already in town - just update position directly
posRef.current.x = location.position.x;
posRef.current.z = location.position.z;
posRef.current.y = 1; // Ground level

// Update velocity to 0 (stop any movement)
velRef.current.x = 0;
velRef.current.z = 0;
velRef.current.y = 0;

// Update the visual mesh position immediately
if (playerRef.current) {
playerRef.current.position.set(location.position.x, 1, location.position.z);
}

// Send updated position to server
if (sendPosition) {
sendPosition(location.position.x, location.position.z);
}

// Show a chat bubble notification
setActiveBubble(`✨ Teleported to ${location.label}!`);
};

// ==================== EMOTE WHEEL - STICKY SELECTION ====================
// Selection stays on last hovered sector until a DIFFERENT sector is entered
useEffect(() => {
Expand Down Expand Up @@ -3877,6 +4004,45 @@ const VoxelWorld = ({
return () => document.removeEventListener('mousemove', handleMouseMove);
}, [emoteWheelOpen]);

// ==================== TELEPORT WHEEL - STICKY SELECTION ====================
// Selection stays on last hovered sector until a DIFFERENT sector is entered
useEffect(() => {
if (!teleportWheelOpen) return;

const centerX = window.innerWidth / 2;
const centerY = window.innerHeight / 2;
const DEAD_ZONE = 50; // pixels from center
const NUM_SECTORS = availableTeleportLocations.length;
const SECTOR_SIZE = 360 / NUM_SECTORS;

const handleMouseMove = (e) => {
const dx = e.clientX - centerX;
const dy = e.clientY - centerY;
const distance = Math.sqrt(dx * dx + dy * dy);

// DEAD ZONE: Don't change selection, keep the sticky value
if (distance < DEAD_ZONE) {
return; // Keep current selection
}

// Calculate angle (0° at top, clockwise)
let angle = Math.atan2(dx, -dy) * (180 / Math.PI);
if (angle < 0) angle += 360;

// Determine sector index
const newIndex = Math.floor(angle / SECTOR_SIZE) % NUM_SECTORS;

// STICKY: Only update if entering a DIFFERENT sector
if (newIndex !== teleportSelectionRef.current) {
teleportSelectionRef.current = newIndex;
setTeleportWheelSelection(newIndex);
}
};

document.addEventListener('mousemove', handleMouseMove);
return () => document.removeEventListener('mousemove', handleMouseMove);
}, [teleportWheelOpen, availableTeleportLocations]);

// ==================== VICTORY DANCE ====================
// Auto-trigger dance animation when player wins a match
useEffect(() => {
Expand Down Expand Up @@ -6316,6 +6482,17 @@ const VoxelWorld = ({
onSelect={triggerEmote}
onClose={() => setEmoteWheelOpen(false)}
/>

{/* Teleport Wheel (M key) - DEV/STAGING ONLY debugging tool */}
{import.meta.env.MODE !== 'production' && (
<TeleportWheel
isOpen={teleportWheelOpen}
selection={teleportWheelSelection}
items={availableTeleportLocations}
onSelect={triggerTeleport}
onClose={() => setTeleportWheelOpen(false)}
/>
)}

{/* Settings Menu Modal */}
<SettingsMenu
Expand Down
94 changes: 94 additions & 0 deletions src/components/TeleportWheel.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import React from 'react';

/**
* TeleportWheel - Radial location selection wheel for teleportation
*
* Props:
* - isOpen: boolean - Whether the wheel is visible
* - selection: number - Currently selected index (-1 for none)
* - items: Array<{ id, emoji, label, color }> - Location items to display
* - onSelect: (locationId) => void - Called when a location is selected
* - onClose: () => void - Called when wheel should close
*/
const TeleportWheel = ({ isOpen, selection, items, onSelect, onClose }) => {
if (!isOpen || !items?.length) return null;

const SECTOR_SIZE = 360 / items.length;
const radius = 110;

const handleLocationClick = (location) => {
onSelect(location);
onClose();
};

return (
<div className="absolute inset-0 flex items-center justify-center z-30 bg-black/40">
<div className="relative w-80 h-80">
{/* Location sectors arranged in a circle (top = index 0, clockwise) */}
{items.map((location, index) => {
const angle = (index * SECTOR_SIZE - 90) * (Math.PI / 180); // -90 to start at top
const x = Math.cos(angle) * radius;
const y = Math.sin(angle) * radius;
const isSelected = selection === index;

return (
<div
key={location.id}
className="absolute flex flex-col items-center justify-center"
style={{
left: `calc(50% + ${x}px)`,
top: `calc(50% + ${y}px)`,
transform: `translate(-50%, -50%) scale(${isSelected ? 1.2 : 1})`,
opacity: location.disabled ? 0.3 : (isSelected ? 1 : 0.6),
transition: 'transform 0.1s, opacity 0.1s',
cursor: location.disabled ? 'not-allowed' : 'pointer'
}}
onClick={() => !location.disabled && handleLocationClick(location)}
onTouchStart={() => !location.disabled && handleLocationClick(location)}
>
<div
className={`w-16 h-16 rounded-full ${location.color} flex items-center justify-center shadow-lg`}
style={{
border: location.disabled ? '2px solid rgba(255,255,255,0.2)' : (isSelected ? '4px solid white' : '2px solid rgba(255,255,255,0.4)'),
boxShadow: location.disabled ? 'none' : (isSelected ? '0 0 20px rgba(255,255,255,0.5)' : 'none'),
filter: location.disabled ? 'grayscale(100%)' : 'none'
}}
>
<span className="text-2xl">{location.emoji}</span>
</div>
<span className={`text-xs mt-2 retro-text font-bold ${location.disabled ? 'text-white/20' : (isSelected ? 'text-white' : 'text-white/50')}`}>
{location.label}
</span>
</div>
);
})}

{/* Center - shows current selection */}
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2
w-24 h-24 rounded-full bg-black/90 border-2 border-white/30
flex flex-col items-center justify-center">
{selection >= 0 && items[selection] ? (
<>
<span className="text-4xl" style={{ filter: items[selection].disabled ? 'grayscale(100%)' : 'none' }}>
{items[selection].emoji}
</span>
<span className={`text-xs retro-text mt-1 font-bold ${items[selection].disabled ? 'text-white/30' : 'text-white'}`}>
{items[selection].disabled ? 'Already here' : items[selection].label}
</span>
</>
) : (
<span className="text-white/40 text-xs retro-text text-center">Drag to<br/>select</span>
)}
</div>

{/* Instructions */}
<div className="absolute left-1/2 -translate-x-1/2 bottom-0 text-white/50 text-xs retro-text">
Release [M] to teleport
</div>
</div>
</div>
);
};

export default TeleportWheel;