Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
197 changes: 197 additions & 0 deletions apps/lab/app/components/world-walker/Footman.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<script setup lang="ts">
import type { AnimationAction } from 'three'
import { useLoop, useTres } from '@tresjs/core'
import { OrbitControls, useGLTF, useAnimations } from '@tresjs/cientos'
import { RigidBody, CapsuleCollider } from '@tresjs/rapier'
import { useMagicKeys } from '@vueuse/core'
import { Quaternion, Vector3 } from 'three'
import { HEIGHT_SCALE } from './constants'

type Body = NonNullable<InstanceType<typeof RigidBody>['instance']>
type BodyPosition = ReturnType<Body['translation']>
type Controls = NonNullable<InstanceType<typeof OrbitControls>['instance']>

const { state, isLoading } = useGLTF('https://raw.githubusercontent.com/Tresjs/assets/main/models/gltf/warcraft-3-alliance-footmanfanmade/source/Footman_RIG.glb')

const animations = computed(() => state.value?.animations || [])
const model = computed(() => state?.value?.scene)
const { actions } = useAnimations(animations, model)

// useAnimations binds the actions post-flush, so seed off `actions` rather than the
// loading flag — a pre-flush watcher on isLoading reads them before they exist
watch(actions, (newActions) => {
const idle = newActions.Idle
if (!idle || currentAction.value) return
currentAction.value = idle
idle.play()
if (hasPressed.value) changeAnimation(newActions.SwordAndShieldRun)
})

// constants
const fadeDuration = 0.2
const velocity = 5
const rotateAngle = new Vector3(0, 1, 0)
const cameraTarget = new Vector3()
const walkDirection = new Vector3()
const rotateQuarternion = new Quaternion()
const prevPosition = new Vector3()
let hasPrevPosition = false

// template ref
const { camera } = useTres()
type Camera = NonNullable<typeof camera.value>
const orbitControlsRef = shallowRef<InstanceType<typeof OrbitControls> | null>(null)
const bodyRef = shallowRef<InstanceType<typeof RigidBody> | null>(null)
const currentAction = ref<AnimationAction>()

const changeAnimation = (action: AnimationAction | undefined) => {
if (!action || !currentAction.value || currentAction.value === action) return
currentAction.value.fadeOut(fadeDuration)
action.reset().fadeIn(fadeDuration).play()
currentAction.value = action
}

// KEYS
// useMagicKeys types its keys through an index signature, so each one comes back
// as possibly undefined under noUncheckedIndexedAccess
const keys = useMagicKeys()
const w = keys.w!
const s = keys.s!
const a = keys.a!
const d = keys.d!
const hasPressed = computed(() => w.value || s.value || a.value || d.value)
watch(hasPressed, (pressed) => {
changeAnimation(pressed ? actions.SwordAndShieldRun : actions.Idle)
})

const moveBody = (body: Body, position: BodyPosition, camera: Camera) => {
const angleYCameraDirection = Math.atan2(
camera.position.x - position.x,
camera.position.z - position.z,
)
const directionOffset = getOffset()
const directionOffsetModel = getInvertOffset() // correct rotation model coordinates

// rotate model
rotateQuarternion.setFromAxisAngle(rotateAngle, angleYCameraDirection + directionOffsetModel)
model.value?.quaternion.rotateTowards(rotateQuarternion, 0.2)

// calculate direction
camera.getWorldDirection(walkDirection)
walkDirection.y = 0
walkDirection.normalize()
walkDirection.applyAxisAngle(rotateAngle, directionOffset)

// gravity keeps the y velocity, the heightfield does the rest
body.setLinvel(
{ x: walkDirection.x * velocity, y: body.linvel().y, z: walkDirection.z * velocity },
true,
)
}

const getOffset = () => {
let directionOffset = 0 // ww
if (w.value) {
if (a.value) {
directionOffset = Math.PI / 4 // w+a
} else if (d.value) {
directionOffset = -Math.PI / 4 // w+d
}
} else if (s.value) {
if (a.value) {
directionOffset = Math.PI / 4 + Math.PI / 2 // s+a
} else if (d.value) {
directionOffset = -Math.PI / 4 - Math.PI / 2 // s+d
} else {
directionOffset = Math.PI // s
}
} else if (a.value) {
directionOffset = Math.PI / 2 // a
} else if (d.value) {
directionOffset = -Math.PI / 2 // d
}

return directionOffset
}
const getInvertOffset = () => {
let directionOffset = Math.PI // ww
if (w.value) {
if (a.value) {
directionOffset = -Math.PI / 4 - Math.PI / 2 // w+a
} else if (d.value) {
directionOffset = Math.PI / 4 + Math.PI / 2 // w+d
}
} else if (s.value) {
if (a.value) {
directionOffset = -Math.PI / 4 // s+a
} else if (d.value) {
directionOffset = Math.PI / 4 // s+d
} else {
directionOffset = 0 // s
}
} else if (a.value) {
directionOffset = -Math.PI / 2 // a
} else if (d.value) {
directionOffset = +Math.PI / 2 // d
}

return directionOffset
}
const updateCameraTarget = (position: BodyPosition, camera: Camera, controls: Controls) => {
// move camera by the actual physics displacement
camera.position.x += position.x - prevPosition.x
camera.position.z += position.z - prevPosition.z

// update camera target
cameraTarget.set(position.x, position.y + 1, position.z)
controls.target = cameraTarget
}
const { onBeforeRender } = useLoop()

onBeforeRender(() => {
if (isLoading.value) return

const body = bodyRef.value?.instance
const controls = orbitControlsRef.value?.instance
const activeCamera = camera.value
if (!body || !activeCamera || !controls) return

const position = body.translation()
if (!hasPrevPosition) {
prevPosition.set(position.x, position.y, position.z)
hasPrevPosition = true
}

if (hasPressed.value) {
moveBody(body, position, activeCamera)
} else {
body.setLinvel({ x: 0, y: body.linvel().y, z: 0 }, true)
}

updateCameraTarget(position, activeCamera, controls)
prevPosition.set(position.x, position.y, position.z)
controls.update()
})
</script>

<template>
<OrbitControls
ref="orbitControlsRef"
enable-damping
:enable-pan="false"
:min-distance="3"
:max-distance="12"
:max-polar-angle="Math.PI / 2 - 0.05"
/>
<RigidBody
v-if="model"
ref="bodyRef"
type="dynamic"
:collider="false"
lock-rotations
:position="[0, HEIGHT_SCALE / 2, 0]"
>
<CapsuleCollider :args="[0.5, 0.35]" :position="[0, 0.85, 0]" />
<primitive :object="model" />
</RigidBody>
</template>
62 changes: 62 additions & 0 deletions apps/lab/app/components/world-walker/Terrain.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
<script setup lang="ts">
import { useTextures } from '@tresjs/cientos'
import { RigidBody, HeightfieldCollider } from '@tresjs/rapier'
import { RepeatWrapping } from 'three'
import { loadHeightImage, readHeightData } from './heightmap'
import { TERRAIN_SIZE, HEIGHT_SCALE, HEIGHTFIELD_ROWS } from './constants'

const { textures } = useTextures([
'/textures/world-walker/height.jpg',
'/textures/world-walker/color.jpg',
'/textures/world-walker/normal.jpg',
'/textures/world-walker/ao.jpg',
])

// tile color/normal/ao (skip the displacement map at index 0)
watchEffect(() => {
for (const map of [textures.value[1], textures.value[2], textures.value[3]]) {
if (!map) continue
map.wrapS = RepeatWrapping
map.wrapT = RepeatWrapping
map.repeat.set(8, 8)
map.needsUpdate = true
}
})

// same displacement map the vegetation plants against, downsampled to the collider resolution
const size = HEIGHTFIELD_ROWS + 1
const img = await loadHeightImage()
const rowMajor = readHeightData(img, size)

// rapier wants the heights matrix column-major (row = z, col = x)
const heights = new Float32Array(size * size)
for (let row = 0; row < size; row++) {
for (let col = 0; col < size; col++) {
heights[col * size + row] = rowMajor[row * size + col]!
}
}
</script>

<template>
<RigidBody type="fixed" :collider="false">
<HeightfieldCollider
:args="[
HEIGHTFIELD_ROWS,
HEIGHTFIELD_ROWS,
heights,
{ x: TERRAIN_SIZE, y: HEIGHT_SCALE, z: TERRAIN_SIZE },
]"
/>
</RigidBody>
<TresMesh :rotation-x="Math.PI * -0.5">
<TresPlaneGeometry :args="[TERRAIN_SIZE, TERRAIN_SIZE, 256, 256]" />
<TresMeshStandardMaterial
v-if="textures[0] && textures[1]"
:displacement-map="textures[0]"
:displacement-scale="HEIGHT_SCALE"
:map="textures[1]"
:normal-map="textures[2]"
:ao-map="textures[3]"
/>
</TresMesh>
</template>
Loading
Loading