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
9 changes: 9 additions & 0 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -396,6 +396,15 @@ walking and authored traversal interactions such as climb, descend, drop, lift,
bridge, enter, inspect, and recovery helpers.
_Avoid_: mini-game controls, one-off arcade movement, required jump platforming

**Portrait-Cover Touch Control**:
Mobile input for side-view portrait-cover Phaser scenes such as the
Exploration Map, Hobbies, Basement, and Ridge. The player drags anywhere on
the game area for horizontal analog movement; a small ephemeral drag indicator
appears at the touch anchor only while dragging. Tap releases interact when the
touch did not become a move. Jump stays desktop-only for these scenes.
_Avoid_: fixed on-screen joystick, permanent jump/interact buttons, swipe-up
jump on mobile, vertical analog stick

**First Playable Interaction Vocabulary**:
The small shared verb set for the First Playable Route: move left/right,
interact or talk, inspect, enter/exit an Area Interior Pocket when present,
Expand Down
4 changes: 2 additions & 2 deletions docs/game-design/player-manual.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,9 @@ Current shipped behavior for interactive mode. Concept and future-design notes l

### Mobile / Touch

- **Move:** swipe left or right on the game area.
- **Jump:** swipe up.
- **Move:** drag left or right anywhere on the game area. A small drag indicator appears under your finger while moving.
- **Interact:** tap the game area.
- **Jump:** desktop only (up arrow). Not mapped on mobile touch.

## Overworld

Expand Down
78 changes: 14 additions & 64 deletions src/game/shell/Game.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef } from 'react';
import * as Phaser from 'phaser';
import { Circle, Hand } from 'lucide-react';
import { ControlMatDragIndicator } from '@/shared/ui';
import { useGameBridgeCallbacks } from './useGameBridgeCallbacks';
import { useGameTouchControls } from './useGameTouchControls';
import { usePhaserGameBoot } from './usePhaserGameBoot';
Expand Down Expand Up @@ -35,7 +35,7 @@ export default function Game({
}: GameProps) {
const containerRef = useRef<HTMLDivElement>(null);
const gameRef = useRef<Phaser.Game | null>(null);
const shouldUseGestureOverlay = presentationMode === 'portrait-cover';
const shouldUsePortraitCoverTouch = presentationMode === 'portrait-cover';
const {
bridgeRef,
stableOnEnterScene,
Expand Down Expand Up @@ -68,74 +68,24 @@ export default function Game({
return (
<div className={frameClassName}>
<div ref={containerRef} className="absolute inset-0 outline-none" />
{shouldUseGestureOverlay && (
{shouldUsePortraitCoverTouch && (
<>
<div
aria-label="Move and interact"
className="absolute inset-0 z-10 touch-none md:hidden"
{...touchControls.gestureHandlers}
role="application"
onPointerCancel={touchControls.onPointerCancel}
onPointerDown={touchControls.onPointerDown}
onPointerLeave={touchControls.onPointerLeave}
onPointerMove={touchControls.onPointerMove}
onPointerUp={touchControls.onPointerUp}
/>
<ControlMatDragIndicator
className="z-20 md:hidden"
state={touchControls.dragIndicator}
/>
<ExplorationTouchControls controls={touchControls} />
</>
)}
</div>
);
}

function ExplorationTouchControls({
controls
}: {
controls: ReturnType<typeof useGameTouchControls>;
}) {
/**
* Temporary Ridge exploration touch UI.
*
* Longer term, mobile controls should use one shared scene-control system instead of each
* mode inventing its own overlay. The target shape is closer to Stampede Sketch: a large
* touch/control mat around the game card with controls that appear under touch or fade when
* idle, so movement has more usable area and buttons do not permanently block the playfield.
*/
const actionButtonClassName = [
'pointer-events-auto flex h-14 w-14 items-center justify-center rounded-full border-2 border-[#1a1a1a]/70',
'bg-[#fbfbf9]/50 text-[#1a1a1a]/82 shadow-[3px_3px_0px_0px_rgba(26,26,26,0.32)] backdrop-blur-[2px]',
'touch-none active:scale-95 active:bg-[#f3df8b]/62'
].join(' ');

return (
<div className="pointer-events-none absolute inset-x-0 bottom-4 z-20 flex items-end justify-between px-5 md:hidden">
<div
aria-label="Move"
role="application"
className="pointer-events-auto relative h-28 w-28 rounded-full border-2 border-[#1a1a1a]/48 bg-[#fbfbf9]/32 shadow-[4px_4px_0px_0px_rgba(26,26,26,0.24)] backdrop-blur-[2px] touch-none"
{...controls.joystickHandlers}
>
<div className="absolute left-1/2 top-1/2 h-[2px] w-16 -translate-x-1/2 -translate-y-1/2 bg-[#1a1a1a]/18" />
<div className="absolute left-1/2 top-1/2 h-16 w-[2px] -translate-x-1/2 -translate-y-1/2 bg-[#1a1a1a]/18" />
<div
className="absolute h-12 w-12 rounded-full border-2 border-[#1a1a1a]/64 bg-[#f3df8b]/58 shadow-[2px_2px_0px_0px_rgba(26,26,26,0.24)]"
style={{
left: `calc(50% - 24px + ${controls.joystickOffset.x}px)`,
top: `calc(50% - 24px + ${controls.joystickOffset.y}px)`
}}
/>
</div>
<div className="flex gap-2">
<button
type="button"
aria-label="Jump"
className={actionButtonClassName}
{...controls.jumpButtonHandlers}
>
<Circle className="h-6 w-6" strokeWidth={2.4} aria-hidden />
</button>
<button
type="button"
aria-label="Interact"
className={actionButtonClassName}
{...controls.interactButtonHandlers}
>
<Hand className="h-6 w-6" strokeWidth={2.4} aria-hidden />
</button>
</div>
</div>
);
}
31 changes: 31 additions & 0 deletions src/game/shell/portraitCoverTouchInput.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
import { describe, expect, it } from 'vitest';
import {
resolvePortraitCoverHorizontalAxis,
shouldPortraitCoverDragActivate,
shouldPortraitCoverPointerTravelActivate
} from './portraitCoverTouchInput';

describe('portraitCoverTouchInput', () => {
it('returns zero inside the movement deadzone', () => {
expect(resolvePortraitCoverHorizontalAxis(4)).toBe(0);
expect(resolvePortraitCoverHorizontalAxis(-4)).toBe(0);
});

it('maps horizontal drag delta to a clamped axis', () => {
expect(resolvePortraitCoverHorizontalAxis(22)).toBeCloseTo(0.5);
expect(resolvePortraitCoverHorizontalAxis(-44)).toBe(-1);
expect(resolvePortraitCoverHorizontalAxis(80)).toBe(1);
});

it('activates drag only after horizontal threshold', () => {
expect(shouldPortraitCoverDragActivate(10)).toBe(false);
expect(shouldPortraitCoverDragActivate(16)).toBe(true);
expect(shouldPortraitCoverDragActivate(-20)).toBe(true);
});

it('activates pointer travel when either axis crosses threshold', () => {
expect(shouldPortraitCoverPointerTravelActivate(10, 10)).toBe(false);
expect(shouldPortraitCoverPointerTravelActivate(16, 0)).toBe(true);
expect(shouldPortraitCoverPointerTravelActivate(0, 16)).toBe(true);
});
});
28 changes: 28 additions & 0 deletions src/game/shell/portraitCoverTouchInput.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
const PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX = 44;
const PORTRAIT_COVER_TAP_THRESHOLD_PX = 15;
const PORTRAIT_COVER_MOVEMENT_DEADZONE = 0.18;

export function resolvePortraitCoverHorizontalAxis(
deltaX: number,
maxDistance = PORTRAIT_COVER_DRAG_MAX_DISTANCE_PX,
deadzone = PORTRAIT_COVER_MOVEMENT_DEADZONE
): number {
const normalized = Math.max(-1, Math.min(1, deltaX / maxDistance));
return Math.abs(normalized) >= deadzone ? normalized : 0;
}

export function shouldPortraitCoverDragActivate(
deltaX: number,
threshold = PORTRAIT_COVER_TAP_THRESHOLD_PX
): boolean {
return Math.abs(deltaX) > threshold;
}

export function shouldPortraitCoverPointerTravelActivate(
deltaX: number,
deltaY: number,
threshold = PORTRAIT_COVER_TAP_THRESHOLD_PX
): boolean {
return shouldPortraitCoverDragActivate(deltaX, threshold)
|| shouldPortraitCoverDragActivate(deltaY, threshold);
}
Loading
Loading