v4
This commit is contained in:
parent
e713c1471c
commit
edc6d68e9e
@ -170,131 +170,319 @@ textarea::placeholder {
|
|||||||
|
|
||||||
.recliner-stage {
|
.recliner-stage {
|
||||||
position: relative;
|
position: relative;
|
||||||
background: #101419;
|
background:
|
||||||
|
radial-gradient(circle at 22% 18%, rgba(255, 255, 255, 0.08), transparent 28%),
|
||||||
|
radial-gradient(circle at 78% 20%, rgba(212, 216, 222, 0.12), transparent 20%),
|
||||||
|
linear-gradient(180deg, #12171d 0%, #0d1116 100%);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--radius-md);
|
border-radius: var(--radius-md);
|
||||||
min-height: 360px;
|
min-height: 420px;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
isolation: isolate;
|
||||||
|
perspective: 1600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-stage::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: auto 0 0;
|
||||||
|
height: 38%;
|
||||||
|
background: linear-gradient(180deg, transparent, rgba(0, 0, 0, 0.34));
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.grid-fade,
|
||||||
|
.recliner-aurora {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.grid-fade {
|
.grid-fade {
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
background-image:
|
background-image:
|
||||||
linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
linear-gradient(to right, rgba(255, 255, 255, 0.04) 1px, transparent 1px),
|
||||||
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
linear-gradient(to bottom, rgba(255, 255, 255, 0.04) 1px, transparent 1px);
|
||||||
background-size: 36px 36px;
|
background-size: 40px 40px;
|
||||||
mask-image: linear-gradient(180deg, rgba(0,0,0,0.65), transparent 95%);
|
mask-image: linear-gradient(180deg, rgba(0, 0, 0, 0.72), transparent 95%);
|
||||||
|
opacity: 0.8;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis-label {
|
.recliner-aurora {
|
||||||
|
background:
|
||||||
|
radial-gradient(circle at 50% 72%, rgba(212, 216, 222, 0.08), transparent 32%),
|
||||||
|
radial-gradient(circle at 58% 40%, rgba(255, 255, 255, 0.06), transparent 16%);
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label,
|
||||||
|
.motion-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
top: 18px;
|
top: 18px;
|
||||||
color: var(--muted);
|
color: var(--muted);
|
||||||
font-size: 0.72rem;
|
font-size: 0.72rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
letter-spacing: 0.08em;
|
letter-spacing: 0.08em;
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.axis-label-left { left: 18px; }
|
.axis-label-left { left: 18px; }
|
||||||
.axis-label-right { right: 18px; }
|
.axis-label-right { right: 18px; }
|
||||||
|
|
||||||
|
.motion-indicator {
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
padding: 0.42rem 0.75rem;
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(10, 12, 15, 0.52);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
|
||||||
.recliner-figure {
|
.recliner-figure {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 50%;
|
left: 50%;
|
||||||
bottom: 48px;
|
bottom: 26px;
|
||||||
width: 320px;
|
width: 430px;
|
||||||
height: 230px;
|
height: 320px;
|
||||||
transform: translateX(calc(-50% - (var(--angle) * 0.18px))) translateY(calc(var(--angle) * 0.06px));
|
--figure-scale: 1;
|
||||||
|
--drift-x: 0px;
|
||||||
|
--drift-y: 0px;
|
||||||
|
--shake-x: 4px;
|
||||||
|
--shake-y: 2px;
|
||||||
|
--shake-rotate: 0.7deg;
|
||||||
|
--vibe-speed: 140ms;
|
||||||
|
--glow-strength: 0.26;
|
||||||
|
--glow-opacity: 0.54;
|
||||||
|
transform: translateX(calc(-50% + var(--drift-x))) translateY(var(--drift-y)) scale(var(--figure-scale));
|
||||||
|
transform-origin: center bottom;
|
||||||
|
transition: transform 240ms ease;
|
||||||
|
z-index: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-shadow,
|
.recliner-rig,
|
||||||
.recliner-seat,
|
.recliner-motion {
|
||||||
.recliner-arm,
|
|
||||||
.recliner-back,
|
|
||||||
.recliner-head,
|
|
||||||
.recliner-leg,
|
|
||||||
.recliner-base {
|
|
||||||
position: absolute;
|
position: absolute;
|
||||||
background: var(--surface-3);
|
inset: 0;
|
||||||
border: 1px solid rgba(255, 255, 255, 0.08);
|
transform-style: preserve-3d;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-shadow {
|
.recliner-rig {
|
||||||
left: 78px;
|
transform: rotateX(64deg) rotateZ(-29deg);
|
||||||
right: 26px;
|
transform-origin: center bottom;
|
||||||
bottom: 6px;
|
}
|
||||||
|
|
||||||
|
.recliner-motion.is-vibrating.vibration-continuous {
|
||||||
|
animation: rumble-continuous var(--vibe-speed) steps(2, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-motion.is-vibrating.vibration-pulse {
|
||||||
|
animation: rumble-pulse 820ms cubic-bezier(0.34, 1.56, 0.64, 1) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-glow,
|
||||||
|
.recliner-floor,
|
||||||
|
.recliner-seat-pad,
|
||||||
|
.recliner-back-inner {
|
||||||
|
position: absolute;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-glow {
|
||||||
|
left: 76px;
|
||||||
|
bottom: 32px;
|
||||||
|
width: 270px;
|
||||||
|
height: 130px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(212, 216, 222, 0.42) 0%, rgba(212, 216, 222, 0) 72%);
|
||||||
|
filter: blur(18px);
|
||||||
|
opacity: var(--glow-opacity);
|
||||||
|
transform: translateZ(-42px);
|
||||||
|
transition: opacity 180ms ease, filter 180ms ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-floor {
|
||||||
|
left: 64px;
|
||||||
|
bottom: 12px;
|
||||||
|
width: 296px;
|
||||||
|
height: 162px;
|
||||||
|
border-radius: 52% 48% 44% 56% / 56% 54% 46% 44%;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.06), rgba(255, 255, 255, 0.015));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.04);
|
||||||
|
box-shadow: inset 0 -22px 44px rgba(0, 0, 0, 0.22);
|
||||||
|
transform: translateZ(-30px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-block {
|
||||||
|
position: absolute;
|
||||||
|
background: linear-gradient(180deg, #2a323d 0%, #171c22 100%);
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.06), 0 18px 34px rgba(0, 0, 0, 0.22);
|
||||||
|
transform-style: preserve-3d;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-block::before,
|
||||||
|
.recliner-block::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-block::before {
|
||||||
|
left: 10px;
|
||||||
|
right: 0;
|
||||||
|
bottom: 100%;
|
||||||
height: 18px;
|
height: 18px;
|
||||||
border: 0;
|
background: linear-gradient(90deg, rgba(255, 255, 255, 0.22), rgba(255, 255, 255, 0.05));
|
||||||
background: rgba(0, 0, 0, 0.32);
|
clip-path: polygon(0 100%, 100% 100%, calc(100% - 10px) 0, 10px 0);
|
||||||
filter: blur(14px);
|
opacity: 0.9;
|
||||||
border-radius: 999px;
|
}
|
||||||
|
|
||||||
|
.recliner-block::after {
|
||||||
|
top: 8px;
|
||||||
|
left: 100%;
|
||||||
|
bottom: 0;
|
||||||
|
width: 18px;
|
||||||
|
background: linear-gradient(180deg, rgba(10, 12, 15, 0.44), rgba(255, 255, 255, 0.04));
|
||||||
|
clip-path: polygon(0 0, 100% 10px, 100% 100%, 0 calc(100% - 8px));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-plinth {
|
||||||
|
left: 138px;
|
||||||
|
bottom: 52px;
|
||||||
|
width: 126px;
|
||||||
|
height: 28px;
|
||||||
|
border-radius: 18px;
|
||||||
|
transform: translateZ(10px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-column {
|
||||||
|
left: 182px;
|
||||||
|
bottom: 80px;
|
||||||
|
width: 38px;
|
||||||
|
height: 72px;
|
||||||
|
border-radius: 18px;
|
||||||
|
transform: translateZ(22px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-base {
|
||||||
|
left: 154px;
|
||||||
|
bottom: 106px;
|
||||||
|
width: 110px;
|
||||||
|
height: 52px;
|
||||||
|
border-radius: 28px 28px 20px 20px;
|
||||||
|
transform: translateZ(40px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-seat {
|
.recliner-seat {
|
||||||
left: 92px;
|
left: 144px;
|
||||||
bottom: 78px;
|
bottom: 152px;
|
||||||
width: 130px;
|
width: 142px;
|
||||||
height: 52px;
|
height: 82px;
|
||||||
border-radius: 14px 14px 10px 10px;
|
border-radius: 28px 26px 18px 18px;
|
||||||
|
transform: translateZ(62px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-seat-pad {
|
||||||
|
left: 158px;
|
||||||
|
bottom: 166px;
|
||||||
|
width: 112px;
|
||||||
|
height: 46px;
|
||||||
|
border-radius: 20px 18px 16px 16px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.03));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
transform: translateZ(78px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-arm {
|
.recliner-arm {
|
||||||
left: 120px;
|
left: 124px;
|
||||||
bottom: 118px;
|
bottom: 160px;
|
||||||
width: 82px;
|
width: 28px;
|
||||||
height: 20px;
|
height: 102px;
|
||||||
border-radius: 10px;
|
border-radius: 18px;
|
||||||
|
transform: translateZ(64px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-arm-secondary {
|
||||||
|
left: 278px;
|
||||||
|
bottom: 154px;
|
||||||
|
height: 98px;
|
||||||
|
transform: translateZ(34px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-back {
|
.recliner-back {
|
||||||
left: 106px;
|
left: 114px;
|
||||||
bottom: 108px;
|
bottom: 194px;
|
||||||
width: 34px;
|
width: 92px;
|
||||||
height: 116px;
|
height: 142px;
|
||||||
border-radius: 14px;
|
border-radius: 30px;
|
||||||
transform-origin: 18px calc(100% - 10px);
|
transform-origin: right bottom;
|
||||||
transform: rotate(calc(-92deg + (var(--angle) * 0.5deg)));
|
transform: translateZ(66px) rotate(calc(-6deg + (var(--angle) * 0.38deg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-back-inner {
|
||||||
|
left: 130px;
|
||||||
|
bottom: 210px;
|
||||||
|
width: 60px;
|
||||||
|
height: 104px;
|
||||||
|
border-radius: 20px;
|
||||||
|
background: linear-gradient(180deg, rgba(255, 255, 255, 0.16), rgba(255, 255, 255, 0.03));
|
||||||
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
|
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.08);
|
||||||
|
transform-origin: right bottom;
|
||||||
|
transform: translateZ(82px) rotate(calc(-6deg + (var(--angle) * 0.38deg)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-head {
|
.recliner-head {
|
||||||
left: 90px;
|
left: 124px;
|
||||||
bottom: 188px;
|
bottom: 308px;
|
||||||
width: 48px;
|
width: 74px;
|
||||||
height: 30px;
|
height: 38px;
|
||||||
border-radius: 14px;
|
border-radius: 22px;
|
||||||
transform: rotate(calc(-14deg + (var(--angle) * 0.1deg)));
|
transform: translateZ(86px) rotate(calc(-10deg + (var(--angle) * 0.14deg)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-leg {
|
.recliner-leg {
|
||||||
left: 202px;
|
left: 284px;
|
||||||
bottom: 88px;
|
bottom: 166px;
|
||||||
width: 24px;
|
width: 58px;
|
||||||
height: 96px;
|
height: 118px;
|
||||||
border-radius: 14px;
|
border-radius: 20px;
|
||||||
transform-origin: 12px 10px;
|
transform-origin: left center;
|
||||||
transform: rotate(calc(10deg + (var(--angle) * 0.42deg)));
|
transform: translateZ(46px) rotate(calc(10deg + (var(--angle) * 0.34deg)));
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-base {
|
.recliner-footpad {
|
||||||
left: 112px;
|
left: 326px;
|
||||||
bottom: 44px;
|
bottom: 208px;
|
||||||
width: 112px;
|
width: 42px;
|
||||||
height: 14px;
|
height: 58px;
|
||||||
border-radius: 999px;
|
border-radius: 18px;
|
||||||
|
transform-origin: left center;
|
||||||
|
transform: translateZ(30px) rotate(calc(10deg + (var(--angle) * 0.2deg)));
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-figure.is-vibrating .recliner-glow {
|
||||||
|
opacity: 0.9;
|
||||||
|
filter: blur(24px);
|
||||||
|
animation: glow-pulse 760ms ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.recliner-figure[data-pattern="continuous"].is-vibrating .recliner-glow {
|
||||||
|
animation-duration: 220ms;
|
||||||
}
|
}
|
||||||
|
|
||||||
.angle-indicator {
|
.angle-indicator {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
right: 12px;
|
right: 12px;
|
||||||
top: 16px;
|
top: 18px;
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
align-items: flex-end;
|
align-items: flex-end;
|
||||||
padding: 12px 14px;
|
padding: 12px 14px;
|
||||||
border: 1px solid var(--border-strong);
|
border: 1px solid rgba(255, 255, 255, 0.08);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
background: rgba(11, 13, 16, 0.74);
|
background: rgba(11, 13, 16, 0.72);
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
z-index: 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
.angle-indicator span {
|
.angle-indicator span {
|
||||||
@ -310,6 +498,51 @@ textarea::placeholder {
|
|||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@keyframes rumble-continuous {
|
||||||
|
0% {
|
||||||
|
transform: translate3d(calc(var(--shake-x) * -1), calc(var(--shake-y) * -1), 0) rotate(calc(var(--shake-rotate) * -1));
|
||||||
|
}
|
||||||
|
25% {
|
||||||
|
transform: translate3d(var(--shake-x), 0, 0) rotate(calc(var(--shake-rotate) * 0.6));
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translate3d(calc(var(--shake-x) * -0.65), var(--shake-y), 0) rotate(calc(var(--shake-rotate) * -0.5));
|
||||||
|
}
|
||||||
|
75% {
|
||||||
|
transform: translate3d(calc(var(--shake-x) * 0.75), calc(var(--shake-y) * -1), 0) rotate(calc(var(--shake-rotate) * 0.3));
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translate3d(calc(var(--shake-x) * -1), calc(var(--shake-y) * -1), 0) rotate(calc(var(--shake-rotate) * -1));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes rumble-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1);
|
||||||
|
}
|
||||||
|
12% {
|
||||||
|
transform: translate3d(var(--shake-x), calc(var(--shake-y) * -1), 0) rotate(var(--shake-rotate));
|
||||||
|
}
|
||||||
|
20% {
|
||||||
|
transform: translate3d(calc(var(--shake-x) * -1), var(--shake-y), 0) rotate(calc(var(--shake-rotate) * -1));
|
||||||
|
}
|
||||||
|
32% {
|
||||||
|
transform: translate3d(calc(var(--shake-x) * 0.6), calc(var(--shake-y) * -0.4), 0);
|
||||||
|
}
|
||||||
|
44% {
|
||||||
|
transform: translate3d(0, 0, 0) scale(1.015);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes glow-pulse {
|
||||||
|
0%, 100% {
|
||||||
|
transform: translateZ(-42px) scale(0.96);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateZ(-42px) scale(1.04);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.stat-row {
|
.stat-row {
|
||||||
margin-top: -4px;
|
margin-top: -4px;
|
||||||
}
|
}
|
||||||
@ -477,66 +710,39 @@ footer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.recliner-stage {
|
.recliner-stage {
|
||||||
min-height: 320px;
|
min-height: 360px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-figure {
|
.recliner-figure {
|
||||||
width: 280px;
|
--figure-scale: 0.84;
|
||||||
height: 220px;
|
bottom: 8px;
|
||||||
}
|
|
||||||
|
|
||||||
.recliner-seat {
|
|
||||||
left: 76px;
|
|
||||||
width: 122px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recliner-base {
|
|
||||||
left: 92px;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 575.98px) {
|
@media (max-width: 575.98px) {
|
||||||
.recliner-stage {
|
.recliner-stage {
|
||||||
min-height: 280px;
|
min-height: 300px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.axis-label,
|
||||||
|
.motion-indicator {
|
||||||
|
top: 14px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.motion-indicator {
|
||||||
|
max-width: calc(100% - 128px);
|
||||||
|
text-align: center;
|
||||||
|
line-height: 1.25;
|
||||||
}
|
}
|
||||||
|
|
||||||
.recliner-figure {
|
.recliner-figure {
|
||||||
width: 240px;
|
--figure-scale: 0.66;
|
||||||
height: 200px;
|
bottom: -18px;
|
||||||
bottom: 36px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recliner-seat {
|
|
||||||
left: 58px;
|
|
||||||
bottom: 72px;
|
|
||||||
width: 112px;
|
|
||||||
height: 46px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recliner-back {
|
|
||||||
left: 68px;
|
|
||||||
bottom: 98px;
|
|
||||||
height: 104px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recliner-head {
|
|
||||||
left: 56px;
|
|
||||||
bottom: 170px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recliner-leg {
|
|
||||||
left: 168px;
|
|
||||||
height: 86px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.recliner-base {
|
|
||||||
left: 76px;
|
|
||||||
width: 100px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.angle-indicator {
|
.angle-indicator {
|
||||||
right: 8px;
|
right: 8px;
|
||||||
top: 8px;
|
top: 52px;
|
||||||
padding: 10px 12px;
|
padding: 10px 12px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,6 +14,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
|
|
||||||
const ui = {
|
const ui = {
|
||||||
figure: document.getElementById('recliner-figure'),
|
figure: document.getElementById('recliner-figure'),
|
||||||
|
motion: document.getElementById('recliner-motion'),
|
||||||
|
motionState: document.getElementById('motion-state'),
|
||||||
angleValue: document.getElementById('angle-value'),
|
angleValue: document.getElementById('angle-value'),
|
||||||
anglePill: document.getElementById('angle-pill'),
|
anglePill: document.getElementById('angle-pill'),
|
||||||
intensityPill: document.getElementById('intensity-pill'),
|
intensityPill: document.getElementById('intensity-pill'),
|
||||||
@ -36,6 +38,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
let knownGamepads = [];
|
let knownGamepads = [];
|
||||||
let scanTimer = null;
|
let scanTimer = null;
|
||||||
let continuousVibrationInterval = null;
|
let continuousVibrationInterval = null;
|
||||||
|
let activeActuator = null;
|
||||||
|
|
||||||
const notify = (message) => {
|
const notify = (message) => {
|
||||||
if (!message) {
|
if (!message) {
|
||||||
@ -56,19 +59,71 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return 'Upright';
|
return 'Upright';
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
||||||
|
|
||||||
const currentState = () => ({
|
const currentState = () => ({
|
||||||
angle: Number(controls.angle?.value || 0),
|
angle: Number(controls.angle?.value || 0),
|
||||||
intensity: Number(controls.intensity?.value || 0),
|
intensity: Number(controls.intensity?.value || 0),
|
||||||
pattern: controls.pattern?.value || 'continuous'
|
pattern: controls.pattern?.value || 'continuous'
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const vibrationIsRunning = () => continuousVibrationInterval !== null;
|
||||||
|
|
||||||
|
const updateMotionIndicator = (isActive, pattern, intensity) => {
|
||||||
|
if (!ui.motionState) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!isActive) {
|
||||||
|
ui.motionState.textContent = 'Preview idle';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
ui.motionState.textContent = `${capitalize(pattern)} haptics · ${intensity}%`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncFigureVariables = (state) => {
|
||||||
|
if (!ui.figure) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const driftX = -Math.round(state.angle * 0.22);
|
||||||
|
const driftY = Math.round(state.angle * 0.08);
|
||||||
|
const shakeX = (1.6 + state.intensity * 0.055).toFixed(2);
|
||||||
|
const shakeY = (0.9 + state.intensity * 0.028).toFixed(2);
|
||||||
|
const shakeRotate = (0.28 + state.intensity * 0.008).toFixed(2);
|
||||||
|
const vibeSpeed = Math.max(90, 220 - state.intensity).toFixed(0);
|
||||||
|
const glowStrength = Math.min(0.46, 0.14 + state.intensity / 320).toFixed(2);
|
||||||
|
const glowOpacity = Math.min(0.92, 0.36 + state.intensity / 180).toFixed(2);
|
||||||
|
|
||||||
|
ui.figure.style.setProperty('--angle', String(state.angle));
|
||||||
|
ui.figure.style.setProperty('--intensity', String(state.intensity));
|
||||||
|
ui.figure.style.setProperty('--drift-x', `${driftX}px`);
|
||||||
|
ui.figure.style.setProperty('--drift-y', `${driftY}px`);
|
||||||
|
ui.figure.style.setProperty('--shake-x', `${shakeX}px`);
|
||||||
|
ui.figure.style.setProperty('--shake-y', `${shakeY}px`);
|
||||||
|
ui.figure.style.setProperty('--shake-rotate', `${shakeRotate}deg`);
|
||||||
|
ui.figure.style.setProperty('--vibe-speed', `${vibeSpeed}ms`);
|
||||||
|
ui.figure.style.setProperty('--glow-strength', glowStrength);
|
||||||
|
ui.figure.style.setProperty('--glow-opacity', glowOpacity);
|
||||||
|
ui.figure.dataset.pattern = state.pattern;
|
||||||
|
};
|
||||||
|
|
||||||
|
const setVisualVibrationState = (isActive, state = currentState()) => {
|
||||||
|
syncFigureVariables(state);
|
||||||
|
if (ui.figure) {
|
||||||
|
ui.figure.classList.toggle('is-vibrating', isActive);
|
||||||
|
}
|
||||||
|
if (ui.motion) {
|
||||||
|
ui.motion.classList.toggle('is-vibrating', isActive);
|
||||||
|
ui.motion.classList.toggle('vibration-continuous', isActive && state.pattern === 'continuous');
|
||||||
|
ui.motion.classList.toggle('vibration-pulse', isActive && state.pattern === 'pulse');
|
||||||
|
}
|
||||||
|
updateMotionIndicator(isActive, state.pattern, state.intensity);
|
||||||
|
};
|
||||||
|
|
||||||
const updateVisualization = () => {
|
const updateVisualization = () => {
|
||||||
const state = currentState();
|
const state = currentState();
|
||||||
const tone = presetTone(state.angle);
|
const tone = presetTone(state.angle);
|
||||||
if (ui.figure) {
|
syncFigureVariables(state);
|
||||||
ui.figure.style.setProperty('--angle', String(state.angle));
|
|
||||||
ui.figure.style.setProperty('--intensity', String(state.intensity));
|
|
||||||
}
|
|
||||||
|
|
||||||
const map = {
|
const map = {
|
||||||
angleValue: `${state.angle}`,
|
angleValue: `${state.angle}`,
|
||||||
@ -91,9 +146,13 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
ui[key].textContent = value;
|
ui[key].textContent = value;
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const capitalize = (value) => value.charAt(0).toUpperCase() + value.slice(1);
|
if (!vibrationIsRunning()) {
|
||||||
|
updateMotionIndicator(false, state.pattern, state.intensity);
|
||||||
|
} else {
|
||||||
|
setVisualVibrationState(true, state);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const applyPresetState = (preset) => {
|
const applyPresetState = (preset) => {
|
||||||
if (!preset) return;
|
if (!preset) return;
|
||||||
@ -120,6 +179,33 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return knownGamepads.find((gamepad) => gamepad.index === selectedIndex) || knownGamepads[0];
|
return knownGamepads.find((gamepad) => gamepad.index === selectedIndex) || knownGamepads[0];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopEffect = async (actuator) => {
|
||||||
|
if (!actuator) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (typeof actuator.reset === 'function') {
|
||||||
|
await actuator.reset();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof actuator.playEffect === 'function') {
|
||||||
|
await actuator.playEffect('dual-rumble', {
|
||||||
|
startDelay: 0,
|
||||||
|
duration: 0,
|
||||||
|
weakMagnitude: 0,
|
||||||
|
strongMagnitude: 0
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (typeof actuator.pulse === 'function') {
|
||||||
|
await actuator.pulse(0, 0);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.debug('Unable to reset vibration actuator cleanly.', error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const refreshGamepads = () => {
|
const refreshGamepads = () => {
|
||||||
if (!navigator.getGamepads) {
|
if (!navigator.getGamepads) {
|
||||||
if (ui.gamepadState) {
|
if (ui.gamepadState) {
|
||||||
@ -132,6 +218,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (ui.testButton) {
|
if (ui.testButton) {
|
||||||
ui.testButton.disabled = true;
|
ui.testButton.disabled = true;
|
||||||
}
|
}
|
||||||
|
setVisualVibrationState(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -175,6 +262,16 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (ui.testButton) {
|
if (ui.testButton) {
|
||||||
ui.testButton.disabled = !active || !actuator;
|
ui.testButton.disabled = !active || !actuator;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vibrationIsRunning() && !actuator) {
|
||||||
|
window.clearInterval(continuousVibrationInterval);
|
||||||
|
continuousVibrationInterval = null;
|
||||||
|
activeActuator = null;
|
||||||
|
if (ui.testButton) {
|
||||||
|
ui.testButton.textContent = 'Test vibration (continuous)';
|
||||||
|
}
|
||||||
|
setVisualVibrationState(false);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const playEffect = async (actuator, intensity, duration) => {
|
const playEffect = async (actuator, intensity, duration) => {
|
||||||
@ -192,6 +289,70 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const stopVibration = async ({ silent = false } = {}) => {
|
||||||
|
if (continuousVibrationInterval) {
|
||||||
|
window.clearInterval(continuousVibrationInterval);
|
||||||
|
continuousVibrationInterval = null;
|
||||||
|
}
|
||||||
|
await stopEffect(activeActuator);
|
||||||
|
activeActuator = null;
|
||||||
|
if (ui.testButton) {
|
||||||
|
ui.testButton.textContent = 'Test vibration (continuous)';
|
||||||
|
}
|
||||||
|
setVisualVibrationState(false);
|
||||||
|
if (!silent) {
|
||||||
|
notify('Vibration stopped.');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const startVibration = async (actuator, { silent = false } = {}) => {
|
||||||
|
const state = currentState();
|
||||||
|
const normalizedIntensity = Math.max(0, Math.min(1, state.intensity / 100));
|
||||||
|
|
||||||
|
activeActuator = actuator;
|
||||||
|
if (ui.testButton) {
|
||||||
|
ui.testButton.textContent = 'Stop vibration';
|
||||||
|
}
|
||||||
|
setVisualVibrationState(true, state);
|
||||||
|
|
||||||
|
if (state.pattern === 'pulse') {
|
||||||
|
const pulse = async () => {
|
||||||
|
const live = currentState();
|
||||||
|
setVisualVibrationState(true, live);
|
||||||
|
await playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 500);
|
||||||
|
};
|
||||||
|
await pulse();
|
||||||
|
continuousVibrationInterval = window.setInterval(() => {
|
||||||
|
pulse().catch((error) => console.error(error));
|
||||||
|
}, 800);
|
||||||
|
} else {
|
||||||
|
await playEffect(actuator, normalizedIntensity, 150);
|
||||||
|
continuousVibrationInterval = window.setInterval(() => {
|
||||||
|
const live = currentState();
|
||||||
|
setVisualVibrationState(true, live);
|
||||||
|
playEffect(actuator, Math.max(0, Math.min(1, live.intensity / 100)), 150).catch((error) => console.error(error));
|
||||||
|
}, 100);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!silent) {
|
||||||
|
notify(`Running ${state.pattern} vibration at ${state.intensity}%.`);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const restartVibration = async () => {
|
||||||
|
if (!vibrationIsRunning()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const gamepad = selectedGamepad();
|
||||||
|
const actuator = getActuator(gamepad);
|
||||||
|
if (!gamepad || !actuator) {
|
||||||
|
await stopVibration({ silent: true });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
await stopVibration({ silent: true });
|
||||||
|
await startVibration(actuator, { silent: true });
|
||||||
|
};
|
||||||
|
|
||||||
const runVibration = async () => {
|
const runVibration = async () => {
|
||||||
const gamepad = selectedGamepad();
|
const gamepad = selectedGamepad();
|
||||||
const actuator = getActuator(gamepad);
|
const actuator = getActuator(gamepad);
|
||||||
@ -201,38 +362,17 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { intensity, pattern } = currentState();
|
if (vibrationIsRunning()) {
|
||||||
const normalizedIntensity = Math.max(0, Math.min(1, intensity / 100));
|
await stopVibration();
|
||||||
|
|
||||||
if (continuousVibrationInterval) {
|
|
||||||
window.clearInterval(continuousVibrationInterval);
|
|
||||||
continuousVibrationInterval = null;
|
|
||||||
ui.testButton.textContent = 'Test vibration (continuous)';
|
|
||||||
notify('Vibration stopped.');
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
ui.testButton.textContent = 'Stop vibration';
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (pattern === 'pulse') {
|
await startVibration(actuator);
|
||||||
const pulse = async () => {
|
|
||||||
await playEffect(actuator, normalizedIntensity, 500);
|
|
||||||
};
|
|
||||||
pulse();
|
|
||||||
continuousVibrationInterval = window.setInterval(pulse, 800);
|
|
||||||
} else {
|
|
||||||
// For continuous, we re-trigger every 100ms
|
|
||||||
await playEffect(actuator, normalizedIntensity, 150);
|
|
||||||
continuousVibrationInterval = window.setInterval(async () => {
|
|
||||||
await playEffect(actuator, normalizedIntensity, 150);
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
notify(`Running ${pattern} vibration at ${intensity}%.`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(error);
|
console.error(error);
|
||||||
|
await stopVibration({ silent: true });
|
||||||
notify('The browser detected the controller, but vibration could not start.');
|
notify('The browser detected the controller, but vibration could not start.');
|
||||||
ui.testButton.textContent = 'Test vibration (continuous)';
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@ -253,7 +393,9 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (ui.testButton) {
|
if (ui.testButton) {
|
||||||
ui.testButton.addEventListener('click', runVibration);
|
ui.testButton.addEventListener('click', () => {
|
||||||
|
runVibration().catch((error) => console.error(error));
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (ui.resetButton) {
|
if (ui.resetButton) {
|
||||||
@ -263,13 +405,24 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[controls.angle, controls.intensity, controls.pattern].forEach((element) => {
|
[controls.angle, controls.intensity].forEach((element) => {
|
||||||
if (element) {
|
if (element) {
|
||||||
element.addEventListener('input', updateVisualization);
|
element.addEventListener('input', updateVisualization);
|
||||||
element.addEventListener('change', updateVisualization);
|
element.addEventListener('change', updateVisualization);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if (controls.pattern) {
|
||||||
|
controls.pattern.addEventListener('input', () => {
|
||||||
|
updateVisualization();
|
||||||
|
restartVibration().catch((error) => console.error(error));
|
||||||
|
});
|
||||||
|
controls.pattern.addEventListener('change', () => {
|
||||||
|
updateVisualization();
|
||||||
|
restartVibration().catch((error) => console.error(error));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
if (ui.gamepadSelect) {
|
if (ui.gamepadSelect) {
|
||||||
ui.gamepadSelect.addEventListener('change', refreshGamepads);
|
ui.gamepadSelect.addEventListener('change', refreshGamepads);
|
||||||
}
|
}
|
||||||
@ -304,5 +457,6 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (continuousVibrationInterval) {
|
if (continuousVibrationInterval) {
|
||||||
window.clearInterval(continuousVibrationInterval);
|
window.clearInterval(continuousVibrationInterval);
|
||||||
}
|
}
|
||||||
|
stopEffect(activeActuator).catch(() => {});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
33
index.php
33
index.php
@ -139,24 +139,37 @@ if (isset($_GET['saved']) && ctype_digit((string) $_GET['saved'])) {
|
|||||||
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
<div class="d-flex flex-wrap justify-content-between align-items-start gap-3 mb-4">
|
||||||
<div>
|
<div>
|
||||||
<div class="small-label">Visualizer</div>
|
<div class="small-label">Visualizer</div>
|
||||||
<h2 class="h4 mb-1">Live recline preview</h2>
|
<h2 class="h4 mb-1">Live 3D recline preview</h2>
|
||||||
<p class="text-secondary mb-0">A restrained 2D profile that updates instantly as you move the angle and intensity controls.</p>
|
<p class="text-secondary mb-0">A dimensional lounge model that tilts with the angle control and physically shakes in sync while the vibration test is running.</p>
|
||||||
</div>
|
</div>
|
||||||
<div class="badge badge-soft" id="recline-mode"><?= e(preset_tone((int) $formData['angle_deg'])) ?></div>
|
<div class="badge badge-soft" id="recline-mode"><?= e(preset_tone((int) $formData['angle_deg'])) ?></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="recliner-stage mb-4">
|
<div class="recliner-stage mb-4">
|
||||||
<div class="grid-fade"></div>
|
<div class="grid-fade"></div>
|
||||||
|
<div class="recliner-aurora" aria-hidden="true"></div>
|
||||||
<div class="axis-label axis-label-left">upright</div>
|
<div class="axis-label axis-label-left">upright</div>
|
||||||
<div class="axis-label axis-label-right">full recline</div>
|
<div class="axis-label axis-label-right">full recline</div>
|
||||||
<div class="recliner-figure" id="recliner-figure" style="--angle: <?= e((string) $formData['angle_deg']) ?>; --intensity: <?= e((string) $formData['intensity_pct']) ?>;">
|
<div class="motion-indicator" id="motion-state">Preview idle</div>
|
||||||
<div class="recliner-shadow"></div>
|
<div class="recliner-figure" id="recliner-figure" role="img" aria-label="3D recliner preview responding to angle and vibration settings" data-pattern="<?= e($formData['pattern_mode']) ?>" style="--angle: <?= e((string) $formData['angle_deg']) ?>; --intensity: <?= e((string) $formData['intensity_pct']) ?>;">
|
||||||
<div class="recliner-seat"></div>
|
<div class="recliner-rig" aria-hidden="true">
|
||||||
<div class="recliner-arm"></div>
|
<div class="recliner-motion" id="recliner-motion">
|
||||||
<div class="recliner-back" id="recliner-back"></div>
|
<div class="recliner-glow"></div>
|
||||||
<div class="recliner-head"></div>
|
<div class="recliner-floor"></div>
|
||||||
<div class="recliner-leg" id="recliner-leg"></div>
|
<div class="recliner-plinth recliner-block"></div>
|
||||||
<div class="recliner-base"></div>
|
<div class="recliner-column recliner-block"></div>
|
||||||
|
<div class="recliner-base recliner-block"></div>
|
||||||
|
<div class="recliner-seat recliner-block"></div>
|
||||||
|
<div class="recliner-seat-pad"></div>
|
||||||
|
<div class="recliner-arm recliner-block"></div>
|
||||||
|
<div class="recliner-arm recliner-arm-secondary recliner-block"></div>
|
||||||
|
<div class="recliner-back recliner-block"></div>
|
||||||
|
<div class="recliner-back-inner"></div>
|
||||||
|
<div class="recliner-head recliner-block"></div>
|
||||||
|
<div class="recliner-leg recliner-block"></div>
|
||||||
|
<div class="recliner-footpad recliner-block"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="angle-indicator"><span id="angle-value"><?= e((string) $formData['angle_deg']) ?></span><small>degrees</small></div>
|
<div class="angle-indicator"><span id="angle-value"><?= e((string) $formData['angle_deg']) ?></span><small>degrees</small></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user