
Platform Building
- 52 installs
- 8 repo stars
- Updated August 4, 2026
- bbeierle12/skill-mcp-claude
platform-building is a Claude skill that adds mobile touch, VR input, and accessibility patterns to cross-platform building games.
About
This skill provides platform-specific building systems covering mobile touch controls, VR spatial input, and accessibility patterns. It ships a touch build controller, a VR building adapter with comfort settings, and an accessibility config for colorblind modes, high contrast, and screen readers, plus platform detection. A developer uses it to make a building game work well across mobile, VR, and assistive contexts.
- Touch build controller with tap, drag, pinch, and swipe gestures
- VR building adapter with hand tracking and comfort settings
- Accessibility config for colorblind modes, contrast, and screen readers
Platform Building by the numbers
- 52 all-time installs (skills.sh)
- Ranked #160 of 247 Game Development skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
platform-building capabilities & compatibility
- Capabilities
- multiplayer building · performance at scale
- Use cases
- ui design
- Platforms
- macOS · Windows · Linux
What platform-building says it does
Platform-specific building systems for mobile, VR, and accessibility. Use when implementing touch controls for building games, VR spatial input, colorblind-friendly feedback
Valheim uses blue/yellow instead of green/red for stability indicators, supporting deuteranopia (the most common colorblind condition).
npx skills add https://github.com/bbeierle12/skill-mcp-claude --skill platform-buildingAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 52 |
|---|---|
| repo stars | ★ 8 |
| Last updated | August 4, 2026 |
| Repository | bbeierle12/skill-mcp-claude ↗ |
What it does
Add mobile touch controls, VR spatial input, and colorblind-friendly accessibility patterns to a cross-platform building game.
Who is it for?
Making a building game work across mobile, VR, and accessible input
Skip if: Desktop mouse-and-keyboard-only games with no accessibility needs
When should I use this skill?
Implementing touch controls, VR spatial input, colorblind feedback, or cross-platform building
What you get
A building system with adapted input and inclusive design across platforms
- Touch build controller
- VR building adapter
- Accessibility config
By the numbers
- 3 bundled scripts (~1200 lines total)
- Minimum 44px touch targets recommended
Files
Platform Building
Touch controls, VR input, and accessibility patterns for building systems.
Quick Start
import { TouchBuildController } from './scripts/touch-build-controller.js';
import { VRBuildingAdapter } from './scripts/vr-building-adapter.js';
import { AccessibilityConfig } from './scripts/accessibility-config.js';
// Mobile touch building
const touch = new TouchBuildController(canvas, {
doubleTapToPlace: true,
pinchToRotate: true,
swipeToChangePiece: true
});
touch.onPlace = (position, rotation) => buildingSystem.place(position, rotation);
touch.onRotate = (angle) => ghost.rotate(angle);
// VR building with hand tracking
const vr = new VRBuildingAdapter(xrSession, {
dominantHand: 'right',
snapToGrid: true,
comfortMode: true // Reduces motion sickness
});
vr.onGrab = (piece) => selection.select(piece);
vr.onRelease = (position) => buildingSystem.place(position);
// Accessibility configuration
const a11y = new AccessibilityConfig({
colorblindMode: 'deuteranopia', // red-green
highContrast: true,
screenReaderHints: true
});
// Apply to ghost preview
ghost.setColors(a11y.getValidityColors());Reference
See references/platform-considerations.md for:
- Mobile gesture patterns (Fortnite Mobile, Minecraft PE)
- VR building research and comfort guidelines
- Colorblind palette recommendations
- Screen reader integration patterns
- Cross-platform input abstraction
Scripts
| File | Lines | Purpose |
|---|---|---|
touch-build-controller.js | ~450 | Mobile gestures: tap, drag, pinch, swipe |
vr-building-adapter.js | ~400 | VR hand/controller input, comfort settings |
accessibility-config.js | ~350 | Colorblind modes, contrast, screen reader |
Platform Detection
// Detect platform capabilities
const platform = {
isMobile: /Android|iPhone|iPad|iPod/i.test(navigator.userAgent),
isTouch: 'ontouchstart' in window,
isVR: navigator.xr !== undefined,
prefersReducedMotion: window.matchMedia('(prefers-reduced-motion: reduce)').matches,
prefersHighContrast: window.matchMedia('(prefers-contrast: more)').matches
};
// Initialize appropriate controllers
if (platform.isVR && xrSession) {
setupVRControls();
} else if (platform.isTouch) {
setupTouchControls();
} else {
setupMouseKeyboard();
}Mobile Considerations
Fortnite Mobile demonstrates effective touch building with customizable HUD, auto-material selection, and gesture-based piece rotation. Key patterns include dedicated build mode toggle, large touch targets (minimum 44px), and visual feedback for all actions.
VR Considerations
VR building requires attention to comfort. Snap rotation reduces motion sickness, and arm-length building distances prevent fatigue. Hand tracking enables intuitive grab-and-place, while controller building benefits from laser-pointer selection for distant pieces.
Accessibility Patterns
Valheim uses blue/yellow instead of green/red for stability indicators, supporting deuteranopia (the most common colorblind condition). High contrast modes should use 7:1 ratio for critical indicators, and all visual feedback should have audio/haptic alternatives.
{
"name": "platform-building",
"description": "Platform-specific building systems for mobile, VR, and accessibility. Use when implementing touch controls for building games, VR spatial input, colorblind-friendly feedback, or cross-platform building mechanics.",
"tags": [
"building-game",
"javascript",
"code-generation"
],
"sub_skills": [],
"source": "claude-user",
"type": "template",
"depends_on": [],
"enhances": [
"multiplayer-building",
"builder-ux"
],
"last_reviewed_at": null,
"review_score": null,
"relevance_tier": null
}
Platform Considerations for Building Systems
Building mechanics require adaptation across platforms. Mobile touch lacks hover states and precision, VR adds spatial depth but introduces comfort concerns, and accessibility requirements affect visual feedback systems. This reference covers patterns from successful games and research-backed guidelines.
Mobile Touch Patterns
Gesture Vocabulary
Touch building uses a limited gesture set to avoid conflicts and ensure discoverability. Fortnite Mobile's approach demonstrates the minimal effective set.
Core Gestures:
- Single tap: Select piece type / Confirm placement
- Tap and hold: Enter placement mode / Show options menu
- Drag: Move piece position / Pan camera
- Pinch: Zoom camera / Scale piece (if supported)
- Two-finger rotate: Rotate piece around Y axis
- Swipe (edge): Cycle piece types / Switch materials
Conflict Resolution:
Distinguishing tap from drag requires a distance threshold (typically 10-15px) and time threshold (150-200ms). If the touch moves beyond the distance threshold before the time threshold, it's a drag. If time expires without movement, it's a tap-hold.
const DRAG_THRESHOLD = 12; // pixels
const HOLD_THRESHOLD = 180; // milliseconds
function classifyGesture(touchStart, touchCurrent, elapsed) {
const distance = Math.hypot(
touchCurrent.x - touchStart.x,
touchCurrent.y - touchStart.y
);
if (distance > DRAG_THRESHOLD) return 'drag';
if (elapsed > HOLD_THRESHOLD) return 'hold';
return 'pending';
}Touch Target Sizes
Apple Human Interface Guidelines specify 44x44pt minimum touch targets. For building games where precision matters, slightly larger targets (48-56pt) reduce errors. Fortnite Mobile uses 52pt for build piece buttons.
Recommended Sizes:
- Primary actions (place, delete): 52-56pt
- Secondary actions (rotate, upgrade): 44-48pt
- Navigation (camera controls): Full edge zones (64pt strips)
Build Mode Patterns
Fortnite Mobile Approach:
Fortnite uses a dedicated build mode toggle. When active, the screen layout changes to show piece selection prominently, and tap behavior shifts from combat to building. This modal approach prevents accidental placement during movement.
Minecraft Pocket Edition Approach:
Minecraft PE uses contextual building. Tap on existing blocks places adjacent blocks, tap and hold destroys. No explicit mode toggle, but requires existing geometry as reference. Better for exploration games, worse for rapid construction.
Recommended Hybrid:
For survival builders, combine approaches. Default to contextual mode for simple placements, with a build mode toggle for complex construction. The toggle can be a floating action button or a gesture (three-finger tap).
Performance Budgets
Mobile GPUs are constrained. Building games must balance piece complexity against device capability.
Device Tiers:
| Tier | Example Devices | Max Pieces | Triangle Budget |
|---|---|---|---|
| Low | iPhone 8, older Android | 500 | 50k |
| Medium | iPhone 11, mid Android | 2,000 | 200k |
| High | iPhone 14+, flagship Android | 5,000 | 500k |
Adaptive Quality:
Implement LOD based on device tier and current frame time.
function getQualityLevel(avgFrameTime, deviceTier) {
if (avgFrameTime > 33) return 'low'; // Below 30fps
if (avgFrameTime > 20) return 'medium'; // Below 50fps
return deviceTier === 'high' ? 'high' : 'medium';
}Auto-Material Selection
Mobile benefits from reducing decision points. Auto-material selection chooses the highest available material the player can afford, reducing taps.
function autoSelectMaterial(pieceType, inventory) {
const materials = ['armored', 'metal', 'stone', 'wood', 'twig'];
for (const material of materials) {
const cost = getCost(pieceType, material);
if (hasResources(inventory, cost)) {
return material;
}
}
return null; // Can't afford any material
}VR Building Patterns
Input Modalities
VR supports multiple input types with different characteristics.
Controller-Based:
Dominant in current VR. Trigger for select/place, grip for grab, thumbstick for rotation. Laser pointer for distant selection (beyond arm's reach).
Hand Tracking:
Emerging with Quest hand tracking. Pinch gesture for select, palm-down release for place, rotation by physically rotating hand. More intuitive but less precise.
Gaze-Based:
Fallback for seated VR or accessibility. Head direction selects, button confirms. Slower but works for everyone.
Comfort Guidelines
VR building introduces unique comfort concerns. Motion sickness affects 40-70% of users depending on experience design.
Snap Rotation:
Instead of smooth rotation, use snap increments (typically 30°, 45°, or 90°). Eliminates vection (visual motion without physical motion), the primary cause of VR sickness.
const SNAP_ANGLES = {
coarse: Math.PI / 2, // 90°
medium: Math.PI / 4, // 45°
fine: Math.PI / 6 // 30°
};
function snapRotation(current, direction, granularity = 'medium') {
const snap = SNAP_ANGLES[granularity];
return current + (direction * snap);
}Building Distance:
Pieces placed too close cause eye strain (vergence-accommodation conflict). Minimum comfortable distance is 0.5m, optimal is 1-2m.
const MIN_BUILD_DISTANCE = 0.5; // meters
const MAX_BUILD_DISTANCE = 10; // meters
const OPTIMAL_DISTANCE = 1.5;
function clampBuildDistance(distance) {
return Math.max(MIN_BUILD_DISTANCE, Math.min(MAX_BUILD_DISTANCE, distance));
}Arm Fatigue (Gorilla Arm):
Extended arm positions cause fatigue within 1-2 minutes. Building UI should be positioned at hip/waist level when not in active use. Consider "build from palm" where pieces spawn from the open hand rather than requiring reach.
Teleportation Building:
For large structures, allow teleportation while in build mode. Smooth locomotion during building increases sickness. Implement "build station" concept where player teleports to scaffolding positions.
VR-Specific Feedback
Haptics:
Controller vibration communicates placement validity. Short pulse for valid, double pulse for invalid, long pulse for confirm.
const HAPTIC_PATTERNS = {
valid: { duration: 50, intensity: 0.3 },
invalid: { duration: 30, intensity: 0.5, repeat: 2, gap: 50 },
confirm: { duration: 100, intensity: 0.6 },
grab: { duration: 40, intensity: 0.4 }
};
function triggerHaptic(controller, pattern) {
const p = HAPTIC_PATTERNS[pattern];
if (p.repeat) {
for (let i = 0; i < p.repeat; i++) {
setTimeout(() => {
controller.gamepad.hapticActuators[0]?.pulse(p.intensity, p.duration);
}, i * (p.duration + p.gap));
}
} else {
controller.gamepad.hapticActuators[0]?.pulse(p.intensity, p.duration);
}
}Spatial Audio:
Sound position reinforces placement location. Place confirmation sound at piece position, not at player ears. Use 3D audio spatialization.
Visual Guides:
Grid overlays help placement in VR where depth perception can be uncertain. Show floor grid, wall alignment guides, and snap indicators.
Accessibility Patterns
Color Vision Deficiency
8% of males and 0.5% of females have some form of color vision deficiency. Red-green deficiency (deuteranopia/protanopia) is most common.
Problematic Patterns:
Traditional validity feedback (green = valid, red = invalid) fails for red-green colorblind users. These colors appear as similar shades of brown/olive.
Accessible Alternatives:
| Feedback Type | Traditional | Accessible Alternative |
|---|---|---|
| Valid placement | Green (#00FF00) | Blue (#0066FF) |
| Invalid placement | Red (#FF0000) | Orange/Yellow (#FF9900) |
| Stability high | Green | Blue (#3366FF) |
| Stability low | Red | Yellow (#FFCC00) |
| Stability critical | Dark red | White with pattern |
Valheim uses this blue/yellow pattern and it works well.
Additional Cues:
Color should never be the only indicator. Combine with shape, animation, or pattern.
const VALIDITY_INDICATORS = {
valid: {
color: 0x0066ff,
pattern: 'solid',
animation: 'none',
icon: 'checkmark'
},
invalid: {
color: 0xff9900,
pattern: 'striped',
animation: 'pulse',
icon: 'x'
},
blocked: {
color: 0xffcc00,
pattern: 'dashed',
animation: 'shake',
icon: 'lock'
}
};Colorblind Simulation
Test designs by simulating color vision deficiency.
// Approximate color transforms for colorblind simulation
const COLORBLIND_MATRICES = {
protanopia: [
0.567, 0.433, 0.000,
0.558, 0.442, 0.000,
0.000, 0.242, 0.758
],
deuteranopia: [
0.625, 0.375, 0.000,
0.700, 0.300, 0.000,
0.000, 0.300, 0.700
],
tritanopia: [
0.950, 0.050, 0.000,
0.000, 0.433, 0.567,
0.000, 0.475, 0.525
]
};
function simulateColorblind(color, type) {
const matrix = COLORBLIND_MATRICES[type];
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
return {
r: r * matrix[0] + g * matrix[1] + b * matrix[2],
g: r * matrix[3] + g * matrix[4] + b * matrix[5],
b: r * matrix[6] + g * matrix[7] + b * matrix[8]
};
}High Contrast Mode
Some users need increased contrast. Detect system preference and provide manual toggle.
// Detect system preference
const prefersHighContrast = window.matchMedia('(prefers-contrast: more)').matches;
// High contrast palette
const CONTRAST_PALETTES = {
normal: {
background: 0x1a1a1a,
foreground: 0xffffff,
accent: 0x0088ff,
error: 0xff4444,
gridLines: 0x333333
},
highContrast: {
background: 0x000000,
foreground: 0xffffff,
accent: 0x00ffff,
error: 0xffff00,
gridLines: 0xffffff
}
};WCAG 2.1 requires 4.5:1 contrast ratio for normal text, 3:1 for large text, and 3:1 for UI components. For building feedback, aim for 7:1 on critical indicators.
Screen Reader Integration
Building games are inherently visual, but screen reader support helps blind/low-vision users and benefits everyone with audio feedback.
ARIA Live Regions:
Announce placement results without requiring focus change.
// Create announcement region
const announcer = document.createElement('div');
announcer.setAttribute('aria-live', 'polite');
announcer.setAttribute('aria-atomic', 'true');
announcer.className = 'sr-only'; // Visually hidden
document.body.appendChild(announcer);
function announce(message, priority = 'polite') {
announcer.setAttribute('aria-live', priority);
announcer.textContent = message;
}
// Usage
function onPlacement(result) {
if (result.success) {
announce(`Placed ${result.pieceType} at grid position ${result.gridX}, ${result.gridZ}`);
} else {
announce(`Cannot place: ${result.reason}`, 'assertive');
}
}Spatial Descriptions:
Describe piece positions in meaningful terms.
function describePosition(position, referencePoint) {
const dx = position.x - referencePoint.x;
const dz = position.z - referencePoint.z;
const distance = Math.sqrt(dx * dx + dz * dz);
const direction = getCardinalDirection(dx, dz);
return `${Math.round(distance)} meters ${direction}`;
}
function getCardinalDirection(dx, dz) {
const angle = Math.atan2(dz, dx) * (180 / Math.PI);
if (angle > -22.5 && angle <= 22.5) return 'east';
if (angle > 22.5 && angle <= 67.5) return 'southeast';
if (angle > 67.5 && angle <= 112.5) return 'south';
// ... etc
}Reduced Motion
Some users experience motion sickness or discomfort from animations. Respect system preference.
const prefersReducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
const ANIMATION_SETTINGS = {
normal: {
ghostPulse: true,
placementBounce: true,
cameraSmoothing: 0.1,
rotationAnimated: true
},
reduced: {
ghostPulse: false,
placementBounce: false,
cameraSmoothing: 0, // Instant
rotationAnimated: false // Snap instead
}
};Motor Accessibility
Not all users have fine motor control. Provide alternatives to precise gestures.
Timing Adjustments:
Allow configuration of timing thresholds for tap-hold, double-tap, etc.
const TIMING_PRESETS = {
default: { holdTime: 180, doubleTapWindow: 300 },
relaxed: { holdTime: 400, doubleTapWindow: 500 },
extended: { holdTime: 800, doubleTapWindow: 1000 }
};Alternative Input:
Support keyboard/switch control for all functions.
const KEYBOARD_BINDINGS = {
'ArrowUp': 'movePieceForward',
'ArrowDown': 'movePieceBack',
'ArrowLeft': 'movePieceLeft',
'ArrowRight': 'movePieceRight',
'PageUp': 'movePieceUp',
'PageDown': 'movePieceDown',
'q': 'rotateCCW',
'e': 'rotateCW',
'Enter': 'confirmPlacement',
'Escape': 'cancelPlacement',
'Tab': 'nextPieceType',
'Shift+Tab': 'prevPieceType'
};Cross-Platform Abstraction
Input Abstraction Layer
Unify input handling across platforms behind a common interface.
class BuildInputManager {
constructor() {
this.handlers = new Map();
this.activeController = null;
}
// Register platform-specific controllers
registerController(platform, controller) {
this.handlers.set(platform, controller);
}
// Detect and activate appropriate controller
activate() {
if (navigator.xr) {
this.activeController = this.handlers.get('vr');
} else if ('ontouchstart' in window) {
this.activeController = this.handlers.get('touch');
} else {
this.activeController = this.handlers.get('desktop');
}
this.activeController?.activate();
}
// Unified event interface
on(event, callback) {
// Events: 'select', 'place', 'cancel', 'rotate', 'move', 'cycleType'
this.activeController?.on(event, callback);
}
}Consistent Feedback
Ensure feedback works across all platforms.
class FeedbackManager {
constructor(options = {}) {
this.visualEnabled = options.visual ?? true;
this.audioEnabled = options.audio ?? true;
this.hapticEnabled = options.haptic ?? true;
}
feedback(type, data) {
if (this.visualEnabled) this.visualFeedback(type, data);
if (this.audioEnabled) this.audioFeedback(type, data);
if (this.hapticEnabled) this.hapticFeedback(type, data);
}
visualFeedback(type, data) {
// Color flash, animation, etc.
}
audioFeedback(type, data) {
// Sound effect
}
hapticFeedback(type, data) {
// Vibration (mobile) or controller rumble (VR/gamepad)
if ('vibrate' in navigator) {
navigator.vibrate(type === 'error' ? [50, 50, 50] : [30]);
}
}
}Testing Checklist
Mobile Testing
- [ ] All gestures work with single hand
- [ ] Touch targets meet minimum size (44pt)
- [ ] Works in both portrait and landscape
- [ ] Performance acceptable on low-tier devices
- [ ] Works with screen magnification enabled
VR Testing
- [ ] No discomfort after 15 minutes of building
- [ ] Works with both hands as dominant
- [ ] Snap rotation feels comfortable
- [ ] Haptic feedback distinguishable
- [ ] Works seated and standing
Accessibility Testing
- [ ] Tested with colorblind simulation
- [ ] All functions keyboard accessible
- [ ] Screen reader announces all actions
- [ ] Reduced motion preference respected
- [ ] High contrast mode readable
- [ ] Timing adjustable for motor accessibility
/**
* AccessibilityConfig - Accessibility settings for building systems
*
* Manages colorblind-friendly palettes, high contrast modes, reduced
* motion preferences, and screen reader integration. Based on WCAG 2.1
* guidelines and patterns from accessible games like Valheim.
*
* Usage:
* const a11y = new AccessibilityConfig({
* colorblindMode: 'deuteranopia',
* highContrast: true
* });
* ghost.setColors(a11y.getValidityColors());
* a11y.announce('Placed wall at grid 5, 3');
*/
/**
* Colorblind modes supported
*/
export const ColorblindMode = {
NONE: 'none',
PROTANOPIA: 'protanopia', // Red-blind (~1% males)
DEUTERANOPIA: 'deuteranopia', // Green-blind (~6% males)
TRITANOPIA: 'tritanopia', // Blue-blind (rare)
ACHROMATOPSIA: 'achromatopsia' // Full color blindness (very rare)
};
/**
* Default color palettes for different modes
*/
const ColorPalettes = {
[ColorblindMode.NONE]: {
valid: 0x00ff00, // Green
invalid: 0xff0000, // Red
warning: 0xffaa00, // Orange
blocked: 0xff6600, // Dark orange
neutral: 0x888888, // Gray
highlight: 0x00aaff, // Blue
stabilityHigh: 0x00ff00,
stabilityMedium: 0xffff00,
stabilityLow: 0xff6600,
stabilityCritical: 0xff0000
},
[ColorblindMode.PROTANOPIA]: {
valid: 0x0066ff, // Blue (instead of green)
invalid: 0xffcc00, // Yellow (instead of red)
warning: 0xff9900, // Orange
blocked: 0xffffff, // White
neutral: 0x888888,
highlight: 0x00ccff,
stabilityHigh: 0x0066ff,
stabilityMedium: 0x00ccff,
stabilityLow: 0xffcc00,
stabilityCritical: 0xffffff
},
[ColorblindMode.DEUTERANOPIA]: {
valid: 0x0066ff, // Blue (Valheim pattern)
invalid: 0xffcc00, // Yellow/Gold
warning: 0xff9900,
blocked: 0xffffff,
neutral: 0x888888,
highlight: 0x00ccff,
stabilityHigh: 0x0066ff,
stabilityMedium: 0x00ccff,
stabilityLow: 0xffcc00,
stabilityCritical: 0xffffff
},
[ColorblindMode.TRITANOPIA]: {
valid: 0x00ff00, // Green (preserved)
invalid: 0xff0066, // Magenta (instead of red)
warning: 0xff6699,
blocked: 0xffffff,
neutral: 0x888888,
highlight: 0x00ff99,
stabilityHigh: 0x00ff00,
stabilityMedium: 0x99ff00,
stabilityLow: 0xff6699,
stabilityCritical: 0xff0066
},
[ColorblindMode.ACHROMATOPSIA]: {
valid: 0xffffff, // White
invalid: 0x333333, // Dark gray
warning: 0xaaaaaa, // Medium gray
blocked: 0x666666,
neutral: 0x888888,
highlight: 0xffffff,
stabilityHigh: 0xffffff,
stabilityMedium: 0xcccccc,
stabilityLow: 0x666666,
stabilityCritical: 0x333333
}
};
/**
* High contrast palette modifications
*/
const HighContrastModifiers = {
background: 0x000000,
foreground: 0xffffff,
borderWidth: 2,
outlineEnabled: true,
patternOverlay: true
};
/**
* Timing presets for motor accessibility
*/
export const TimingPresets = {
default: {
longPress: 400,
doubleTap: 300,
holdToConfirm: 500,
animationDuration: 200
},
relaxed: {
longPress: 800,
doubleTap: 500,
holdToConfirm: 1000,
animationDuration: 400
},
extended: {
longPress: 1500,
doubleTap: 800,
holdToConfirm: 2000,
animationDuration: 600
}
};
export class AccessibilityConfig {
/**
* Create accessibility configuration
* @param {Object} options - Configuration options
*/
constructor(options = {}) {
// Colorblind settings
this.colorblindMode = options.colorblindMode ?? ColorblindMode.NONE;
this.customPalette = options.customPalette ?? null;
// Contrast settings
this.highContrast = options.highContrast ?? false;
this.contrastRatio = options.contrastRatio ?? 4.5; // WCAG AA
// Motion settings
this.reducedMotion = options.reducedMotion ?? this.detectReducedMotion();
this.disableParallax = options.disableParallax ?? this.reducedMotion;
this.disablePulse = options.disablePulse ?? this.reducedMotion;
// Timing settings
this.timingPreset = options.timingPreset ?? 'default';
this.customTimings = options.customTimings ?? null;
// Audio settings
this.screenReaderEnabled = options.screenReaderEnabled ?? false;
this.audioFeedback = options.audioFeedback ?? true;
this.hapticFeedback = options.hapticFeedback ?? true;
// Visual aids
this.showPatterns = options.showPatterns ?? false; // Pattern overlays on colors
this.showIcons = options.showIcons ?? true; // Icons alongside colors
this.largeText = options.largeText ?? false;
this.textScale = options.textScale ?? 1.0;
// Screen reader announcer element
this.announcer = null;
this.announcerPolite = null;
// Initialize
this.initialize();
}
/**
* Initialize accessibility features
*/
initialize() {
// Detect system preferences
this.detectSystemPreferences();
// Create screen reader announcer
this.createAnnouncer();
}
/**
* Detect system accessibility preferences
*/
detectSystemPreferences() {
if (typeof window === 'undefined') return;
// Reduced motion
if (window.matchMedia) {
const motionQuery = window.matchMedia('(prefers-reduced-motion: reduce)');
if (motionQuery.matches) {
this.reducedMotion = true;
this.disableParallax = true;
this.disablePulse = true;
}
// High contrast
const contrastQuery = window.matchMedia('(prefers-contrast: more)');
if (contrastQuery.matches) {
this.highContrast = true;
}
// Color scheme (for potential dark mode adjustments)
const darkQuery = window.matchMedia('(prefers-color-scheme: dark)');
this.prefersDark = darkQuery.matches;
}
}
/**
* Detect if user prefers reduced motion
*/
detectReducedMotion() {
if (typeof window === 'undefined') return false;
return window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
}
/**
* Create screen reader announcer elements
*/
createAnnouncer() {
if (typeof document === 'undefined') return;
// Assertive announcer (interrupts)
this.announcer = document.createElement('div');
this.announcer.setAttribute('role', 'alert');
this.announcer.setAttribute('aria-live', 'assertive');
this.announcer.setAttribute('aria-atomic', 'true');
this.announcer.className = 'sr-only';
this.applyScreenReaderOnlyStyles(this.announcer);
// Polite announcer (waits)
this.announcerPolite = document.createElement('div');
this.announcerPolite.setAttribute('role', 'status');
this.announcerPolite.setAttribute('aria-live', 'polite');
this.announcerPolite.setAttribute('aria-atomic', 'true');
this.announcerPolite.className = 'sr-only';
this.applyScreenReaderOnlyStyles(this.announcerPolite);
document.body.appendChild(this.announcer);
document.body.appendChild(this.announcerPolite);
}
/**
* Apply screen-reader-only styles
*/
applyScreenReaderOnlyStyles(element) {
element.style.cssText = `
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
`;
}
/**
* Announce message to screen readers
* @param {string} message - Message to announce
* @param {string} priority - 'polite' or 'assertive'
*/
announce(message, priority = 'polite') {
const announcer = priority === 'assertive' ? this.announcer : this.announcerPolite;
if (!announcer) return;
// Clear and set new message (triggers announcement)
announcer.textContent = '';
// Small delay to ensure screen readers pick up the change
requestAnimationFrame(() => {
announcer.textContent = message;
});
}
/**
* Announce placement result
*/
announcePlacement(result) {
if (result.success) {
const position = this.formatPosition(result.position);
this.announce(`Placed ${result.pieceType} at ${position}`, 'polite');
} else {
this.announce(`Cannot place: ${result.reason}`, 'assertive');
}
}
/**
* Announce selection change
*/
announceSelection(piece) {
if (piece) {
const position = this.formatPosition(piece.position);
this.announce(`Selected ${piece.type} at ${position}`, 'polite');
} else {
this.announce('Selection cleared', 'polite');
}
}
/**
* Format position for announcement
*/
formatPosition(position) {
if (!position) return 'unknown location';
const x = Math.round(position.x);
const y = Math.round(position.y);
const z = Math.round(position.z);
return `grid ${x}, ${y}, ${z}`;
}
/**
* Get current color palette
* @returns {Object} Color palette
*/
getPalette() {
if (this.customPalette) {
return { ...ColorPalettes[ColorblindMode.NONE], ...this.customPalette };
}
return ColorPalettes[this.colorblindMode] ?? ColorPalettes[ColorblindMode.NONE];
}
/**
* Get validity colors (for ghost preview)
* @returns {Object} Valid/invalid colors
*/
getValidityColors() {
const palette = this.getPalette();
return {
valid: palette.valid,
invalid: palette.invalid,
warning: palette.warning,
blocked: palette.blocked
};
}
/**
* Get stability colors (for piece stability display)
* @returns {Object} Stability gradient colors
*/
getStabilityColors() {
const palette = this.getPalette();
return {
high: palette.stabilityHigh,
medium: palette.stabilityMedium,
low: palette.stabilityLow,
critical: palette.stabilityCritical
};
}
/**
* Get color for stability value
* @param {number} stability - 0-1 stability value
* @returns {number} Color hex value
*/
getStabilityColor(stability) {
const colors = this.getStabilityColors();
if (stability >= 0.75) return colors.high;
if (stability >= 0.5) return colors.medium;
if (stability >= 0.25) return colors.low;
return colors.critical;
}
/**
* Get timing settings
* @returns {Object} Timing values
*/
getTimings() {
if (this.customTimings) {
return { ...TimingPresets.default, ...this.customTimings };
}
return TimingPresets[this.timingPreset] ?? TimingPresets.default;
}
/**
* Get animation settings
* @returns {Object} Animation configuration
*/
getAnimationSettings() {
const timings = this.getTimings();
return {
enabled: !this.reducedMotion,
duration: this.reducedMotion ? 0 : timings.animationDuration,
pulseEnabled: !this.disablePulse,
parallaxEnabled: !this.disableParallax,
useSnapTransitions: this.reducedMotion
};
}
/**
* Get visual indicator settings
* @returns {Object} Indicator configuration
*/
getIndicatorSettings() {
return {
showPatterns: this.showPatterns || this.colorblindMode !== ColorblindMode.NONE,
showIcons: this.showIcons,
useHighContrast: this.highContrast,
borderWidth: this.highContrast ? HighContrastModifiers.borderWidth : 1,
outlineEnabled: this.highContrast
};
}
/**
* Get text settings
* @returns {Object} Text configuration
*/
getTextSettings() {
return {
scale: this.textScale * (this.largeText ? 1.25 : 1.0),
minSize: this.largeText ? 16 : 12,
highContrast: this.highContrast
};
}
/**
* Set colorblind mode
*/
setColorblindMode(mode) {
if (!Object.values(ColorblindMode).includes(mode)) {
console.warn(`Unknown colorblind mode: ${mode}`);
return;
}
this.colorblindMode = mode;
}
/**
* Set high contrast mode
*/
setHighContrast(enabled) {
this.highContrast = enabled;
}
/**
* Set reduced motion
*/
setReducedMotion(enabled) {
this.reducedMotion = enabled;
this.disableParallax = enabled;
this.disablePulse = enabled;
}
/**
* Set timing preset
*/
setTimingPreset(preset) {
if (!TimingPresets[preset]) {
console.warn(`Unknown timing preset: ${preset}`);
return;
}
this.timingPreset = preset;
}
/**
* Set custom timing value
*/
setCustomTiming(key, value) {
if (!this.customTimings) {
this.customTimings = {};
}
this.customTimings[key] = value;
}
/**
* Set text scale
*/
setTextScale(scale) {
this.textScale = Math.max(0.5, Math.min(2.0, scale));
}
/**
* Simulate colorblind view of a color
* @param {number} color - Original color
* @param {string} mode - Colorblind mode to simulate
* @returns {number} Simulated color
*/
simulateColorblind(color, mode = this.colorblindMode) {
if (mode === ColorblindMode.NONE) return color;
// Extract RGB
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
// Transformation matrices (simplified)
const matrices = {
[ColorblindMode.PROTANOPIA]: [
[0.567, 0.433, 0.000],
[0.558, 0.442, 0.000],
[0.000, 0.242, 0.758]
],
[ColorblindMode.DEUTERANOPIA]: [
[0.625, 0.375, 0.000],
[0.700, 0.300, 0.000],
[0.000, 0.300, 0.700]
],
[ColorblindMode.TRITANOPIA]: [
[0.950, 0.050, 0.000],
[0.000, 0.433, 0.567],
[0.000, 0.475, 0.525]
]
};
const matrix = matrices[mode];
if (!matrix) return color;
const newR = Math.round(r * matrix[0][0] + g * matrix[0][1] + b * matrix[0][2]);
const newG = Math.round(r * matrix[1][0] + g * matrix[1][1] + b * matrix[1][2]);
const newB = Math.round(r * matrix[2][0] + g * matrix[2][1] + b * matrix[2][2]);
return (newR << 16) | (newG << 8) | newB;
}
/**
* Calculate contrast ratio between two colors
* @param {number} color1 - First color
* @param {number} color2 - Second color
* @returns {number} Contrast ratio (1-21)
*/
calculateContrastRatio(color1, color2) {
const lum1 = this.getRelativeLuminance(color1);
const lum2 = this.getRelativeLuminance(color2);
const lighter = Math.max(lum1, lum2);
const darker = Math.min(lum1, lum2);
return (lighter + 0.05) / (darker + 0.05);
}
/**
* Get relative luminance of a color
*/
getRelativeLuminance(color) {
const r = ((color >> 16) & 0xff) / 255;
const g = ((color >> 8) & 0xff) / 255;
const b = (color & 0xff) / 255;
const toLinear = (c) => c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
return 0.2126 * toLinear(r) + 0.7152 * toLinear(g) + 0.0722 * toLinear(b);
}
/**
* Check if color combination meets WCAG contrast requirements
*/
meetsContrastRequirement(foreground, background, level = 'AA') {
const ratio = this.calculateContrastRatio(foreground, background);
// AA: 4.5:1 for normal text, 3:1 for large text/UI
// AAA: 7:1 for normal text, 4.5:1 for large text
const requirements = {
'AA': 4.5,
'AA-large': 3,
'AAA': 7,
'AAA-large': 4.5
};
return ratio >= (requirements[level] ?? 4.5);
}
/**
* Serialize configuration for storage
*/
serialize() {
return {
colorblindMode: this.colorblindMode,
highContrast: this.highContrast,
reducedMotion: this.reducedMotion,
timingPreset: this.timingPreset,
customTimings: this.customTimings,
showPatterns: this.showPatterns,
showIcons: this.showIcons,
largeText: this.largeText,
textScale: this.textScale,
screenReaderEnabled: this.screenReaderEnabled,
audioFeedback: this.audioFeedback,
hapticFeedback: this.hapticFeedback
};
}
/**
* Load configuration from storage
*/
load(data) {
if (data.colorblindMode) this.colorblindMode = data.colorblindMode;
if (data.highContrast !== undefined) this.highContrast = data.highContrast;
if (data.reducedMotion !== undefined) this.reducedMotion = data.reducedMotion;
if (data.timingPreset) this.timingPreset = data.timingPreset;
if (data.customTimings) this.customTimings = data.customTimings;
if (data.showPatterns !== undefined) this.showPatterns = data.showPatterns;
if (data.showIcons !== undefined) this.showIcons = data.showIcons;
if (data.largeText !== undefined) this.largeText = data.largeText;
if (data.textScale !== undefined) this.textScale = data.textScale;
if (data.screenReaderEnabled !== undefined) this.screenReaderEnabled = data.screenReaderEnabled;
if (data.audioFeedback !== undefined) this.audioFeedback = data.audioFeedback;
if (data.hapticFeedback !== undefined) this.hapticFeedback = data.hapticFeedback;
}
/**
* Get summary of current settings for display
*/
getSummary() {
const settings = [];
if (this.colorblindMode !== ColorblindMode.NONE) {
settings.push(`Colorblind mode: ${this.colorblindMode}`);
}
if (this.highContrast) settings.push('High contrast');
if (this.reducedMotion) settings.push('Reduced motion');
if (this.timingPreset !== 'default') settings.push(`Timing: ${this.timingPreset}`);
if (this.largeText) settings.push('Large text');
if (this.screenReaderEnabled) settings.push('Screen reader');
return settings.length > 0 ? settings : ['Default settings'];
}
/**
* Dispose of resources
*/
dispose() {
if (this.announcer?.parentNode) {
this.announcer.parentNode.removeChild(this.announcer);
}
if (this.announcerPolite?.parentNode) {
this.announcerPolite.parentNode.removeChild(this.announcerPolite);
}
}
}
export default AccessibilityConfig;
/**
* TouchBuildController - Mobile gesture handling for building systems
*
* Handles touch input for building games on mobile devices. Supports
* tap-to-place, drag-to-move, pinch-to-zoom, and two-finger rotation.
* Designed around patterns from Fortnite Mobile and Minecraft PE.
*
* Usage:
* const touch = new TouchBuildController(canvas, {
* doubleTapToPlace: true,
* pinchToRotate: true
* });
* touch.onPlace = (position, rotation) => buildingSystem.place(position, rotation);
* touch.onRotate = (angle) => ghost.rotate(angle);
*/
/**
* Gesture types recognized by the controller
*/
export const GestureType = {
TAP: 'tap',
DOUBLE_TAP: 'doubleTap',
LONG_PRESS: 'longPress',
DRAG: 'drag',
PINCH: 'pinch',
ROTATE: 'rotate',
SWIPE: 'swipe'
};
/**
* Swipe directions
*/
export const SwipeDirection = {
UP: 'up',
DOWN: 'down',
LEFT: 'left',
RIGHT: 'right'
};
export class TouchBuildController {
/**
* Create touch build controller
* @param {HTMLElement} element - Element to attach listeners to
* @param {Object} options - Configuration options
*/
constructor(element, options = {}) {
this.element = element;
// Gesture configuration
this.tapThreshold = options.tapThreshold ?? 10; // pixels
this.longPressTime = options.longPressTime ?? 400; // ms
this.doubleTapTime = options.doubleTapTime ?? 300; // ms
this.swipeThreshold = options.swipeThreshold ?? 50; // pixels
this.swipeVelocity = options.swipeVelocity ?? 0.5; // pixels/ms
// Feature toggles
this.doubleTapToPlace = options.doubleTapToPlace ?? true;
this.longPressForOptions = options.longPressForOptions ?? true;
this.pinchToZoom = options.pinchToZoom ?? true;
this.pinchToRotate = options.pinchToRotate ?? false;
this.twoFingerRotate = options.twoFingerRotate ?? true;
this.swipeToChangePiece = options.swipeToChangePiece ?? true;
this.edgeSwipeEnabled = options.edgeSwipeEnabled ?? true;
this.edgeSize = options.edgeSize ?? 50; // pixels from edge
// State tracking
this.activeTouches = new Map();
this.gestureState = {
type: null,
startTime: 0,
startPosition: null,
lastPosition: null,
initialDistance: 0,
initialAngle: 0
};
// Timing state
this.lastTapTime = 0;
this.lastTapPosition = null;
this.longPressTimer = null;
// Build mode state
this.buildModeActive = options.buildModeActive ?? false;
this.selectedPieceType = null;
this.currentRotation = 0;
// Sensitivity settings
this.dragSensitivity = options.dragSensitivity ?? 1.0;
this.rotateSensitivity = options.rotateSensitivity ?? 1.0;
this.zoomSensitivity = options.zoomSensitivity ?? 1.0;
// Callbacks (set by user)
this.onTap = null;
this.onDoubleTap = null;
this.onLongPress = null;
this.onDragStart = null;
this.onDrag = null;
this.onDragEnd = null;
this.onPinch = null;
this.onRotate = null;
this.onSwipe = null;
this.onPlace = null;
this.onCancel = null;
this.onCyclePiece = null;
this.onToggleBuildMode = null;
// Bind event handlers
this.handleTouchStart = this.handleTouchStart.bind(this);
this.handleTouchMove = this.handleTouchMove.bind(this);
this.handleTouchEnd = this.handleTouchEnd.bind(this);
this.handleTouchCancel = this.handleTouchCancel.bind(this);
// Attach listeners
this.attach();
}
/**
* Attach touch event listeners
*/
attach() {
this.element.addEventListener('touchstart', this.handleTouchStart, { passive: false });
this.element.addEventListener('touchmove', this.handleTouchMove, { passive: false });
this.element.addEventListener('touchend', this.handleTouchEnd, { passive: false });
this.element.addEventListener('touchcancel', this.handleTouchCancel, { passive: false });
}
/**
* Detach touch event listeners
*/
detach() {
this.element.removeEventListener('touchstart', this.handleTouchStart);
this.element.removeEventListener('touchmove', this.handleTouchMove);
this.element.removeEventListener('touchend', this.handleTouchEnd);
this.element.removeEventListener('touchcancel', this.handleTouchCancel);
}
/**
* Handle touch start
*/
handleTouchStart(event) {
event.preventDefault();
// Track all touches
for (const touch of event.changedTouches) {
this.activeTouches.set(touch.identifier, {
id: touch.identifier,
startX: touch.clientX,
startY: touch.clientY,
currentX: touch.clientX,
currentY: touch.clientY,
startTime: Date.now()
});
}
const touchCount = this.activeTouches.size;
if (touchCount === 1) {
this.handleSingleTouchStart(event.changedTouches[0]);
} else if (touchCount === 2) {
this.handleTwoTouchStart();
} else if (touchCount === 3) {
this.handleThreeTouchStart();
}
}
/**
* Handle single touch start
*/
handleSingleTouchStart(touch) {
const position = { x: touch.clientX, y: touch.clientY };
this.gestureState = {
type: 'pending',
startTime: Date.now(),
startPosition: position,
lastPosition: position
};
// Start long press timer
if (this.longPressForOptions) {
this.longPressTimer = setTimeout(() => {
if (this.gestureState.type === 'pending') {
this.gestureState.type = GestureType.LONG_PRESS;
if (this.onLongPress) {
this.onLongPress(this.gestureState.startPosition);
}
}
}, this.longPressTime);
}
}
/**
* Handle two touch start (pinch/rotate)
*/
handleTwoTouchStart() {
this.clearLongPressTimer();
const touches = Array.from(this.activeTouches.values());
const t1 = touches[0];
const t2 = touches[1];
// Calculate initial distance and angle
const dx = t2.currentX - t1.currentX;
const dy = t2.currentY - t1.currentY;
this.gestureState.initialDistance = Math.hypot(dx, dy);
this.gestureState.initialAngle = Math.atan2(dy, dx);
this.gestureState.type = 'twoFinger';
}
/**
* Handle three touch start (build mode toggle)
*/
handleThreeTouchStart() {
this.clearLongPressTimer();
// Three finger tap toggles build mode
if (this.onToggleBuildMode) {
this.buildModeActive = !this.buildModeActive;
this.onToggleBuildMode(this.buildModeActive);
}
}
/**
* Handle touch move
*/
handleTouchMove(event) {
event.preventDefault();
// Update touch positions
for (const touch of event.changedTouches) {
const tracked = this.activeTouches.get(touch.identifier);
if (tracked) {
tracked.currentX = touch.clientX;
tracked.currentY = touch.clientY;
}
}
const touchCount = this.activeTouches.size;
if (touchCount === 1) {
this.handleSingleTouchMove();
} else if (touchCount === 2) {
this.handleTwoTouchMove();
}
}
/**
* Handle single touch move
*/
handleSingleTouchMove() {
const touch = Array.from(this.activeTouches.values())[0];
const position = { x: touch.currentX, y: touch.currentY };
// Calculate movement
const dx = position.x - this.gestureState.startPosition.x;
const dy = position.y - this.gestureState.startPosition.y;
const distance = Math.hypot(dx, dy);
// Check if exceeded tap threshold
if (distance > this.tapThreshold && this.gestureState.type === 'pending') {
this.clearLongPressTimer();
this.gestureState.type = GestureType.DRAG;
if (this.onDragStart) {
this.onDragStart(this.gestureState.startPosition);
}
}
// Handle ongoing drag
if (this.gestureState.type === GestureType.DRAG) {
const delta = {
x: (position.x - this.gestureState.lastPosition.x) * this.dragSensitivity,
y: (position.y - this.gestureState.lastPosition.y) * this.dragSensitivity
};
if (this.onDrag) {
this.onDrag(position, delta);
}
this.gestureState.lastPosition = position;
}
}
/**
* Handle two touch move (pinch/rotate)
*/
handleTwoTouchMove() {
const touches = Array.from(this.activeTouches.values());
const t1 = touches[0];
const t2 = touches[1];
const dx = t2.currentX - t1.currentX;
const dy = t2.currentY - t1.currentY;
const currentDistance = Math.hypot(dx, dy);
const currentAngle = Math.atan2(dy, dx);
// Calculate pinch scale
const scale = currentDistance / this.gestureState.initialDistance;
// Calculate rotation delta
let angleDelta = currentAngle - this.gestureState.initialAngle;
// Normalize angle to -PI to PI
while (angleDelta > Math.PI) angleDelta -= 2 * Math.PI;
while (angleDelta < -Math.PI) angleDelta += 2 * Math.PI;
// Determine if primarily pinching or rotating
const scaleChange = Math.abs(scale - 1);
const angleChange = Math.abs(angleDelta);
if (scaleChange > 0.1 && this.pinchToZoom) {
// Pinch gesture (zoom)
if (this.onPinch) {
this.onPinch(scale, this.getCenterPoint(t1, t2));
}
}
if (angleChange > 0.1 && this.twoFingerRotate) {
// Rotation gesture
const rotationAmount = angleDelta * this.rotateSensitivity;
this.currentRotation += rotationAmount;
if (this.onRotate) {
this.onRotate(rotationAmount, this.currentRotation);
}
// Update initial angle for next frame
this.gestureState.initialAngle = currentAngle;
}
}
/**
* Handle touch end
*/
handleTouchEnd(event) {
event.preventDefault();
for (const touch of event.changedTouches) {
const tracked = this.activeTouches.get(touch.identifier);
if (tracked && this.activeTouches.size === 1) {
this.handleSingleTouchEnd(tracked);
}
this.activeTouches.delete(touch.identifier);
}
if (this.activeTouches.size === 0) {
this.resetGestureState();
}
}
/**
* Handle single touch end
*/
handleSingleTouchEnd(touch) {
this.clearLongPressTimer();
const position = { x: touch.currentX, y: touch.currentY };
const elapsed = Date.now() - touch.startTime;
// Calculate movement
const dx = position.x - touch.startX;
const dy = position.y - touch.startY;
const distance = Math.hypot(dx, dy);
const velocity = distance / elapsed;
// Check for swipe
if (distance > this.swipeThreshold && velocity > this.swipeVelocity) {
this.handleSwipe(dx, dy, position);
return;
}
// Check for tap (minimal movement, short duration)
if (distance <= this.tapThreshold && this.gestureState.type !== GestureType.LONG_PRESS) {
this.handleTap(position);
return;
}
// End drag
if (this.gestureState.type === GestureType.DRAG && this.onDragEnd) {
this.onDragEnd(position);
}
}
/**
* Handle tap gesture
*/
handleTap(position) {
const now = Date.now();
// Check for double tap
if (this.doubleTapToPlace &&
this.lastTapTime &&
now - this.lastTapTime < this.doubleTapTime &&
this.isNearPosition(position, this.lastTapPosition)) {
// Double tap - place piece
if (this.buildModeActive && this.onPlace) {
this.onPlace(position, this.currentRotation);
} else if (this.onDoubleTap) {
this.onDoubleTap(position);
}
this.lastTapTime = 0;
this.lastTapPosition = null;
} else {
// Single tap
if (this.onTap) {
this.onTap(position);
}
this.lastTapTime = now;
this.lastTapPosition = position;
}
}
/**
* Handle swipe gesture
*/
handleSwipe(dx, dy, position) {
const direction = this.getSwipeDirection(dx, dy);
// Check for edge swipe
if (this.edgeSwipeEnabled) {
const isEdge = this.isEdgePosition(this.gestureState.startPosition);
if (isEdge) {
this.handleEdgeSwipe(isEdge, direction);
return;
}
}
// Normal swipe
if (this.swipeToChangePiece && this.buildModeActive) {
if (direction === SwipeDirection.LEFT || direction === SwipeDirection.RIGHT) {
const delta = direction === SwipeDirection.RIGHT ? 1 : -1;
if (this.onCyclePiece) {
this.onCyclePiece(delta);
}
}
}
if (this.onSwipe) {
this.onSwipe(direction, position);
}
}
/**
* Handle edge swipe (for menus, etc.)
*/
handleEdgeSwipe(edge, direction) {
// Left edge swipe right = open build menu
// Right edge swipe left = open inventory
// Bottom edge swipe up = quick actions
if (edge === 'left' && direction === SwipeDirection.RIGHT) {
if (this.onToggleBuildMode) {
this.buildModeActive = true;
this.onToggleBuildMode(true);
}
} else if (edge === 'right' && direction === SwipeDirection.LEFT) {
// Could trigger inventory or cancel
if (this.onCancel) {
this.onCancel();
}
}
}
/**
* Handle touch cancel
*/
handleTouchCancel(event) {
for (const touch of event.changedTouches) {
this.activeTouches.delete(touch.identifier);
}
this.clearLongPressTimer();
this.resetGestureState();
if (this.onCancel) {
this.onCancel();
}
}
/**
* Get swipe direction from delta
*/
getSwipeDirection(dx, dy) {
if (Math.abs(dx) > Math.abs(dy)) {
return dx > 0 ? SwipeDirection.RIGHT : SwipeDirection.LEFT;
} else {
return dy > 0 ? SwipeDirection.DOWN : SwipeDirection.UP;
}
}
/**
* Check if position is near screen edge
*/
isEdgePosition(position) {
const rect = this.element.getBoundingClientRect();
if (position.x < rect.left + this.edgeSize) return 'left';
if (position.x > rect.right - this.edgeSize) return 'right';
if (position.y < rect.top + this.edgeSize) return 'top';
if (position.y > rect.bottom - this.edgeSize) return 'bottom';
return null;
}
/**
* Check if two positions are near each other
*/
isNearPosition(pos1, pos2) {
if (!pos1 || !pos2) return false;
const dx = pos1.x - pos2.x;
const dy = pos1.y - pos2.y;
return Math.hypot(dx, dy) < this.tapThreshold * 2;
}
/**
* Get center point between two touches
*/
getCenterPoint(t1, t2) {
return {
x: (t1.currentX + t2.currentX) / 2,
y: (t1.currentY + t2.currentY) / 2
};
}
/**
* Clear long press timer
*/
clearLongPressTimer() {
if (this.longPressTimer) {
clearTimeout(this.longPressTimer);
this.longPressTimer = null;
}
}
/**
* Reset gesture state
*/
resetGestureState() {
this.gestureState = {
type: null,
startTime: 0,
startPosition: null,
lastPosition: null,
initialDistance: 0,
initialAngle: 0
};
}
/**
* Set build mode active state
*/
setBuildMode(active) {
this.buildModeActive = active;
if (this.onToggleBuildMode) {
this.onToggleBuildMode(active);
}
}
/**
* Set selected piece type
*/
setSelectedPiece(type) {
this.selectedPieceType = type;
}
/**
* Set current rotation
*/
setRotation(rotation) {
this.currentRotation = rotation;
}
/**
* Update sensitivity settings
*/
setSensitivity(type, value) {
switch (type) {
case 'drag':
this.dragSensitivity = value;
break;
case 'rotate':
this.rotateSensitivity = value;
break;
case 'zoom':
this.zoomSensitivity = value;
break;
}
}
/**
* Update timing settings (for accessibility)
*/
setTimings(options) {
if (options.longPress !== undefined) {
this.longPressTime = options.longPress;
}
if (options.doubleTap !== undefined) {
this.doubleTapTime = options.doubleTap;
}
}
/**
* Get current state for debugging/UI
*/
getState() {
return {
buildModeActive: this.buildModeActive,
selectedPieceType: this.selectedPieceType,
currentRotation: this.currentRotation,
activeTouchCount: this.activeTouches.size,
currentGesture: this.gestureState.type
};
}
/**
* Dispose of controller
*/
dispose() {
this.detach();
this.clearLongPressTimer();
this.activeTouches.clear();
}
}
export default TouchBuildController;
/**
* VRBuildingAdapter - VR input handling for building systems
*
* Adapts WebXR controller and hand tracking input for building games.
* Handles grab-and-place mechanics, laser pointer selection, and
* provides comfort features like snap rotation and distance clamping.
*
* Usage:
* const vr = new VRBuildingAdapter(xrSession, renderer, {
* dominantHand: 'right',
* comfortMode: true
* });
* vr.onGrab = (piece) => selection.select(piece);
* vr.onPlace = (position, rotation) => buildingSystem.place(position, rotation);
*/
import * as THREE from 'three';
/**
* Input modes for VR building
*/
export const VRInputMode = {
CONTROLLER: 'controller',
HAND_TRACKING: 'handTracking',
GAZE: 'gaze'
};
/**
* Snap rotation presets
*/
export const SnapRotation = {
NONE: 0,
COARSE: Math.PI / 2, // 90°
MEDIUM: Math.PI / 4, // 45°
FINE: Math.PI / 6 // 30°
};
/**
* Haptic feedback patterns
*/
const HapticPatterns = {
select: { duration: 40, intensity: 0.3 },
grab: { duration: 50, intensity: 0.4 },
release: { duration: 30, intensity: 0.2 },
valid: { duration: 50, intensity: 0.3 },
invalid: { duration: 30, intensity: 0.5, repeat: 2, gap: 40 },
confirm: { duration: 100, intensity: 0.6 },
snap: { duration: 20, intensity: 0.2 }
};
export class VRBuildingAdapter {
/**
* Create VR building adapter
* @param {XRSession} xrSession - WebXR session
* @param {THREE.WebGLRenderer} renderer - Three.js renderer
* @param {Object} options - Configuration options
*/
constructor(xrSession, renderer, options = {}) {
this.session = xrSession;
this.renderer = renderer;
// Hand configuration
this.dominantHand = options.dominantHand ?? 'right';
this.nonDominantHand = this.dominantHand === 'right' ? 'left' : 'right';
// Input mode
this.inputMode = options.inputMode ?? VRInputMode.CONTROLLER;
// Comfort settings
this.comfortMode = options.comfortMode ?? true;
this.snapRotation = options.snapRotation ?? SnapRotation.MEDIUM;
this.minBuildDistance = options.minBuildDistance ?? 0.5; // meters
this.maxBuildDistance = options.maxBuildDistance ?? 10; // meters
this.teleportWhileBuilding = options.teleportWhileBuilding ?? true;
// Grid snapping
this.snapToGrid = options.snapToGrid ?? true;
this.gridSize = options.gridSize ?? 0.5; // meters
// Haptic feedback
this.hapticsEnabled = options.hapticsEnabled ?? true;
// State
this.controllers = new Map();
this.hands = new Map();
this.activeController = null;
this.grabbedPiece = null;
this.grabOffset = new THREE.Vector3();
this.currentRotation = 0;
this.isPlacementValid = true;
// Laser pointer
this.laserEnabled = options.laserEnabled ?? true;
this.laserLength = options.laserLength ?? 10;
this.laserLine = null;
this.laserHitPoint = new THREE.Vector3();
// Reference space
this.referenceSpace = null;
// Raycaster for selection
this.raycaster = new THREE.Raycaster();
this.tempMatrix = new THREE.Matrix4();
// Callbacks (set by user)
this.onSelect = null;
this.onGrab = null;
this.onRelease = null;
this.onPlace = null;
this.onRotate = null;
this.onMove = null;
this.onValidityChange = null;
this.onTeleport = null;
// Initialize
this.initialize();
}
/**
* Initialize VR input handling
*/
async initialize() {
// Get reference space
this.referenceSpace = await this.session.requestReferenceSpace('local-floor');
// Setup controller tracking
this.session.addEventListener('inputsourceschange', (event) => {
this.handleInputSourcesChange(event);
});
// Process existing input sources
for (const source of this.session.inputSources) {
this.addInputSource(source);
}
// Create laser pointer visualization
if (this.laserEnabled) {
this.createLaserPointer();
}
}
/**
* Handle input sources change
*/
handleInputSourcesChange(event) {
for (const source of event.added) {
this.addInputSource(source);
}
for (const source of event.removed) {
this.removeInputSource(source);
}
}
/**
* Add input source
*/
addInputSource(source) {
if (source.targetRayMode === 'tracked-pointer') {
// Controller
const hand = source.handedness;
this.controllers.set(hand, {
source,
grip: null,
targetRay: null,
pressing: {
trigger: false,
grip: false,
thumbstick: false
}
});
} else if (source.hand) {
// Hand tracking
this.hands.set(source.handedness, {
source,
hand: source.hand,
pinching: false
});
if (this.inputMode === VRInputMode.CONTROLLER) {
this.inputMode = VRInputMode.HAND_TRACKING;
}
}
}
/**
* Remove input source
*/
removeInputSource(source) {
if (source.targetRayMode === 'tracked-pointer') {
this.controllers.delete(source.handedness);
} else if (source.hand) {
this.hands.delete(source.handedness);
}
}
/**
* Update - call each frame
* @param {XRFrame} frame - Current XR frame
* @param {Array} selectablePieces - Pieces that can be selected
*/
update(frame, selectablePieces = []) {
if (!frame) return;
if (this.inputMode === VRInputMode.HAND_TRACKING) {
this.updateHandTracking(frame);
} else {
this.updateControllers(frame, selectablePieces);
}
// Update grabbed piece position
if (this.grabbedPiece) {
this.updateGrabbedPiece(frame);
}
}
/**
* Update controller input
*/
updateControllers(frame, selectablePieces) {
for (const [hand, controller] of this.controllers) {
const source = controller.source;
const gamepad = source.gamepad;
if (!gamepad) continue;
// Get pose
const targetRayPose = frame.getPose(source.targetRaySpace, this.referenceSpace);
const gripPose = source.gripSpace ?
frame.getPose(source.gripSpace, this.referenceSpace) : null;
if (targetRayPose) {
controller.targetRay = targetRayPose;
}
if (gripPose) {
controller.grip = gripPose;
}
// Process buttons
const trigger = gamepad.buttons[0]; // Select/trigger
const grip = gamepad.buttons[1]; // Grip/squeeze
const thumbstick = gamepad.buttons[3]; // Thumbstick press
const axes = gamepad.axes;
// Trigger for selection/placement
if (trigger && trigger.pressed && !controller.pressing.trigger) {
this.handleTriggerDown(hand, controller, selectablePieces);
} else if (trigger && !trigger.pressed && controller.pressing.trigger) {
this.handleTriggerUp(hand, controller);
}
controller.pressing.trigger = trigger?.pressed ?? false;
// Grip for grabbing
if (grip && grip.pressed && !controller.pressing.grip) {
this.handleGripDown(hand, controller, selectablePieces);
} else if (grip && !grip.pressed && controller.pressing.grip) {
this.handleGripUp(hand, controller);
}
controller.pressing.grip = grip?.pressed ?? false;
// Thumbstick for rotation
if (axes && axes.length >= 4 && hand === this.dominantHand) {
this.handleThumbstick(axes[2], axes[3], controller);
}
// Update laser pointer
if (this.laserEnabled && hand === this.dominantHand) {
this.updateLaserPointer(controller, selectablePieces);
}
}
}
/**
* Update hand tracking input
*/
updateHandTracking(frame) {
for (const [handedness, handData] of this.hands) {
const hand = handData.hand;
// Get pinch state (index tip to thumb tip distance)
const indexTip = hand.get('index-finger-tip');
const thumbTip = hand.get('thumb-tip');
if (!indexTip || !thumbTip) continue;
const indexPose = frame.getJointPose(indexTip, this.referenceSpace);
const thumbPose = frame.getJointPose(thumbTip, this.referenceSpace);
if (!indexPose || !thumbPose) continue;
// Calculate pinch distance
const indexPos = indexPose.transform.position;
const thumbPos = thumbPose.transform.position;
const distance = Math.sqrt(
Math.pow(indexPos.x - thumbPos.x, 2) +
Math.pow(indexPos.y - thumbPos.y, 2) +
Math.pow(indexPos.z - thumbPos.z, 2)
);
const isPinching = distance < 0.02; // 2cm threshold
// Handle pinch state changes
if (isPinching && !handData.pinching) {
this.handlePinchStart(handedness, indexPose.transform.position);
} else if (!isPinching && handData.pinching) {
this.handlePinchEnd(handedness);
}
handData.pinching = isPinching;
// Update position while pinching
if (isPinching && this.grabbedPiece) {
const midpoint = {
x: (indexPos.x + thumbPos.x) / 2,
y: (indexPos.y + thumbPos.y) / 2,
z: (indexPos.z + thumbPos.z) / 2
};
this.updateGrabbedPosition(midpoint);
}
}
}
/**
* Handle trigger down (selection/placement)
*/
handleTriggerDown(hand, controller, selectablePieces) {
if (hand !== this.dominantHand) return;
this.triggerHaptic(controller.source, 'select');
if (this.grabbedPiece) {
// Place the grabbed piece
if (this.isPlacementValid && this.onPlace) {
const position = this.grabbedPiece.position.clone();
this.onPlace(position, this.currentRotation);
this.triggerHaptic(controller.source, 'confirm');
} else {
this.triggerHaptic(controller.source, 'invalid');
}
} else {
// Select piece at laser pointer
const hit = this.raycastSelectables(controller, selectablePieces);
if (hit && this.onSelect) {
this.onSelect(hit.piece, hit.point);
}
}
}
/**
* Handle trigger up
*/
handleTriggerUp(hand, controller) {
// Trigger release logic if needed
}
/**
* Handle grip down (grab)
*/
handleGripDown(hand, controller, selectablePieces) {
if (hand !== this.dominantHand) return;
const hit = this.raycastSelectables(controller, selectablePieces);
if (hit) {
this.grabbedPiece = hit.piece;
// Calculate grab offset
const gripPos = this.getGripPosition(controller);
if (gripPos) {
this.grabOffset.subVectors(hit.piece.position, gripPos);
}
this.triggerHaptic(controller.source, 'grab');
if (this.onGrab) {
this.onGrab(hit.piece);
}
}
}
/**
* Handle grip up (release)
*/
handleGripUp(hand, controller) {
if (hand !== this.dominantHand) return;
if (this.grabbedPiece) {
this.triggerHaptic(controller.source, 'release');
if (this.onRelease) {
this.onRelease(this.grabbedPiece.position.clone());
}
this.grabbedPiece = null;
}
}
/**
* Handle thumbstick input for rotation
*/
handleThumbstick(x, y, controller) {
// Only process significant input
if (Math.abs(x) < 0.5) return;
// Debounce
if (this.lastThumbstickTime && Date.now() - this.lastThumbstickTime < 200) {
return;
}
const direction = x > 0 ? 1 : -1;
if (this.snapRotation > 0) {
// Snap rotation
this.currentRotation += direction * this.snapRotation;
this.triggerHaptic(controller.source, 'snap');
} else {
// Smooth rotation
this.currentRotation += direction * 0.1;
}
// Normalize rotation
while (this.currentRotation > Math.PI) this.currentRotation -= 2 * Math.PI;
while (this.currentRotation < -Math.PI) this.currentRotation += 2 * Math.PI;
if (this.onRotate) {
this.onRotate(this.currentRotation);
}
this.lastThumbstickTime = Date.now();
}
/**
* Handle pinch start (hand tracking)
*/
handlePinchStart(hand, position) {
// Similar to grip down
if (hand !== this.dominantHand) return;
// For hand tracking, we'd need to do spatial query at pinch position
// Simplified: just set position for placement preview
if (this.onGrab) {
this.onGrab(null); // Signal grab intent
}
}
/**
* Handle pinch end (hand tracking)
*/
handlePinchEnd(hand) {
if (hand !== this.dominantHand) return;
if (this.onRelease) {
this.onRelease(this.laserHitPoint.clone());
}
}
/**
* Update grabbed piece position
*/
updateGrabbedPiece(frame) {
const controller = this.controllers.get(this.dominantHand);
if (!controller) return;
const gripPos = this.getGripPosition(controller);
if (!gripPos) return;
// Calculate new position with offset
const newPosition = new THREE.Vector3(
gripPos.x + this.grabOffset.x,
gripPos.y + this.grabOffset.y,
gripPos.z + this.grabOffset.z
);
// Clamp distance from player
const cameraPosition = this.renderer.xr.getCamera().position;
const direction = newPosition.clone().sub(cameraPosition);
const distance = direction.length();
if (distance < this.minBuildDistance) {
direction.normalize().multiplyScalar(this.minBuildDistance);
newPosition.copy(cameraPosition).add(direction);
} else if (distance > this.maxBuildDistance) {
direction.normalize().multiplyScalar(this.maxBuildDistance);
newPosition.copy(cameraPosition).add(direction);
}
// Snap to grid
if (this.snapToGrid) {
newPosition.x = Math.round(newPosition.x / this.gridSize) * this.gridSize;
newPosition.y = Math.round(newPosition.y / this.gridSize) * this.gridSize;
newPosition.z = Math.round(newPosition.z / this.gridSize) * this.gridSize;
}
// Update piece position
this.grabbedPiece.position.copy(newPosition);
this.grabbedPiece.rotation.y = this.currentRotation;
if (this.onMove) {
this.onMove(newPosition, this.currentRotation);
}
}
/**
* Update position from hand tracking
*/
updateGrabbedPosition(position) {
if (!this.grabbedPiece) return;
const newPosition = new THREE.Vector3(position.x, position.y, position.z);
// Snap to grid
if (this.snapToGrid) {
newPosition.x = Math.round(newPosition.x / this.gridSize) * this.gridSize;
newPosition.y = Math.round(newPosition.y / this.gridSize) * this.gridSize;
newPosition.z = Math.round(newPosition.z / this.gridSize) * this.gridSize;
}
this.grabbedPiece.position.copy(newPosition);
}
/**
* Raycast against selectable pieces
*/
raycastSelectables(controller, selectables) {
if (!controller.targetRay) return null;
const pose = controller.targetRay;
const position = pose.transform.position;
const orientation = pose.transform.orientation;
// Set up raycaster
this.tempMatrix.compose(
new THREE.Vector3(position.x, position.y, position.z),
new THREE.Quaternion(orientation.x, orientation.y, orientation.z, orientation.w),
new THREE.Vector3(1, 1, 1)
);
this.raycaster.ray.origin.setFromMatrixPosition(this.tempMatrix);
this.raycaster.ray.direction.set(0, 0, -1).applyMatrix4(this.tempMatrix).normalize();
// Get meshes from selectables
const meshes = selectables
.filter(p => p.mesh)
.map(p => p.mesh);
const intersects = this.raycaster.intersectObjects(meshes, true);
if (intersects.length > 0) {
const hit = intersects[0];
const piece = selectables.find(p =>
p.mesh === hit.object || p.mesh.children?.includes(hit.object)
);
return piece ? { piece, point: hit.point } : null;
}
return null;
}
/**
* Get grip position from controller
*/
getGripPosition(controller) {
if (!controller.grip) return null;
const pos = controller.grip.transform.position;
return new THREE.Vector3(pos.x, pos.y, pos.z);
}
/**
* Create laser pointer visualization
*/
createLaserPointer() {
const geometry = new THREE.BufferGeometry().setFromPoints([
new THREE.Vector3(0, 0, 0),
new THREE.Vector3(0, 0, -this.laserLength)
]);
const material = new THREE.LineBasicMaterial({
color: 0x00aaff,
transparent: true,
opacity: 0.5
});
this.laserLine = new THREE.Line(geometry, material);
this.laserLine.visible = false;
}
/**
* Update laser pointer visualization
*/
updateLaserPointer(controller, selectables) {
if (!this.laserLine || !controller.targetRay) return;
const pose = controller.targetRay;
const position = pose.transform.position;
const orientation = pose.transform.orientation;
// Update laser position/rotation
this.laserLine.position.set(position.x, position.y, position.z);
this.laserLine.quaternion.set(orientation.x, orientation.y, orientation.z, orientation.w);
this.laserLine.visible = true;
// Raycast to find hit point
const hit = this.raycastSelectables(controller, selectables);
if (hit) {
this.laserHitPoint.copy(hit.point);
// Shorten laser to hit point
const distance = this.laserLine.position.distanceTo(hit.point);
this.updateLaserLength(distance);
// Change color on hover
this.laserLine.material.color.setHex(0x00ff00);
} else {
this.updateLaserLength(this.laserLength);
this.laserLine.material.color.setHex(0x00aaff);
}
}
/**
* Update laser length
*/
updateLaserLength(length) {
const positions = this.laserLine.geometry.attributes.position.array;
positions[5] = -length;
this.laserLine.geometry.attributes.position.needsUpdate = true;
}
/**
* Trigger haptic feedback
*/
triggerHaptic(source, patternName) {
if (!this.hapticsEnabled) return;
const gamepad = source.gamepad;
if (!gamepad || !gamepad.hapticActuators || gamepad.hapticActuators.length === 0) {
return;
}
const actuator = gamepad.hapticActuators[0];
const pattern = HapticPatterns[patternName] || HapticPatterns.select;
if (pattern.repeat) {
for (let i = 0; i < pattern.repeat; i++) {
setTimeout(() => {
actuator.pulse(pattern.intensity, pattern.duration);
}, i * (pattern.duration + pattern.gap));
}
} else {
actuator.pulse(pattern.intensity, pattern.duration);
}
}
/**
* Set placement validity (for haptic feedback)
*/
setPlacementValid(valid) {
if (valid !== this.isPlacementValid) {
this.isPlacementValid = valid;
const controller = this.controllers.get(this.dominantHand);
if (controller) {
this.triggerHaptic(controller.source, valid ? 'valid' : 'invalid');
}
if (this.onValidityChange) {
this.onValidityChange(valid);
}
}
}
/**
* Get laser line for adding to scene
*/
getLaserLine() {
return this.laserLine;
}
/**
* Set comfort mode
*/
setComfortMode(enabled) {
this.comfortMode = enabled;
this.snapRotation = enabled ? SnapRotation.MEDIUM : SnapRotation.NONE;
}
/**
* Set snap rotation
*/
setSnapRotation(snap) {
this.snapRotation = snap;
}
/**
* Set grid snapping
*/
setGridSnap(enabled, size = 0.5) {
this.snapToGrid = enabled;
this.gridSize = size;
}
/**
* Get current state
*/
getState() {
return {
inputMode: this.inputMode,
dominantHand: this.dominantHand,
isGrabbing: this.grabbedPiece !== null,
currentRotation: this.currentRotation,
isPlacementValid: this.isPlacementValid,
comfortMode: this.comfortMode,
snapRotation: this.snapRotation
};
}
/**
* Dispose of adapter
*/
dispose() {
if (this.laserLine) {
this.laserLine.geometry.dispose();
this.laserLine.material.dispose();
}
this.controllers.clear();
this.hands.clear();
}
}
export default VRBuildingAdapter;
Related skills
FAQ
What platforms does platform-building cover?
Mobile touch, VR spatial input, and accessibility including colorblind and screen-reader support.
How does it support colorblind users?
Its accessibility config offers colorblind modes like deuteranopia and uses blue/yellow instead of green/red for indicators.