diff --git a/frontend/src/App.vue b/frontend/src/App.vue
index 2f72af5..2af8ecd 100644
--- a/frontend/src/App.vue
+++ b/frontend/src/App.vue
@@ -20,7 +20,7 @@ onMounted(() => {
diff --git a/frontend/src/components/NavBar.vue b/frontend/src/components/NavBar.vue
index 6d3cd6b..e40ea69 100644
--- a/frontend/src/components/NavBar.vue
+++ b/frontend/src/components/NavBar.vue
@@ -1,10 +1,11 @@
@@ -91,4 +99,22 @@ const links = [
+
+
+
diff --git a/frontend/src/game/FightScene.ts b/frontend/src/game/FightScene.ts
index 320c5fe..6d02aa9 100644
--- a/frontend/src/game/FightScene.ts
+++ b/frontend/src/game/FightScene.ts
@@ -8,7 +8,7 @@ import { ARENA_THEMES, spriteAnims, CHALLENGE_THEMED, TIER_ULTIMATES, CREATOR_MO
import {
CREATOR_ULTIMATE_CHANCE, CREATOR_THEMED_MOVE_RATIO,
CRIT_THEMED_RATIO, CHOREOGRAPHY_THEMED_RATIO, CHOREOGRAPHY_GENERIC_CUTOFF,
- TIER_ULTIMATE_CHANCES, isPerfMode, perfParticles,
+ TIER_ULTIMATE_CHANCES, isPerfMode, perfParticles, isReducedMotion, haptic,
} from './fight/config'
import { spawnSparks as _spawnSparks, spawnBulletHoles as _spawnBulletHoles, spawnExhaust as _spawnExhaust } from './fight/particles'
import { glitchRGB as _glitchRGB, scanlineGlitch as _scanlineGlitch, vhsTracking as _vhsTracking, dimensionalShift as _dimensionalShift } from './fight/effects'
@@ -168,6 +168,13 @@ export async function createFightScene(config: FightSceneConfig) {
texFilter: 'nearest',
})
+ // Wrap k.shake to support reduced motion + haptic feedback
+ const _origShake = k.shake.bind(k)
+ k.shake = ((intensity: number) => {
+ if (intensity >= 3) haptic(Math.min(100, intensity * 8))
+ if (!isReducedMotion()) _origShake(intensity)
+ }) as typeof k.shake
+
const colorsA = getBotColors(botA.seed)
const colorsB = getBotColors(botB.seed)
diff --git a/frontend/src/game/fight/config.ts b/frontend/src/game/fight/config.ts
index 6bb8d04..2e9d539 100644
--- a/frontend/src/game/fight/config.ts
+++ b/frontend/src/game/fight/config.ts
@@ -29,11 +29,24 @@ export function setPerfMode(on: boolean): void {
try { localStorage.setItem(PERF_KEY, on ? '1' : '0') } catch {}
}
-/** Scale a particle count down in performance mode (40%) */
+/** Scale a particle count down in performance mode (40%) or reduced motion (20%) */
export function perfParticles(count: number): number {
+ if (_reducedMotion) return Math.max(1, Math.round(count * 0.2))
return _perfMode ? Math.max(1, Math.round(count * 0.4)) : count
}
+// --- Reduced motion ---
+const _reducedMotion = typeof window !== 'undefined'
+ ? window.matchMedia('(prefers-reduced-motion: reduce)').matches
+ : false
+
+export function isReducedMotion(): boolean { return _reducedMotion }
+
+// --- Haptic feedback ---
+export function haptic(ms: number = 50): void {
+ try { navigator?.vibrate?.(ms) } catch { /* no vibration support */ }
+}
+
// --- Choreography selection probabilities ---
export const CREATOR_ULTIMATE_CHANCE = 0.5 // Creator ultimate chance (non-crit); 1.0 on crits
export const CREATOR_THEMED_MOVE_RATIO = 0.6 // Creator uses creator-themed moves 60% of the time
diff --git a/frontend/src/style.css b/frontend/src/style.css
index 57a17fe..e8b14ca 100644
--- a/frontend/src/style.css
+++ b/frontend/src/style.css
@@ -186,3 +186,14 @@
.tier-4 { color: var(--color-neon-cyan); text-shadow: 0 0 8px rgba(0, 240, 255, 0.3); }
.tier-5 { color: var(--color-neon-purple); text-shadow: 0 0 10px rgba(184, 61, 255, 0.5); }
.tier-6 { color: var(--color-neon-pink); text-shadow: 0 0 12px rgba(255, 45, 123, 0.6); }
+
+/* Safe area for bottom nav (iPhone notch/home indicator) */
+.safe-area-bottom { padding-bottom: env(safe-area-inset-bottom, 0px); }
+
+/* Reduced motion: skip animations */
+@media (prefers-reduced-motion: reduce) {
+ *, *::before, *::after {
+ animation-duration: 0.01ms !important;
+ transition-duration: 0.01ms !important;
+ }
+}