<template>
<div id="rack" ref="el">
<!-- Greetings from Stick Season! -->
</div>
</template>
<script setup>
// Imports
import { computed, ref, onMounted, onUnmounted, watch } from "vue"
// Refs
const card = ref(null)
const el = ref(null)
const interactive = ref(true)
const selected = ref(false)
const tracks = ref([
{
"title": "Northern Attitude",
"slug": "northern-attitude",
"position": 1
},
{
"title": "Stick Season",
"slug": "stick-season",
"position": 2
},
{
"title": "All My Love",
"slug": "all-my-love",
"position": 3
},
{
"title": "She Calls Me Back",
"slug": "she-calls-me-back",
"position": 4
},
{
"title": "Come Over",
"slug": "come-over",
"position": 5
},
{
"title": "New Perspective",
"slug": "new-perspective",
"position": 6
},
{
"title": "Everywhere, Everything",
"slug": "everywhere-everything",
"position": 7
},
{
"title": "Orange Juice",
"slug": "orange-juice",
"position": 8
},
{
"title": "Strawberry Wine",
"slug": "strawberry-wine",
"position": 9
},
{
"title": "Growing Sideways",
"slug": "growing-sideways",
"position": 10
},
{
"title": "Halloween",
"slug": "halloween",
"position": 11
},
{
"title": "Homesick",
"slug": "homesick",
"position": 12
},
{
"title": "Still",
"slug": "still",
"position": 13
},
{
"title": "The View Between Villages",
"slug": "the-view-between-villages",
"position": 14
},
{
"title": "Your Needs, My Needs",
"slug": "your-needs-my-needs",
"position": 15
},
{
"title": "Dial Drunk",
"slug": "dial-drunk",
"position": 16
},
{
"title": "Paul Revere",
"slug": "paul-revere",
"position": 17
},
{
"title": "No Complaints",
"slug": "no-complaints",
"position": 18
},
{
"title": "Call Your Mom",
"slug": "call-your-mom",
"position": 19
},
{
"title": "You’re Gonna Go Far",
"slug": "youre-gonna-go-far",
"position": 20
}
])
// Vars
let scene, camera, renderer, raycaster, pointer, frame, hammer
let holderModel, postcardTextures, backTexture, signTexture
let box, rack, spinner, pedestal, topper
let postcards = []
// Watchers
// ----------
// Interactive
watch(interactive, (newVal, oldVal) => {
// Log
console.log('interactive', newVal, oldVal)
// Adjust pan
hammer.get('pan').set({
enable: newVal
})
// Adjust tap
hammer.get('tap').set({
enable: newVal
})
})
// Card
watch(card, (newVal, oldVal) => {
// Log
console.log('card', newVal, oldVal)
// If card reset
if (!newVal) {
// Return card
returnPostcard(postcards[oldVal.position - 1])
}
})
// Selected
watch(selected, (newVal, oldVal) => {
// Log
console.log('selected', newVal, oldVal)
// If selected
if (newVal) {
// Enable tap
hammer.get('tap').set({
enable: true
})
}
})
// Initialize rack
// ----------
async function initializeRack() {
// Scene
addScene()
// Camera
addCamera()
// Renderer
addRenderer()
// Raycaster
addRaycaster()
// Hammer
addHammer()
// Load models
await loadModels()
// Load textures
await loadTextures()
// Rack
addRack()
// Box
addBox()
// Spinner
addSpinner()
// Pedestal
addPedestal()
// Start rendering
startRendering()
// Start resizing
startResizing()
}
// Add scene
// ----------
function addScene() {
// Scene
scene = new THREE.Scene()
}
// Add camera
// ----------
function addCamera() {
// Camera
camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000)
// Position camera
camera.position.y = 22
camera.position.z = 50
}
// Return camera
// ----------
function returnCamera() {
// Animate camera position
gsap.to(camera.position, {
duration: 2,
y: 22,
z: 50
})
}
// Point camera
// ----------
function pointCamera(postcard) {
// Promise
return new Promise((resolve, revoke) => {
// Initialize position
let position = new THREE.Vector3()
// Get postcard world position
postcard.getWorldPosition(position)
// Animate camera position
gsap.to(camera.position, {
duration: 2,
y: position.y + 3,
z: 30,
onComplete() {
// Resolve
resolve()
}
})
})
}
// Add renderer
// ----------
function addRenderer() {
// Renderer
renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true
})
// Resize renderer
renderer.setSize(window.innerWidth, window.innerHeight)
// Pixel ratio
renderer.setPixelRatio(window.devicePixelRatio)
// Append renderer
el.value.appendChild(renderer.domElement)
}
// Add raycaster
// ----------
function addRaycaster() {
// Raycaster
raycaster = new THREE.Raycaster()
// Pointer
pointer = new THREE.Vector2()
// Start moving
startMoving()
}
// Add hammer
// ----------
function addHammer() {
// Initialize hammer
hammer = new Hammer(renderer.domElement, {
enable: interactive.value
})
// Set pan horizontal
hammer.get('pan').set({
direction: Hammer.DIRECTION_HORIZONTAL
})
// Tap
hammer.on('tap', e => {
// Log
// console.log('tap', e)
// Update pointer
pointer.x = (e.center.x / window.innerWidth) * 2 - 1
pointer.y = - (e.center.y / window.innerHeight) * 2 + 1
// Set raycaster
raycaster.setFromCamera(pointer, camera)
// Check for intersects
const intersects = raycaster.intersectObjects(postcards)
// If intersects
if (intersects.length) {
// Get postcard
let postcard = intersects[0].object.parent
// If selected
if (selected.value) {
// Reset card
card.value = null
} else {
// Select postcard
selectPostcard(postcard)
}
}
})
// Pan start
hammer.on('panstart', e => {
// Log
// console.log('panstart', e)
// Grabbing cursor
el.value.style.cursor = "grabbing"
})
// Pan
hammer.on('pan', e => {
// Log
// console.log('pan', e)
// Rotate spinner
spinner.rotation.y += e.velocityX / 4
})
// Pan end
hammer.on('panend', e => {
// Log
// console.log('panend', e)
// Default cursor
el.value.style.cursor = "pointer"
})
}
// Load model
// ----------
function loadModel(url) {
// Promise
return new Promise((resolve, revoke) => {
// Load model
new THREE.GLTFLoader().load(url, resolve, undefined, revoke)
})
}
// Load models
// ----------
async function loadModels() {
// Load model
let model = await loadModel('https://assets.codepen.io/141041/holder.glb')
// Get holder
holderModel = model.scene.children[0]
// Adjust color
holderModel.material = new THREE.MeshBasicMaterial({
color: 0x998564
})
}
// Load texture
// ----------
function loadTexture(url) {
// Promise
return new Promise((resolve, revoke) => {
// Load texture
new THREE.TextureLoader().load(url, resolve, undefined, revoke)
})
}
// Load textures
// ----------
async function loadTextures() {
// Load postcard textures
postcardTextures = await Promise.all(tracks.value.map(track => loadTexture(`https://assets.codepen.io/141041/${track.slug}.jpg`)))
// Loop through postcard textures
postcardTextures.forEach(texture => {
// Set wrap
texture.wrapS = THREE.RepeatWrapping
// Flip texture
texture.repeat.x = -1
})
// Load postcard back texture
backTexture = await loadTexture('https://assets.codepen.io/141041/back.jpg')
// Load sign texture
signTexture = await loadTexture('https://assets.codepen.io/141041/sign.jpg')
// Adjust colorspace
Array.from([...postcardTextures, backTexture, signTexture]).forEach(texture => texture.colorSpace = THREE.SRGBColorSpace)
}
// Add rack
// ----------
function addRack() {
// Initialize rack
rack = new THREE.Object3D()
// Add rack to scene
scene.add(rack)
}
// Add box
// ----------
function addBox() {
// Geometry
let geometry = new THREE.BoxGeometry(24, 64, 24)
// Material
let material = new THREE.MeshBasicMaterial({
color: 0xFFFFFF,
depthWrite: false,
opacity: 0.0,
transparent: true
})
// Box
box = new THREE.Mesh(geometry, material)
// Position box
box.position.y = 20
// Add box to rack
rack.add(box)
}
// Add spinner
// ----------
function addSpinner() {
// Initialize spinner
spinner = new THREE.Object3D()
// Position spinner
spinner.position.y = 2
// Add spinner to rack
rack.add(spinner)
// Loop 5 times for each row
for (let i = 0; i < 5; i++) {
// Add row
addRow(i)
}
}
// Rotate spinner
// ----------
function rotateSpinner(column) {
// Promise
return new Promise((resolve, revoke) => {
// Spinner rotation to
let to = [0, THREE.MathUtils.degToRad(-90), THREE.MathUtils.degToRad(-180), THREE.MathUtils.degToRad(-270)][column]
// Spinner rotation from
let from = spinner.rotation.y % (Math.PI * 2)
// Log
// console.log('from: ', from , 'to: ', to)
// Calculate shortest rotation
if (Math.abs(to - from) > Math.PI) {
if (to > 0) {
to = to - 2 * Math.PI
} else {
to = to + 2 * Math.PI
}
}
// Calculate delta
let delta = to - from
// Log
// console.log('from: ', from , 'to: ', to, 'delta: ', delta)
// Rotate spinner
gsap.to(spinner.rotation, {
duration: 2,
// y: to,
y: `+=${delta}`,
onComplete() {
// Resolve
resolve()
}
})
})
}
// Add row
// ----------
function addRow(row) {
// Loop 4 times for each column
for (let column = 0; column < 4; column++) {
// Clone holder
let holder = holderModel.clone()
// Rotate holder
holder.rotation.y = THREE.MathUtils.degToRad((column * 90) + 90)
// Position holder x or y
switch(column) {
case 0:
holder.position.z = 8
break
case 1:
holder.position.x = 8
break
case 2:
holder.position.z = -8
break
case 3:
holder.position.x = -8
break
}
// Position holder y
holder.position.y = (8 * row) + 3
// Name
holder.name = "holder"
// Row
holder.row = row
// Column
holder.column = column
// Add holder to spinner
spinner.add(holder)
// Get postcard index
let postcard = row * 4 + column
// Add postcard
addPostcard(postcard, holder)
}
}
// Initialize postcard
// ----------
function addPostcard(index, holder) {
// Postcard
let postcard = new THREE.Object3D()
// Geometry
let geometry = new THREE.PlaneGeometry(12, 8)
// Rotate geometry
geometry.rotateY(THREE.MathUtils.degToRad(90))
// Back material
let backMaterial = new THREE.MeshBasicMaterial({
color: 0xFFFFFF,
map: backTexture,
side: THREE.FrontSide,
})
// Postcard back
let postcardBack = new THREE.Mesh(geometry, backMaterial)
// Name
postcardBack.name = "back"
// Add to postcard
postcard.add(postcardBack)
// Front material
let frontMaterial = new THREE.MeshBasicMaterial({
color: 0xFFFFFF,
map: postcardTextures[index],
side: THREE.BackSide,
})
// Postcard front
let postcardFront = new THREE.Mesh(geometry, frontMaterial)
// Name
postcardFront.name = "front"
// Add to postcard
postcard.add(postcardFront)
// Name
postcard.name = "postcard"
// Track
postcard.track = tracks.value[index]
// Selected
postcard.selected = false
// Scale postcard
postcard.scale.set(0.95, 0.95, 0.95)
// Rotate postcard
postcard.rotation.z = THREE.MathUtils.degToRad(-20)
// Position postcard
postcard.position.x = 0.75
postcard.position.y = 0.75
// Add postcard to holder
holder.add(postcard)
// Add postcard to array
postcards.push(postcard)
// Animation
// ----------
// Postcard animation timeline
let tl = gsap.timeline({
duration: 0.25,
paused: true
})
// Rotate postcard back
tl.to(postcard.rotation, {
z: THREE.MathUtils.degToRad(30),
})
// Position postcard back
tl.to(postcard.position, {
x: "0"
}, "<")
// Pull out of holder
tl.to(postcard.position, {
y: "+=6",
x: "-=5"
})
// Hold straight
tl.to(postcard.rotation, {
z: THREE.MathUtils.degToRad(0)
})
// Lower
tl.to(postcard.position, {
y: "-=3"
}, "<")
// Fade away everything else
tl.to(box.material, {
opacity: 1.0
}, "<")
// Add postcard pull animation
postcard.pull = tl
}
// Select postcard
// ----------
async function selectPostcard(postcard) {
// Log
console.log('select postcard: ', postcard)
// Not interactive
interactive.value = false
// Selected
postcard.selected = true
// Point camera and rotate spinner
await Promise.all([
pointCamera(postcard),
rotateSpinner(postcard.parent.column)
])
// Pull postcard
postcard.pull.play().then(() => {
// Log
console.log('postcard selected')
// Update card
card.value = postcard.track
// Selected
selected.value = true
})
}
// Return postcard
// ----------
function returnPostcard(postcard) {
// Log
console.log('return postcard: ', postcard)
// Not selected
postcard.selected = false
// Return camera
returnCamera()
// Put back postcard
postcard.pull.reverse().then(() => {
// Log
console.log('postcard returned')
// Interactive
interactive.value = true
// Not selected
selected.value = false
})
}
// Add pedestal
// ----------
function addPedestal() {
// Initialize pedestal
pedestal = new THREE.Object3D()
// Add pedestal to rack
rack.add(pedestal)
// Base
addBase()
// Pole
addPole()
// Topper
addTopper()
}
// Add base
// ----------
function addBase() {
// Geometry
let geometry = new THREE.CylinderGeometry(12, 12, 0.25, 32)
// Material
let material = new THREE.MeshBasicMaterial({
color: 0xF1F1F1
})
// Base
let base = new THREE.Mesh(geometry, material)
// Name
base.name = "base"
// Add base to pedestal
pedestal.add(base)
}
// Add pole
// ----------
function addPole() {
// Geometry
let geometry = new THREE.CylinderGeometry(1, 1, 44, 16)
// Material
let material = new THREE.MeshBasicMaterial({
color: 0x998564
})
// Pole
let pole = new THREE.Mesh(geometry, material)
// Name
pole.name = "pole"
// Position pole
pole.position.y = 22
// Add pole to pedestal
pedestal.add(pole)
}
// Add topper
// ----------
function addTopper() {
// Initialize topper
topper = new THREE.Object3D()
// Position topper
topper.position.y = 44
// Add topper to pedestal
pedestal.add(topper)
// Slot
addSlot()
// Sign
addSign()
}
// Add slot
// ----------
function addSlot() {
// Geometry
let geometry = new THREE.BoxGeometry(5, 2, 1)
// Material
let material = new THREE.MeshBasicMaterial({
color: 0xF1F1F1
})
// Slot
let slot = new THREE.Mesh(geometry, material)
// Name
slot.name = "slot"
// Position slot
slot.position.y = 1
// Add slot to topper
topper.add(slot)
}
// Add sign
// ----------
function addSign() {
// Geometry
let geometry = new THREE.PlaneGeometry(17, 7)
// Material
let material = new THREE.MeshBasicMaterial({
map: signTexture,
side: THREE.DoubleSide,
// transparent: true
})
// Sign
let sign = new THREE.Mesh(geometry, material)
// Name
sign.name = "sign"
// Position sign
sign.position.y = 4
// Add sign to topper
topper.add(sign)
}
// Render
// ----------
function render() {
// Request animation frame
frame = requestAnimationFrame(render)
// Render scene
renderer.render(scene, camera)
}
// Start rendering
// ----------
function startRendering() {
// Log
console.log('start rendering')
// Render
render()
}
// Stop rendering
// ----------
function stopRendering() {
// Log
console.log('stop rendering')
// Stop render
cancelAnimationFrame(frame)
}
// Resize
// ----------
function resize() {
// Log
console.log('resize')
// Update camera aspect
camera.aspect = window.innerWidth / window.innerHeight
// Update camera projection
camera.updateProjectionMatrix()
// Resize renderer
renderer.setSize(window.innerWidth, window.innerHeight)
}
// Start resizing
// ----------
function startResizing() {
// Log
console.log('start resizing')
// On window resize
window.addEventListener('resize', resize)
}
// Stop resizing
// ----------
function stopResizing() {
// Log
console.log('stop resizing')
// Remove window resize event listener
window.removeEventListener('resize', resize)
}
// Move
// ----------
function move(e) {
// Log
// console.log('move', e)
// Update pointer
pointer.x = (e.clientX / window.innerWidth) * 2 - 1
pointer.y = - (e.clientY / window.innerHeight) * 2 + 1
}
// Start moving
// ----------
function startMoving() {
// Log
console.log('start moving')
// On pointer move
window.addEventListener('pointermove', move)
}
// Stop moving
// ----------
function stopMoving() {
// Log
console.log('stop moving')
// Remove window pointer move listener
window.removeEventListener('pointermove', move)
}
// Play intro
// ----------
function playIntro() {
// Intro timeline
let tl = gsap.timeline({
ease: "none",
delay: 0,
onComplete() {
// Introed
introed.value = true
// Select postcard
selectPostcard(postcards[card.value.position - 1])
}
})
// Fade out box
tl.to(box.material, {
duration: 10,
opacity: 0
})
// Position camera
tl.to(camera.position, {
duration: 10,
y: 22,
z: 50
}, "<")
// Rotate spinner (to particular location?)
tl.to(spinner.rotation, {
duration: 10,
y: Math.PI * 2
}, "<")
}
// On mounted
// ----------
onMounted(async () => {
// Initialize rack
initializeRack()
})
// On Unmounted
// ----------
onUnmounted(() => {
// Stop rendering
stopRendering()
// Stop resizing
stopResizing()
// Stop moving
stopMoving()
})
</script>
<style>
canvas{
cursor: pointer;
}
</style>
This Pen doesn't use any external CSS resources.