I have a small 3D cube written in CSS and JavaScript / Angular that rotates on one axis using the "transform: rotateX" method.
It's designed so that when it's clicked on, it rotates up or down in the mouse's direction - this works fairly well. rotateX is powered by a torque control and a rotation value.
However, when released I'd like it to come to a smooth stop on the "camera-facing" side. This.. is very much a hit or miss. Sometimes it works smoothly, others it decides to jump to the next panel on the cube.
At the moment, I have hardcoded detecting which side it's on but there's probably a better way - this might be part of the problem. With the code I have now, is there a better method to get the stop effect?
In Action: Video of rolling dice under user control
HTML:
<div class="dice"
(mousedown)="mouseDown($event)"
(dblclick)="doubleClick()"
>
<div class="cube" [ngStyle]="{'transform': 'rotateX(' + rotation + 'deg)'}">
<div #pane class="face front">4</div>
<div #pane class="face bottom">3</div>
<div #pane class="face back">2</div>
<div #pane class="face top">1</div>
</div>
JavaScript:
import { Component, OnInit, HostListener, ElementRef, ViewChildren, QueryList } from '@angular/core';
@Component({
selector: 'roller',
templateUrl: './roller.component.html',
styleUrls: ['./roller.component.scss']
})
export class RollerComponent implements OnInit {
//@ts-ignore
@ViewChildren("pane", { static: false }) diePanels: QueryList<ElementRef>;
controlDown: boolean = false;
rotationSpeed: number = 2;
friction: number = 0.1;
frictionCutoff: number = 0.93;
mouseY: number = 0;
lastY: number = 0;
delta: number = 0;
torque: number = 0;
rotation: number = 0;
lastSide: number = 0;
currentSide: number = 0;
targetMovement: number = 0;
torqueTarget: number = 0;
torqueMaxSpeed: number = 8;
stopAt: number = 0;
// Get an array of all dice panels //
panels: ElementRef[] = [];
constructor() { }
ngOnInit(): void {
// Lifecycle Placeholder //
}
ngAfterViewInit()
{
this.panels = this.diePanels.toArray();
// In case a reference to the parent div is needed //
// let parent = this.panels[0].nativeElement.parentNode.parentNode;
}
mouseDown(e: any) {
this.controlDown = true;
this.mouseY = e.pageY;
this.lastY = this.mouseY;
}
@HostListener('document:mouseup', ['$event'])
mouseUp()
{
// Listen for mouse up when not on div //
this.controlDown = false;
}
@HostListener('document:mousemove', ['$event'])
onMouseMove(e) {
if (this.controlDown)
{
// Listen for mouse movement //
this.mouseY = e.pageY;
}
}
doubleClick()
{
// Double Click Test //
console.log(this.controlDown);
}
animate(timeDelta: number)
{
// Capture the difference in the click position and current mouse pos
this.delta = this.mouseY - this.lastY;
// Clamp Rotation to 0-360 //
if(this.rotation > 360) {
this.rotation = 0;
} else if(this.rotation < 0) {
this.rotation = 360;
}
// Control Dice when mouse is clicked on it
if (this.controlDown)
{
this.torque = this.torque * this.frictionCutoff + (this.delta * this.rotationSpeed - this.torque) * this.friction;
// Limit Torque Speed
if (this.torque >= this.torqueMaxSpeed)
{
this.torque = this.torqueMaxSpeed;
} else if (this.torque <= -this.torqueMaxSpeed) {
this.torque = -this.torqueMaxSpeed;
}
this.rotation -= this.torque;
} else {
// Slow dice and stop //
this.delta = 0;
this.lastY = 0;
this.torque = 0;
let rot = Math.floor(this.rotation);
// Hardcoding to detect which side is "facing" the viewer //
if (rot > 0 && rot < 90)
{
this.torqueTarget = 90;
this.currentSide = 0;
} else if (rot > 90 && rot < 180) {
this.torqueTarget = 180;
this.currentSide = 1;
} else if (rot > 180 && rot < 270) {
this.torqueTarget = 270;
this.currentSide = 2;
} else if (rot > 270 && rot < 360) {
this.torqueTarget = 360;
this.currentSide = 3;
}
this.rotation = this.lerp(this.rotation, this.torqueTarget, 0.5);
}
}
lerp(start: number, end: number, time: number)
{
return (1-time)*start+time*end;
}
}
Per Request: SCSS
$diceSize: 55px;
.dice {
width: $diceSize;
height: $diceSize;
margin: 335px auto 0;
cursor: pointer;
box-shadow: 4px 5px 6px -1px rgba(0,0,0,0.94);
-webkit-box-shadow: 4px 5px 6px -1px rgba(0,0,0,0.94);
-moz-box-shadow: 4px 5px 6px -1px rgba(0,0,0,0.94);
}
.cube {
transform-style: preserve-3d;
width: 100%;
height: 100%;
position: relative;
animation-fill-mode: backwards;
}
.face {
display: flex;
position: absolute;
font-size: 2.5rem;
align-content: center;
align-items: center;
justify-content: center;
width: 100%;
height: 100%;
background: rgb(255, 255, 255);
background-image: url('./../../../assets/DiceBackground.png');
background-size: auto 100%;
border: rgb(68, 68, 68) inset 1px;
pointer-events: none;
user-select: none;
}
.top {
transform: rotateX(90deg)
translateZ(calc($diceSize / 2));
}
.bottom {
transform: rotateX(-90deg)
translateZ(calc($diceSize / 2));
}
.front {
transform: rotateX(0deg)
translateZ(calc($diceSize / 2));
}
.back {
transform: rotateX(-180deg)
translateZ(calc($diceSize / 2));
}