bodev.vn

Lắp ráp căn phòng

Phòng isometric dựng dần theo cuộn: sàn, tường, đồ đạc rơi vào chỗ, cuối cùng bật đèn.

Cách hoạt động

  1. 1.Cả căn phòng vẽ bằng SVG từ JS. Hàm box(x, y, z, rộng, sâu, cao, màu) dựng một khối hộp 3 mặt, sofa hay bàn chỉ là vài khối ghép lại.
  2. 2.Mỗi nhóm đồ có data-step. Timeline cho từng bước hiện ra, ScrollTrigger gắn timeline vào thanh cuộn (scrub).
  3. 3.Bước cuối phủ một lớp tối lên phòng rồi bật quầng sáng quanh đèn, nhìn như chuyển sang buổi tối.

Thử chỉnh

Thêm đồ nội thất
Tạo group(bước) rồi gọi box(...) với toạ độ trong phòng 10×10. Vẽ đồ ở trong trước, ngoài sau.
Đổi màu sàn, tường
Sửa mã màu trong phần "2. Căn phòng" của file JS.
Cuộn lâu hơn mới dựng xong
Tăng end: "+=2600" trong ScrollTrigger.

Code tham khảo

<!doctype html>
<html lang="vi">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Lắp ráp căn phòng · bodev.vn</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Be+Vietnam+Pro:wght@400;500;700;800&display=swap" rel="stylesheet" />
<style>
:root {
  --bg: #efe9df;
  --text: #2a2622;
  --muted: #8a8177;
  --accent: #b5643c;
}

* { box-sizing: border-box; }
body {
  margin: 0;
  font-family: "Be Vietnam Pro", system-ui, sans-serif;
  background: var(--bg);
  color: var(--text);
}

.intro,
.outro {
  min-height: 70vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 40px clamp(20px, 6vw, 80px);
}
.kicker { margin: 0; color: var(--accent); font-size: 13px; letter-spacing: 0.12em; text-transform: uppercase; }
.intro h1 { max-width: 12ch; margin: 10px 0 0; font-size: clamp(38px, 6.5vw, 80px); font-weight: 800; line-height: 1; }
.lead { margin: 16px 0 0; color: var(--muted); }

.build {
  background: var(--bg);
  height: 100vh;
  display: grid;
  grid-template-columns: minmax(180px, 280px) 1fr;
  align-items: center;
  gap: 24px;
  padding: 24px clamp(20px, 5vw, 64px);
  overflow: hidden;
}
.steps { margin: 0; padding: 0; list-style: none; display: grid; gap: 14px; }
.step { font-size: 18px; font-weight: 500; opacity: 0.3; }
.step b { display: inline-block; width: 2.4em; color: var(--accent); }

.room { width: 100%; height: 100%; max-height: 88vh; overflow: hidden; }

.outro { align-items: flex-start; }
.outro h2 { margin: 0 0 24px; font-size: clamp(28px, 4vw, 48px); }
.btn {
  /* CTA trong demo là <button>, không gắn link. Nối vào form hoặc popup của bạn. */
  border: 0;
  font: inherit;
  cursor: pointer;
  display: inline-block;
  padding: 14px 26px;
  border-radius: 999px;
  background: var(--accent);
  color: #fff;
  font-weight: 700;
  text-decoration: none;
}

@media (max-width: 760px) {
  .build { grid-template-columns: 1fr; grid-template-rows: auto 1fr; }
  .steps { grid-auto-flow: column; overflow: hidden; gap: 10px; }
  .step { font-size: 13px; white-space: nowrap; }
  .step b { width: auto; margin-right: 4px; }
}
</style>
</head>
<body>
<section class="intro">
  <p class="kicker">Nội thất · Phòng khách 25m²</p>
  <h1>Từ căn phòng trống tới lúc bật đèn</h1>
  <p class="lead">Cuộn xuống để dựng phòng.</p>
</section>

<section class="build">
  <ol class="steps">
    <li class="step" data-step="0"><b>01</b> Sàn gỗ sồi</li>
    <li class="step" data-step="1"><b>02</b> Tường, cửa sổ</li>
    <li class="step" data-step="2"><b>03</b> Sofa, thảm, bàn</li>
    <li class="step" data-step="3"><b>04</b> Kệ, cây, đèn</li>
    <li class="step" data-step="4"><b>05</b> Bật đèn</li>
  </ol>
  <!-- JS vẽ căn phòng vào đây -->
  <svg class="room" viewBox="-260 -215 520 510" role="img" aria-label="Phòng khách dựng dần theo cuộn"></svg>
</section>

<section class="outro">
  <h2>Muốn thấy phòng mình trông thế nào?</h2>
  <button type="button" class="btn">Đặt lịch khảo sát miễn phí</button>
</section>

<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/gsap.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/gsap@3.15.0/dist/ScrollTrigger.min.js"></script>
<script>
// Lắp ráp căn phòng — bodev.vn/lab/lap-rap-can-phong
// Cần: gsap + ScrollTrigger.

gsap.registerPlugin(ScrollTrigger);

// ---------- 1. Vẽ isometric ----------
// Toạ độ phòng: x, y trên sàn (0 → 10), z là chiều cao. Mỗi đơn vị = 26px.
const UNIT = 26;
const COS = Math.cos(Math.PI / 6);
const SIN = Math.sin(Math.PI / 6);
const iso = (x, y, z = 0) => [(x - y) * COS * UNIT, (x + y) * SIN * UNIT - z * UNIT];
const pts = (...list) => list.map((p) => iso(...p).map((n) => n.toFixed(1)).join(",")).join(" ");

const NS = "http://www.w3.org/2000/svg";
const svg = document.querySelector(".room");
function el(tag, attrs, parent = svg) {
  const node = document.createElementNS(NS, tag);
  for (const [k, v] of Object.entries(attrs)) node.setAttribute(k, v);
  parent.appendChild(node);
  return node;
}

// Làm tối màu một chút cho mặt bên, để khối có chiều sâu.
function shade(hex, amount) {
  const n = parseInt(hex.slice(1), 16);
  const f = (c) => Math.round(c * (1 - amount));
  return `rgb(${f(n >> 16)}, ${f((n >> 8) & 255)}, ${f(n & 255)})`;
}

// Một khối hộp: vẽ 3 mặt nhìn thấy (trên, phải, trước).
function box(group, x, y, z, w, d, h, color) {
  el("polygon", { points: pts([x + w, y, z], [x + w, y + d, z], [x + w, y + d, z + h], [x + w, y, z + h]), fill: shade(color, 0.12) }, group);
  el("polygon", { points: pts([x, y + d, z], [x + w, y + d, z], [x + w, y + d, z + h], [x, y + d, z + h]), fill: shade(color, 0.25) }, group);
  el("polygon", { points: pts([x, y, z + h], [x + w, y, z + h], [x + w, y + d, z + h], [x, y + d, z + h]), fill: color }, group);
}

// Mọi thứ trừ cây đèn nằm trong scene, để lúc tối chỉ làm tối scene, đèn vẫn sáng.
const scene = el("g", { class: "scene" });
const group = (step, parent = scene) => el("g", { class: "piece", "data-step": step }, parent);

// ---------- 2. Căn phòng ----------
const ROOM = 10;
const WALL = 7;

const floor = group(0);
el("polygon", { points: pts([0, 0], [ROOM, 0], [ROOM, ROOM], [0, ROOM]), fill: "#d6b48a" }, floor);
for (let i = 1; i < ROOM; i++) {
  el("polyline", { points: pts([i, 0], [i, ROOM]), stroke: "#c9a67b", "stroke-width": 1, fill: "none" }, floor);
}

const walls = group(1);
el("polygon", { points: pts([0, 0, 0], [0, ROOM, 0], [0, ROOM, WALL], [0, 0, WALL]), fill: "#e9e2d6" }, walls);
el("polygon", { points: pts([0, 0, 0], [ROOM, 0, 0], [ROOM, 0, WALL], [0, 0, WALL]), fill: "#f4efe7" }, walls);
// Cửa sổ trên tường phải, tranh trên tường trái.
el("polygon", { points: pts([1.8, 0, 2.6], [4.6, 0, 2.6], [4.6, 0, 6], [1.8, 0, 6]), fill: "#bcd3dd" }, walls);
el("polyline", { points: pts([3.2, 0, 2.6], [3.2, 0, 6]), stroke: "#fff", "stroke-width": 3, fill: "none" }, walls);
el("polygon", { points: pts([0, 3.2, 3.4], [0, 6.2, 3.4], [0, 6.2, 5.4], [0, 3.2, 5.4]), fill: "#b5643c" }, walls);
el("polygon", { points: pts([0, 3.6, 3.8], [0, 5.8, 3.8], [0, 5.8, 5], [0, 3.6, 5]), fill: "#e8b27c" }, walls);

// Đồ vẽ từ trong ra ngoài (x + y nhỏ trước) để món trước che món sau cho đúng.
const rug = group(2);
box(rug, 2.4, 3, 0, 5, 4.6, 0.06, "#9fae9a");

const sofa = group(2);
box(sofa, 0.3, 2.4, 0, 1.8, 5, 1.1, "#5f7d8c");
box(sofa, 0.3, 2.4, 1.1, 0.6, 5, 1.3, "#5f7d8c");
box(sofa, 0.3, 2.4, 1.1, 1.8, 0.5, 0.6, "#56727f");
box(sofa, 0.3, 6.9, 1.1, 1.8, 0.5, 0.6, "#56727f");
box(sofa, 0.95, 3.1, 1.1, 0.35, 1, 0.8, "#e8b27c");

const table = group(2);
box(table, 3.6, 4.2, 0, 0.2, 0.2, 0.8, "#6b4a33");
box(table, 5.4, 5.9, 0, 0.2, 0.2, 0.8, "#6b4a33");
box(table, 3.4, 4, 0.8, 2.4, 2.2, 0.18, "#8a6244");

const shelf = group(3);
box(shelf, 5.2, 0.1, 0, 3.6, 1.1, 2.2, "#8a6244");
box(shelf, 5.6, 0.3, 2.2, 0.7, 0.6, 0.9, "#b5643c");
box(shelf, 7.4, 0.3, 2.2, 0.5, 0.5, 1.4, "#e9e2d6");

const plant = group(3);
box(plant, 8.9, 1.6, 0, 0.8, 0.8, 0.9, "#c9b8a3");
const [px, py] = iso(9.3, 2, 2.1);
el("ellipse", { cx: px, cy: py, rx: 26, ry: 34, fill: "#5d7f55" }, plant);
el("ellipse", { cx: px - 14, cy: py + 10, rx: 16, ry: 22, fill: "#6f9464" }, plant);

const lamp = group(3, svg);
box(lamp, 0.7, 8.4, 0, 0.9, 0.9, 0.15, "#2a2622");
box(lamp, 1.08, 8.78, 0.15, 0.14, 0.14, 4.6, "#2a2622");
box(lamp, 0.55, 8.25, 4.6, 1.2, 1.2, 1, "#f1e4c8");

// Lớp tối (buổi tối) và quầng sáng của đèn: hiện ở bước cuối.
const [lx, ly] = iso(1.15, 8.85, 4.2);
const defs = el("defs", {});
const glowGrad = el("radialGradient", { id: "glow" }, defs);
el("stop", { offset: "0%", "stop-color": "#ffd98a", "stop-opacity": 0.9 }, glowGrad);
el("stop", { offset: "100%", "stop-color": "#ffd98a", "stop-opacity": 0 }, glowGrad);
const glow = el("circle", { cx: lx, cy: ly, r: 150, fill: "url(#glow)", opacity: 0, class: "glow" });
svg.appendChild(lamp); // đèn vẽ sau cùng, nằm ngoài scene

// ---------- 3. Cuộn để dựng ----------
const steps = document.querySelectorAll(".step");
const byStep = (n) => svg.querySelectorAll(`.piece[data-step="${n}"]`);

function highlight(tl, n, at) {
  tl.to(steps, { opacity: 0.3, x: 0, duration: 0.2 }, at).to(steps[n], { opacity: 1, x: 8, duration: 0.2 }, at);
}

const tl = gsap.timeline({ defaults: { ease: "power3.out", duration: 1 } });

highlight(tl, 0, 0);
tl.from(byStep(0), { scale: 0, transformOrigin: "50% 50%", ease: "back.out(1.4)" }, 0);

highlight(tl, 1, 1);
tl.from(byStep(1), { y: 120, opacity: 0 }, 1);

highlight(tl, 2, 2);
// Từng món rơi từ trên xuống, lần lượt.
tl.from(byStep(2), { y: -260, opacity: 0, stagger: 0.35, ease: "bounce.out" }, 2);

highlight(tl, 3, 3.4);
tl.from(byStep(3), { y: -260, opacity: 0, stagger: 0.35, ease: "bounce.out" }, 3.4);

highlight(tl, 4, 4.8);
// Buổi tối: scene giảm sáng, nền section tối theo. Không dùng lớp phủ nên không lộ viền khung SVG.
const DUSK = 0.45;
const bg = getComputedStyle(document.documentElement).getPropertyValue("--bg").trim();
const duskBg = gsap.utils.interpolate(bg, "#1d1a2b", DUSK);
tl.fromTo(scene, { filter: "brightness(1)" }, { filter: `brightness(${1 - DUSK})` }, 4.8).to(".build", { backgroundColor: duskBg }, 4.8).to(glow, { opacity: 1 }, 5.1);

if (matchMedia("(prefers-reduced-motion: reduce)").matches) {
  tl.progress(1);
} else {
  ScrollTrigger.create({
    trigger: ".build",
    start: "top top",
    end: "+=2600",
    pin: true,
    scrub: 1,
    animation: tl,
  });
}

gsap.from(".intro > *", { opacity: 0, y: 30, duration: 0.8, stagger: 0.1, ease: "expo.out" });
</script>
</body>
</html>

Code để học cách làm, không phải thư viện. Không đảm bảo chạy ngay trên site khác, dùng thì tự kiểm tra.

Tiếp trong Nội thấtĐi xuyên cửa