├── .gitignore ├── App.tsx ├── LICENSE ├── README.md ├── app.json ├── assets ├── adaptive-icon.png ├── bubble.png ├── favicon.png ├── icon.png └── splash.png ├── babel.config.js ├── package.json ├── src ├── assets │ ├── background.png │ ├── bubble.png │ ├── tree1.png │ ├── tree3.png │ └── tree4.png ├── components │ ├── BubblesContainer.tsx │ ├── GradientButton.tsx │ ├── Logo.tsx │ └── index.ts ├── config.ts ├── drawing │ ├── Bubble.tsx │ ├── ParallaxImage.tsx │ ├── Wall.tsx │ └── index.ts ├── hooks │ ├── index.ts │ └── useGravity.ts ├── navigation.ts ├── screens │ ├── PhysicsScreen.tsx │ ├── SensorsScreen.tsx │ └── index.ts └── utils.ts ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | .expo/ 3 | dist/ 4 | npm-debug.* 5 | *.jks 6 | *.p8 7 | *.p12 8 | *.key 9 | *.mobileprovision 10 | *.orig.* 11 | web-build/ 12 | 13 | # macOS 14 | .DS_Store 15 | 16 | # Temporary files created by Metro to check the health of the file watcher 17 | .metro-health-check* 18 | -------------------------------------------------------------------------------- /App.tsx: -------------------------------------------------------------------------------- 1 | import { NavigationContainer } from "@react-navigation/native"; 2 | import { RootStack } from "./src/navigation"; 3 | import { PhysicsScreen, SensorsScreen } from "./src/screens"; 4 | 5 | export default function App() { 6 | return ( 7 | 8 | 14 | 15 | 16 | 17 | 18 | ); 19 | } 20 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Aymeric Chauvin 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-native-gyroscopic-physics 2 | 3 | This repository showcases the possibilities of mixing a physics engine, Skia, sensors, and UI elements, to create a fully interactive experience in a React Native application. 4 | 5 | https://github.com/halftheopposite/react-native-gyroscopic-physics/assets/5473864/34b64175-655c-4084-9ab3-81acfbd7c286 6 | 7 | ## Architecture 8 | 9 | The architecture of the app is layered as a classic React Native application. 10 | 11 | TODO 12 | 13 | ## Libraries 14 | 15 | I've tried to use as few libraries as possible and avoid Reanimated for compatibility purposes: 16 | 17 | - Skia: https://github.com/Shopify/react-native-skia 18 | - Matter.js: https://github.com/liabru/matter-js 19 | - Expo-Sensors: https://github.com/expo/expo/tree/sdk-48/packages/expo-sensors 20 | -------------------------------------------------------------------------------- /app.json: -------------------------------------------------------------------------------- 1 | { 2 | "expo": { 3 | "name": "react-native-gyroscopic-physics", 4 | "slug": "react-native-gyroscopic-physics", 5 | "version": "1.0.0", 6 | "orientation": "portrait", 7 | "icon": "./assets/icon.png", 8 | "userInterfaceStyle": "light", 9 | "splash": { 10 | "image": "./assets/splash.png", 11 | "resizeMode": "contain", 12 | "backgroundColor": "#ffffff" 13 | }, 14 | "assetBundlePatterns": ["**/*"], 15 | "ios": { 16 | "supportsTablet": true 17 | }, 18 | "android": { 19 | "adaptiveIcon": { 20 | "foregroundImage": "./assets/adaptive-icon.png", 21 | "backgroundColor": "#ffffff" 22 | } 23 | }, 24 | "web": { 25 | "favicon": "./assets/favicon.png" 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /assets/adaptive-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/adaptive-icon.png -------------------------------------------------------------------------------- /assets/bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/bubble.png -------------------------------------------------------------------------------- /assets/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/favicon.png -------------------------------------------------------------------------------- /assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/icon.png -------------------------------------------------------------------------------- /assets/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/assets/splash.png -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = function (api) { 2 | api.cache(true); 3 | return { 4 | presets: ["babel-preset-expo"], 5 | }; 6 | }; 7 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-native-gyroscopic-physics", 3 | "version": "1.0.0", 4 | "main": "node_modules/expo/AppEntry.js", 5 | "scripts": { 6 | "start": "expo start", 7 | "android": "expo start --android", 8 | "ios": "expo start --ios", 9 | "web": "expo start --web" 10 | }, 11 | "dependencies": { 12 | "@react-navigation/native": "^6.1.7", 13 | "@react-navigation/native-stack": "^6.9.13", 14 | "@shopify/react-native-skia": "0.1.172", 15 | "expo": "~48.0.18", 16 | "expo-sensors": "^12.1.1", 17 | "expo-status-bar": "~1.4.4", 18 | "matter-js": "^0.19.0", 19 | "react": "18.2.0", 20 | "react-native": "0.71.8", 21 | "react-native-safe-area-context": "4.5.0", 22 | "react-native-screens": "~3.20.0" 23 | }, 24 | "devDependencies": { 25 | "@babel/core": "^7.20.0", 26 | "@types/matter-js": "^0.18.5", 27 | "@types/react": "~18.0.27", 28 | "typescript": "^4.9.4" 29 | }, 30 | "private": true 31 | } 32 | -------------------------------------------------------------------------------- /src/assets/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/background.png -------------------------------------------------------------------------------- /src/assets/bubble.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/bubble.png -------------------------------------------------------------------------------- /src/assets/tree1.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/tree1.png -------------------------------------------------------------------------------- /src/assets/tree3.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/tree3.png -------------------------------------------------------------------------------- /src/assets/tree4.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/halftheopposite/react-native-gyroscopic-physics/fe6a30cb911c011fa9aeacde675beb0ca71429fb/src/assets/tree4.png -------------------------------------------------------------------------------- /src/components/BubblesContainer.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | Fill, 4 | SweepGradient, 5 | useImage, 6 | useTouchHandler, 7 | vec, 8 | } from "@shopify/react-native-skia"; 9 | import { Bodies, Body, Composite, Engine, Query } from "matter-js"; 10 | import React, { useEffect, useRef, useState } from "react"; 11 | import { StyleSheet, View } from "react-native"; 12 | import { 13 | BUBBLES_COUNT, 14 | BUBBLE_RADIUS, 15 | BUBBLE_SPAWN_X, 16 | BUBBLE_SPAWN_Y, 17 | PHYSICS_TIMESTEP, 18 | SCREEN_HEIGHT, 19 | SCREEN_WIDTH, 20 | TOP_WALL_OFFSET, 21 | UI_TIMESTEP, 22 | WALL_WIDTH, 23 | } from "../config"; 24 | import { Bubble, Wall } from "../drawing"; 25 | import { useGravity } from "../hooks"; 26 | import { roundDigitsLength } from "../utils"; 27 | 28 | const BUBBLE_IMAGE_PATH = require("../assets/bubble.png"); 29 | 30 | export type Placeholder = { 31 | id: number; 32 | x: number; 33 | y: number; 34 | width: number; 35 | height: number; 36 | }; 37 | 38 | export function BubblesContainer(props: { 39 | placeholders?: Placeholder[]; 40 | children: React.ReactNode; 41 | }) { 42 | const { placeholders, children } = props; 43 | const image = useImage(BUBBLE_IMAGE_PATH); 44 | 45 | const [wallBodies, setWallBodies] = useState([]); 46 | const [bubbleBodies, setBubbleBodies] = useState([]); 47 | const [placeholderBodies, setPlaceholdersBodies] = useState([]); 48 | 49 | const [, setTick] = useState([]); 50 | 51 | // 52 | // Initialize engine and update loop 53 | // 54 | const engine = useRef( 55 | Engine.create({ 56 | positionIterations: 6, 57 | gravity: { 58 | scale: 0.002, 59 | }, 60 | }) 61 | ).current; 62 | 63 | useEffect(() => { 64 | // Create walls 65 | const wallBodies = createWalls(); 66 | setWallBodies(wallBodies); 67 | 68 | // Create bubbles 69 | const bubbleBodies = createBubbles(); 70 | setBubbleBodies(bubbleBodies); 71 | 72 | // Add all bodies into the world 73 | Composite.add(engine.world, [...wallBodies, ...bubbleBodies]); 74 | 75 | // Launch physics update loop 76 | const update = () => { 77 | Engine.update(engine, PHYSICS_TIMESTEP); 78 | requestAnimationFrame(update); 79 | }; 80 | 81 | update(); 82 | 83 | // Launch UI refresh loop 84 | setInterval(() => { 85 | setTick([]); 86 | }, UI_TIMESTEP); 87 | }, []); 88 | 89 | // 90 | // Listen to placeholder changes 91 | // 92 | useEffect(() => { 93 | if (!placeholders || placeholders.length === 0) { 94 | return; 95 | } 96 | 97 | const bodies: Body[] = []; 98 | 99 | placeholders.forEach((placeholder) => { 100 | const existingBody = Composite.allBodies(engine.world).find( 101 | (body) => body.id === placeholder.id 102 | ); 103 | 104 | // If body already exist we do not add it to the list 105 | if (!!existingBody) { 106 | return; 107 | } 108 | 109 | bodies.push( 110 | createWall( 111 | placeholder.x, 112 | placeholder.y, 113 | placeholder.width, 114 | placeholder.height, 115 | placeholder.id 116 | ) 117 | ); 118 | }); 119 | 120 | setPlaceholdersBodies(bodies); 121 | Composite.add(engine.world, bodies); 122 | }, [placeholders]); 123 | 124 | // 125 | // Listen to mobile orientation changes and update 126 | // the artifical gravity of the simulation 127 | // 128 | useGravity((gravity) => { 129 | engine.gravity.x = gravity.x; 130 | engine.gravity.y = gravity.y; 131 | }); 132 | 133 | // 134 | // Handle touch events to move bubbles arounds 135 | // 136 | const [draggingBody, setDraggingBody] = useState(null); 137 | 138 | const touchHandler = useTouchHandler( 139 | { 140 | onStart: ({ x, y }) => { 141 | if (draggingBody) { 142 | return; 143 | } 144 | 145 | const bodies = Query.point(Composite.allBodies(engine.world), { 146 | x, 147 | y, 148 | }); 149 | 150 | if (!bodies || bodies.length === 0) { 151 | return; 152 | } 153 | 154 | const firstBody = bodies[0]; 155 | if (firstBody.isStatic) { 156 | return; 157 | } 158 | 159 | Body.setStatic(firstBody, true); 160 | 161 | setDraggingBody(firstBody); 162 | }, 163 | onActive: (info) => { 164 | if (!draggingBody) { 165 | return; 166 | } 167 | 168 | Body.setPosition(draggingBody, { 169 | x: info.x, 170 | y: info.y, 171 | }); 172 | }, 173 | onEnd: (info) => { 174 | if (!draggingBody) { 175 | return; 176 | } 177 | 178 | Body.setStatic(draggingBody, false); 179 | 180 | // Apply the current force to the bubble to keep momentum 181 | Body.applyForce( 182 | draggingBody, 183 | { x: info.x, y: info.y }, 184 | { x: info.velocityX / 10000, y: info.velocityY / 10000 } 185 | ); 186 | 187 | setDraggingBody(null); 188 | }, 189 | }, 190 | [draggingBody] 191 | ); 192 | 193 | if (!image) { 194 | return null; 195 | } 196 | 197 | return ( 198 | 199 | {/* Canvas */} 200 | 201 | 202 | 208 | 209 | 210 | {/* Walls */} 211 | {wallBodies.map((body) => ( 212 | 219 | ))} 220 | 221 | {/* Bubbles */} 222 | {bubbleBodies.map((body) => ( 223 | 231 | ))} 232 | 233 | {/* Placeholders */} 234 | {placeholderBodies.map((body) => ( 235 | 242 | ))} 243 | 244 | 245 | {/* UI overlay */} 246 | 247 | {children} 248 | 249 | 250 | ); 251 | } 252 | 253 | const styles = StyleSheet.create({ 254 | container: { 255 | flex: 1, 256 | }, 257 | canvas: { 258 | flex: 1, 259 | }, 260 | contentContainer: { 261 | ...StyleSheet.absoluteFillObject, 262 | }, 263 | }); 264 | 265 | // 266 | // Utils 267 | // 268 | function createWalls(): Body[] { 269 | const totalWidth = SCREEN_WIDTH; 270 | const totalHeight = SCREEN_HEIGHT + TOP_WALL_OFFSET; 271 | 272 | const topWall = createWall(0, -TOP_WALL_OFFSET, totalWidth, WALL_WIDTH); 273 | const leftWall = createWall( 274 | -WALL_WIDTH, 275 | -TOP_WALL_OFFSET, 276 | WALL_WIDTH, 277 | totalHeight 278 | ); 279 | const rightWall = createWall( 280 | totalWidth, 281 | -TOP_WALL_OFFSET, 282 | WALL_WIDTH, 283 | totalHeight 284 | ); 285 | const bottomWall = createWall(0, SCREEN_HEIGHT, totalWidth, WALL_WIDTH); 286 | 287 | return [topWall, leftWall, rightWall, bottomWall]; 288 | } 289 | 290 | function createWall( 291 | x: number, 292 | y: number, 293 | width: number, 294 | height: number, 295 | id?: number 296 | ): Body { 297 | return Bodies.rectangle(x + width / 2, y + height / 2, width, height, { 298 | isStatic: true, 299 | ...(!!id ? { id } : {}), 300 | }); 301 | } 302 | 303 | function createBubbles(): Body[] { 304 | let bodies: Body[] = []; 305 | 306 | for (let i = 0; i < BUBBLES_COUNT; i++) { 307 | bodies.push( 308 | Bodies.circle(BUBBLE_SPAWN_X, BUBBLE_SPAWN_Y, BUBBLE_RADIUS, { 309 | isStatic: false, 310 | restitution: 0.6, 311 | }) 312 | ); 313 | } 314 | 315 | return bodies; 316 | } 317 | -------------------------------------------------------------------------------- /src/components/GradientButton.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | LinearGradient, 4 | RoundedRect, 5 | Shadow, 6 | vec, 7 | } from "@shopify/react-native-skia"; 8 | import { useRef, useState } from "react"; 9 | import { 10 | Animated, 11 | LayoutRectangle, 12 | Pressable, 13 | StyleProp, 14 | StyleSheet, 15 | Text, 16 | View, 17 | ViewStyle, 18 | } from "react-native"; 19 | 20 | const AnimatedTouchable = Animated.createAnimatedComponent(Pressable); 21 | 22 | export function GradientButton(props: { 23 | title: string; 24 | style?: StyleProp; 25 | onLayout?: (layout: LayoutRectangle) => void; 26 | onPress: () => void; 27 | }) { 28 | const { title, style, onLayout, onPress } = props; 29 | const [dimensions, setDimensions] = useState({ width: 0, height: 0 }); 30 | 31 | const scaleValue = useRef(new Animated.Value(1)).current; 32 | 33 | const pressInAnimation = () => { 34 | Animated.spring(scaleValue, { 35 | toValue: 0.93, 36 | useNativeDriver: true, 37 | }).start(); 38 | }; 39 | 40 | const pressOutAnimation = () => { 41 | Animated.spring(scaleValue, { 42 | toValue: 1, 43 | useNativeDriver: true, 44 | }).start(); 45 | }; 46 | 47 | return ( 48 | { 61 | if (onLayout) { 62 | onLayout(event.nativeEvent.layout); 63 | } 64 | 65 | setDimensions({ 66 | width: event.nativeEvent.layout.width, 67 | height: event.nativeEvent.layout.height, 68 | }); 69 | }} 70 | onPressIn={pressInAnimation} 71 | onPressOut={pressOutAnimation} 72 | onPress={onPress} 73 | > 74 | <> 75 | {/* Background */} 76 | 77 | 85 | 90 | 97 | 98 | 99 | 100 | {/* Content */} 101 | 102 | {title} 103 | 104 | 105 | 106 | ); 107 | } 108 | 109 | const styles = StyleSheet.create({ 110 | container: { 111 | flex: 1, 112 | }, 113 | canvas: { 114 | flex: 1, 115 | }, 116 | contentContainer: { 117 | ...StyleSheet.absoluteFillObject, 118 | alignItems: "center", 119 | justifyContent: "center", 120 | }, 121 | text: { 122 | fontSize: 18, 123 | fontWeight: "bold", 124 | color: "white", 125 | }, 126 | }); 127 | -------------------------------------------------------------------------------- /src/components/Logo.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | StyleProp, 3 | Text, 4 | ViewStyle, 5 | StyleSheet, 6 | LayoutRectangle, 7 | } from "react-native"; 8 | 9 | export function Logo(props: { 10 | style?: StyleProp; 11 | onLayout: (layout: LayoutRectangle) => void; 12 | }) { 13 | const { style, onLayout } = props; 14 | 15 | return ( 16 | onLayout(event.nativeEvent.layout)} 19 | > 20 | Physics 21 | 22 | ); 23 | } 24 | 25 | const styles = StyleSheet.create({ 26 | text: { 27 | fontSize: 40, 28 | color: "white", 29 | fontWeight: "bold", 30 | lineHeight: 40, 31 | textAlign: "center", 32 | textTransform: "uppercase", 33 | textShadowOffset: { width: 0, height: 4 }, 34 | textShadowRadius: 4, 35 | textShadowColor: "rgba(0,0,0,0.3)", 36 | }, 37 | }); 38 | -------------------------------------------------------------------------------- /src/components/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./BubblesContainer"; 2 | export * from "./GradientButton"; 3 | export * from "./Logo"; 4 | -------------------------------------------------------------------------------- /src/config.ts: -------------------------------------------------------------------------------- 1 | import { Dimensions } from "react-native"; 2 | 3 | export const SCREEN_WIDTH = Dimensions.get("window").width; 4 | export const SCREEN_HEIGHT = Dimensions.get("window").height; 5 | 6 | export const UI_TIMESTEP = 1000 / 45; 7 | export const PHYSICS_TIMESTEP = 1000 / 60; 8 | 9 | export const TOP_WALL_OFFSET = 300; 10 | export const WALL_WIDTH = 100; 11 | 12 | export const BUBBLES_COUNT = 35; 13 | export const BUBBLE_RADIUS = 30; 14 | export const BUBBLE_SPAWN_X = SCREEN_WIDTH / 2 - BUBBLE_RADIUS; 15 | export const BUBBLE_SPAWN_Y = -150; 16 | -------------------------------------------------------------------------------- /src/drawing/Bubble.tsx: -------------------------------------------------------------------------------- 1 | import { Image, SkImage } from "@shopify/react-native-skia"; 2 | import { memo } from "react"; 3 | 4 | /** The padding used to create some overlap between bubbles. */ 5 | const BUBBLE_PADDING = 5; 6 | 7 | export const Bubble = memo( 8 | (props: { 9 | x: number; 10 | y: number; 11 | radius: number; 12 | angle: number; 13 | image: SkImage; 14 | }) => { 15 | const { x, y, radius, angle, image } = props; 16 | 17 | const size = radius * 2 + BUBBLE_PADDING; 18 | 19 | return ( 20 | 29 | ); 30 | } 31 | ); 32 | -------------------------------------------------------------------------------- /src/drawing/ParallaxImage.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | DataSourceParam, 3 | Fit, 4 | Image, 5 | SkiaMutableValue, 6 | interpolate, 7 | useImage, 8 | useValue, 9 | useValueEffect, 10 | } from "@shopify/react-native-skia"; 11 | 12 | export function ParallaxImage(props: { 13 | x: number; 14 | y: number; 15 | width: number; 16 | height: number; 17 | ratioX: SkiaMutableValue; 18 | ratioY: SkiaMutableValue; 19 | amplitude: [number, number]; 20 | source: DataSourceParam; 21 | fit?: Fit; 22 | }) { 23 | const { x, y, width, height, ratioX, ratioY, amplitude, source, fit } = props; 24 | 25 | const localX = useValue(x); 26 | const localY = useValue(y); 27 | 28 | useValueEffect(ratioX, () => { 29 | localX.current = interpolate( 30 | ratioX.current, 31 | [0, 1], 32 | [x + amplitude[0], x + amplitude[1]] 33 | ); 34 | }); 35 | 36 | useValueEffect(ratioY, () => { 37 | localY.current = interpolate( 38 | ratioY.current, 39 | [0, 1], 40 | [y + amplitude[0], y + amplitude[1]] 41 | ); 42 | }); 43 | 44 | const image = useImage(source); 45 | if (!image) { 46 | return null; 47 | } 48 | 49 | return ( 50 | 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/drawing/Wall.tsx: -------------------------------------------------------------------------------- 1 | import { Rect } from "@shopify/react-native-skia"; 2 | import { memo } from "react"; 3 | 4 | export const Wall = memo( 5 | (props: { 6 | x: number; 7 | y: number; 8 | width: number; 9 | height: number; 10 | color?: string; 11 | }) => { 12 | const { x, y, width, height, color = "transparent" } = props; 13 | 14 | return ; 15 | } 16 | ); 17 | -------------------------------------------------------------------------------- /src/drawing/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./Bubble"; 2 | export * from "./ParallaxImage"; 3 | export * from "./Wall"; 4 | -------------------------------------------------------------------------------- /src/hooks/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./useGravity"; 2 | -------------------------------------------------------------------------------- /src/hooks/useGravity.ts: -------------------------------------------------------------------------------- 1 | import { Vector, clamp } from "@shopify/react-native-skia"; 2 | import { DeviceMotion } from "expo-sensors"; 3 | import { useEffect, useState } from "react"; 4 | import { roundDigitsLength } from "../utils"; 5 | 6 | /** 7 | * A hook to listen to the device's orientation changes and convert them to a vectorized gravity. 8 | */ 9 | export function useGravity( 10 | onUpdate: (gravity: { x: number; y: number }) => void 11 | ) { 12 | console.log("useGravity"); 13 | const update = () => { 14 | DeviceMotion.setUpdateInterval(200); 15 | }; 16 | 17 | const subscribe = () => { 18 | DeviceMotion.addListener((devicemotionData) => { 19 | onUpdate({ 20 | x: devicemotionData.rotation.gamma, 21 | y: devicemotionData.rotation.beta, 22 | }); 23 | }); 24 | 25 | update(); 26 | }; 27 | 28 | const unsubscribe = () => { 29 | DeviceMotion.removeAllListeners(); 30 | }; 31 | 32 | useEffect(() => { 33 | subscribe(); 34 | 35 | return () => { 36 | unsubscribe(); 37 | }; 38 | }, []); 39 | } 40 | 41 | const GRAVITY_DISTANCE = 3; 42 | 43 | /** A hook returning gravity as a normalized vector. */ 44 | export function useNormalizedGravity(): Vector { 45 | const [value, setValue] = useState({ x: 0, y: 0 }); 46 | 47 | useGravity((updated) => { 48 | // Make the value fit between 0 and 3 49 | const clampedX = clamp( 50 | updated.x + GRAVITY_DISTANCE / 2, 51 | 0, 52 | GRAVITY_DISTANCE 53 | ); 54 | 55 | const clampedY = clamp( 56 | updated.y + GRAVITY_DISTANCE / 2, 57 | 0, 58 | GRAVITY_DISTANCE 59 | ); 60 | 61 | // Normalize the value between 0 and 1 62 | const normalizedX = roundDigitsLength(clampedX / GRAVITY_DISTANCE, 2); 63 | const normalizedY = roundDigitsLength(clampedY / GRAVITY_DISTANCE, 2); 64 | 65 | if (value.x !== normalizedX || value.y !== normalizedY) { 66 | setValue({ x: normalizedX, y: normalizedY }); 67 | } 68 | }); 69 | 70 | return value; 71 | } 72 | -------------------------------------------------------------------------------- /src/navigation.ts: -------------------------------------------------------------------------------- 1 | import { 2 | NativeStackScreenProps, 3 | createNativeStackNavigator, 4 | } from "@react-navigation/native-stack"; 5 | 6 | type RootStackParamList = { 7 | Physics: undefined; 8 | Sensors: undefined; 9 | }; 10 | 11 | export type PhysicsProps = NativeStackScreenProps< 12 | RootStackParamList, 13 | "Physics" 14 | >; 15 | export type SensorsProps = NativeStackScreenProps< 16 | RootStackParamList, 17 | "Sensors" 18 | >; 19 | 20 | export const RootStack = createNativeStackNavigator(); 21 | -------------------------------------------------------------------------------- /src/screens/PhysicsScreen.tsx: -------------------------------------------------------------------------------- 1 | import { StatusBar } from "expo-status-bar"; 2 | import { useState } from "react"; 3 | import { LayoutRectangle, StyleSheet, View } from "react-native"; 4 | import { 5 | BubblesContainer, 6 | GradientButton, 7 | Logo, 8 | Placeholder, 9 | } from "../components"; 10 | import { SCREEN_HEIGHT } from "../config"; 11 | import { PhysicsProps } from "../navigation"; 12 | 13 | export function PhysicsScreen(props: PhysicsProps) { 14 | const { navigation } = props; 15 | 16 | // States 17 | const [placeholders, setPlaceholders] = useState([]); 18 | 19 | // Hanlders 20 | const handleOnLayout = (id: number, layout: LayoutRectangle) => { 21 | setPlaceholders((previous) => { 22 | const updated = [...previous]; 23 | 24 | const index = updated.findIndex((placeholder) => placeholder.id === id); 25 | if (index === -1) { 26 | updated.push({ 27 | id, 28 | ...layout, 29 | }); 30 | } else { 31 | updated[index] = { 32 | ...placeholders[index], 33 | ...layout, 34 | }; 35 | } 36 | 37 | return updated; 38 | }); 39 | }; 40 | 41 | return ( 42 | 43 | 44 | 45 | 46 | handleOnLayout(123, layout)} 49 | /> 50 | handleOnLayout(456, layout)} 54 | onPress={() => navigation.navigate("Sensors")} 55 | /> 56 | 57 | 58 | ); 59 | } 60 | 61 | const styles = StyleSheet.create({ 62 | container: { 63 | flex: 1, 64 | }, 65 | logo: { 66 | position: "absolute", 67 | height: 50, 68 | top: 120, 69 | left: 100, 70 | right: 100, 71 | }, 72 | button: { 73 | position: "absolute", 74 | height: 50, 75 | top: SCREEN_HEIGHT - 150, 76 | left: 100, 77 | right: 100, 78 | }, 79 | }); 80 | -------------------------------------------------------------------------------- /src/screens/SensorsScreen.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | Canvas, 3 | useImage, 4 | useTiming, 5 | useTouchHandler, 6 | useValue, 7 | useValueEffect, 8 | } from "@shopify/react-native-skia"; 9 | import { StyleSheet, View } from "react-native"; 10 | import { GradientButton } from "../components"; 11 | import { SCREEN_HEIGHT, SCREEN_WIDTH } from "../config"; 12 | import { useNormalizedGravity } from "../hooks"; 13 | import { SensorsProps } from "../navigation"; 14 | import { ParallaxImage } from "../drawing"; 15 | import { roundDigitsLength } from "../utils"; 16 | import { useEffect } from "react"; 17 | 18 | const BACKGROUND_PATH = require("../assets/background.png"); 19 | const BACKGROUND_PADDING = 100; 20 | 21 | const TREE_1_PATH = require("../assets/tree1.png"); 22 | const TREE_2_PATH = require("../assets/tree2.png"); 23 | const TREE_3_PATH = require("../assets/tree3.png"); 24 | 25 | export function SensorsScreen(props: SensorsProps) { 26 | const { navigation } = props; 27 | const ratioX = useValue(0.5); 28 | const ratioY = useValue(0.5); 29 | 30 | const gravity = { x: 0.5, y: 0.5 }; 31 | // const gravity = useNormalizedGravity(); 32 | 33 | const touchHandler = useTouchHandler({ 34 | onActive: ({ x, y }) => { 35 | // ratioX.current = roundDigitsLength(x / SCREEN_WIDTH, 2); 36 | // ratioY.current = roundDigitsLength(y / SCREEN_HEIGHT, 2); 37 | // console.log(`x: ${ratioX.current} y: ${ratioY.current}`); 38 | }, 39 | }); 40 | 41 | console.log(gravity); 42 | 43 | useEffect(() => { 44 | ratioX.current = gravity.x; 45 | ratioY.current = gravity.y; 46 | }, [gravity.x, gravity.y]); 47 | 48 | return ( 49 | 50 | 51 | 62 | 63 | 73 | 74 | 84 | 85 | 95 | 96 | 106 | 107 | 108 | navigation.navigate("Physics")} 112 | /> 113 | 114 | ); 115 | } 116 | 117 | const styles = StyleSheet.create({ 118 | container: { 119 | flex: 1, 120 | alignItems: "center", 121 | }, 122 | canvas: { 123 | ...StyleSheet.absoluteFillObject, 124 | }, 125 | button: { 126 | position: "absolute", 127 | height: 50, 128 | top: SCREEN_HEIGHT - 150, 129 | left: 100, 130 | right: 100, 131 | }, 132 | }); 133 | -------------------------------------------------------------------------------- /src/screens/index.ts: -------------------------------------------------------------------------------- 1 | export * from "./PhysicsScreen"; 2 | export * from "./SensorsScreen"; 3 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | export function roundDigitsLength(value: number, length: number): number { 2 | return parseFloat(value.toFixed(length)); 3 | } 4 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": {}, 3 | "extends": "expo/tsconfig.base" 4 | } 5 | --------------------------------------------------------------------------------