Created
July 11, 2026 14:14
-
-
Save senko/fd521362f55a0ff5396228377e2ee204 to your computer and use it in GitHub Desktop.
Minecraft clone by GPT-5.5 Sol
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>Solarcraft — Tiny Voxel Adventure</title> | |
| <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='4' fill='%23101a2a'/%3E%3Cpath d='M16 3 29 16 16 29 3 16Z' fill='%23ffd35a'/%3E%3Cpath d='m16 8 8 8-8 8-8-8Z' fill='%23f3a72a'/%3E%3C/svg%3E" /> | |
| <style> | |
| :root { | |
| --ink: #f8fbff; | |
| --muted: #c4cddd; | |
| --panel: rgba(9, 15, 25, 0.78); | |
| --panel-solid: #101a2a; | |
| --line: rgba(255, 255, 255, 0.14); | |
| --sun: #ffd35a; | |
| --green: #72e58c; | |
| --red: #ff6377; | |
| --cyan: #62d8ff; | |
| --shadow: 0 18px 50px rgba(0, 0, 0, 0.38); | |
| } | |
| * { box-sizing: border-box; } | |
| html, body { | |
| width: 100%; | |
| height: 100%; | |
| margin: 0; | |
| overflow: hidden; | |
| background: #08101d; | |
| color: var(--ink); | |
| font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; | |
| user-select: none; | |
| } | |
| button, input { font: inherit; } | |
| #game { | |
| position: fixed; | |
| inset: 0; | |
| background: radial-gradient(circle at 50% 35%, #9fe3ff 0, #548fc0 48%, #15253d 100%); | |
| } | |
| canvas { | |
| position: absolute; | |
| inset: 0; | |
| width: 100%; | |
| height: 100%; | |
| display: block; | |
| image-rendering: pixelated; | |
| outline: none; | |
| } | |
| #hud { | |
| position: absolute; | |
| inset: 0; | |
| pointer-events: none; | |
| z-index: 4; | |
| opacity: 0; | |
| transition: opacity 260ms ease; | |
| } | |
| #hud.visible { opacity: 1; } | |
| .glass { | |
| border: 1px solid var(--line); | |
| background: linear-gradient(145deg, rgba(20, 31, 49, 0.88), rgba(8, 14, 24, 0.74)); | |
| box-shadow: var(--shadow), inset 0 1px rgba(255, 255, 255, 0.05); | |
| backdrop-filter: blur(12px); | |
| -webkit-backdrop-filter: blur(12px); | |
| } | |
| #brand { | |
| position: absolute; | |
| top: 22px; | |
| left: 24px; | |
| display: flex; | |
| align-items: center; | |
| gap: 12px; | |
| text-shadow: 0 2px 8px rgba(0, 0, 0, 0.45); | |
| } | |
| .brand-mark { | |
| width: 39px; | |
| height: 39px; | |
| position: relative; | |
| transform: rotate(45deg); | |
| border: 2px solid rgba(255, 255, 255, 0.8); | |
| background: linear-gradient(135deg, #fff5ae, #ffb527 65%, #e86f21); | |
| box-shadow: 0 0 25px rgba(255, 197, 66, 0.45); | |
| } | |
| .brand-mark::after { | |
| content: ""; | |
| position: absolute; | |
| inset: 8px; | |
| border: 2px solid rgba(75, 36, 9, 0.48); | |
| } | |
| .brand-copy strong { | |
| display: block; | |
| font-size: 18px; | |
| line-height: 1; | |
| letter-spacing: 0.18em; | |
| font-weight: 900; | |
| } | |
| .brand-copy span { | |
| display: block; | |
| margin-top: 5px; | |
| color: rgba(255, 255, 255, 0.7); | |
| font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.16em; | |
| text-transform: uppercase; | |
| } | |
| #world-info { | |
| position: absolute; | |
| left: 24px; | |
| top: 84px; | |
| display: flex; | |
| gap: 7px; | |
| } | |
| .chip { | |
| padding: 7px 10px; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| border-radius: 4px; | |
| background: rgba(7, 12, 20, 0.56); | |
| color: #dce7f8; | |
| font: 700 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.06em; | |
| box-shadow: 0 5px 18px rgba(0, 0, 0, 0.2); | |
| } | |
| #mission { | |
| position: absolute; | |
| top: 22px; | |
| right: 24px; | |
| width: min(310px, calc(100vw - 48px)); | |
| padding: 15px 16px 14px; | |
| border-radius: 6px; | |
| } | |
| .eyebrow { | |
| color: var(--sun); | |
| font: 800 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.2em; | |
| text-transform: uppercase; | |
| } | |
| #mission-title { | |
| margin-top: 8px; | |
| font-weight: 850; | |
| font-size: 15px; | |
| letter-spacing: -0.01em; | |
| } | |
| #mission-copy { | |
| margin-top: 5px; | |
| color: var(--muted); | |
| font-size: 11px; | |
| line-height: 1.5; | |
| } | |
| #mission-meter { | |
| height: 4px; | |
| margin-top: 12px; | |
| overflow: hidden; | |
| border-radius: 99px; | |
| background: rgba(255, 255, 255, 0.1); | |
| } | |
| #mission-meter > span { | |
| display: block; | |
| width: 0%; | |
| height: 100%; | |
| background: linear-gradient(90deg, #ffd35a, #79e38c); | |
| box-shadow: 0 0 12px #ffd35a; | |
| transition: width 300ms ease; | |
| } | |
| #crosshair { | |
| position: absolute; | |
| left: 50%; | |
| top: 50%; | |
| width: 22px; | |
| height: 22px; | |
| transform: translate(-50%, -50%); | |
| filter: drop-shadow(0 1px 2px rgba(0, 0, 0, 0.8)); | |
| transition: transform 100ms ease, filter 100ms ease; | |
| } | |
| #crosshair::before, #crosshair::after { | |
| content: ""; | |
| position: absolute; | |
| background: rgba(255, 255, 255, 0.92); | |
| border-radius: 1px; | |
| } | |
| #crosshair::before { width: 2px; height: 18px; left: 10px; top: 2px; } | |
| #crosshair::after { width: 18px; height: 2px; left: 2px; top: 10px; } | |
| #crosshair.hostile { transform: translate(-50%, -50%) rotate(45deg) scale(1.15); filter: drop-shadow(0 0 5px var(--red)); } | |
| #crosshair.hostile::before, #crosshair.hostile::after { background: #ff7f8f; } | |
| #target-label { | |
| position: absolute; | |
| left: 50%; | |
| top: calc(50% + 25px); | |
| transform: translateX(-50%); | |
| min-width: 120px; | |
| color: rgba(255, 255, 255, 0.86); | |
| text-align: center; | |
| text-shadow: 0 2px 5px #000; | |
| font: 800 10px/1.2 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| } | |
| #mine-wrap { | |
| position: absolute; | |
| left: 50%; | |
| top: calc(50% + 43px); | |
| width: 108px; | |
| height: 5px; | |
| transform: translateX(-50%); | |
| overflow: hidden; | |
| border-radius: 10px; | |
| background: rgba(0, 0, 0, 0.48); | |
| opacity: 0; | |
| transition: opacity 100ms; | |
| } | |
| #mine-wrap.active { opacity: 1; } | |
| #mine-fill { width: 0; height: 100%; background: linear-gradient(90deg, var(--cyan), white); box-shadow: 0 0 7px var(--cyan); } | |
| #vitals { | |
| position: absolute; | |
| left: 24px; | |
| bottom: 26px; | |
| display: flex; | |
| align-items: flex-end; | |
| gap: 12px; | |
| } | |
| #hearts { display: flex; gap: 5px; } | |
| .heart { | |
| position: relative; | |
| width: 22px; | |
| height: 20px; | |
| color: #ff5a6e; | |
| font-size: 22px; | |
| line-height: 20px; | |
| text-shadow: 0 2px 0 #791f2e, 0 0 9px rgba(255, 75, 100, 0.5); | |
| transition: transform 120ms, filter 120ms; | |
| } | |
| .heart.empty { color: rgba(255, 255, 255, 0.15); text-shadow: 0 1px 2px #000; } | |
| .heart.half { color: rgba(255, 255, 255, 0.15); text-shadow: 0 1px 2px #000; } | |
| .heart.half::after { content: "♥"; position: absolute; left: 0; width: 52%; overflow: hidden; color: #ff5a6e; text-shadow: 0 2px 0 #791f2e; } | |
| .vital-caption { | |
| margin-top: 8px; | |
| color: rgba(255, 255, 255, 0.55); | |
| font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.14em; | |
| text-transform: uppercase; | |
| } | |
| #resource-strip { | |
| display: flex; | |
| gap: 7px; | |
| margin-bottom: -1px; | |
| } | |
| .resource { | |
| min-width: 52px; | |
| padding: 7px 9px; | |
| border: 1px solid rgba(255, 255, 255, 0.12); | |
| border-radius: 4px; | |
| background: rgba(6, 11, 19, 0.66); | |
| color: rgba(255, 255, 255, 0.6); | |
| font: 750 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.06em; | |
| } | |
| .resource b { margin-left: 5px; color: white; font-size: 12px; } | |
| #hotbar-wrap { | |
| position: absolute; | |
| left: 50%; | |
| bottom: 22px; | |
| transform: translateX(-50%); | |
| } | |
| #hotbar { | |
| display: flex; | |
| align-items: end; | |
| gap: 5px; | |
| padding: 6px; | |
| border-radius: 7px; | |
| } | |
| .slot { | |
| position: relative; | |
| width: 54px; | |
| height: 54px; | |
| border: 1px solid rgba(255, 255, 255, 0.15); | |
| border-radius: 4px; | |
| background: rgba(2, 7, 13, 0.54); | |
| box-shadow: inset 0 0 18px rgba(0, 0, 0, 0.4); | |
| transition: transform 120ms, border 120ms, background 120ms; | |
| } | |
| .slot.selected { | |
| transform: translateY(-7px); | |
| border-color: #fff0a8; | |
| background: rgba(255, 207, 74, 0.13); | |
| box-shadow: 0 0 0 2px rgba(255, 211, 90, 0.25), 0 9px 20px rgba(0, 0, 0, 0.38), inset 0 0 18px rgba(255, 196, 49, 0.11); | |
| } | |
| .slot-key { | |
| position: absolute; | |
| left: 5px; | |
| top: 4px; | |
| z-index: 2; | |
| color: rgba(255, 255, 255, 0.54); | |
| font: 700 9px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| } | |
| .slot-icon { | |
| position: absolute; | |
| width: 28px; | |
| height: 28px; | |
| left: 13px; | |
| top: 12px; | |
| border: 2px solid rgba(255, 255, 255, 0.16); | |
| box-shadow: inset -5px -6px rgba(0, 0, 0, 0.14), 0 4px 5px rgba(0, 0, 0, 0.35); | |
| transform: rotate(-3deg); | |
| image-rendering: pixelated; | |
| } | |
| .slot-count { | |
| position: absolute; | |
| right: 5px; | |
| bottom: 3px; | |
| z-index: 2; | |
| color: white; | |
| font: 900 12px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| text-shadow: 0 2px 2px #000; | |
| } | |
| #slot-name { | |
| position: absolute; | |
| left: 50%; | |
| bottom: 68px; | |
| transform: translateX(-50%); | |
| color: white; | |
| white-space: nowrap; | |
| text-shadow: 0 2px 5px #000; | |
| font: 800 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.12em; | |
| text-transform: uppercase; | |
| } | |
| #help { | |
| position: absolute; | |
| right: 24px; | |
| bottom: 24px; | |
| padding: 10px 12px; | |
| border-radius: 5px; | |
| color: rgba(255, 255, 255, 0.62); | |
| font: 700 9px/1.75 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.05em; | |
| text-align: right; | |
| } | |
| #help b { color: white; } | |
| #toast-stack { | |
| position: absolute; | |
| left: 50%; | |
| top: 18%; | |
| width: min(390px, calc(100vw - 40px)); | |
| transform: translateX(-50%); | |
| display: flex; | |
| flex-direction: column; | |
| align-items: center; | |
| gap: 8px; | |
| } | |
| .toast { | |
| padding: 10px 14px; | |
| border: 1px solid rgba(255, 255, 255, 0.16); | |
| border-radius: 4px; | |
| background: rgba(7, 13, 22, 0.86); | |
| box-shadow: 0 12px 30px rgba(0, 0, 0, 0.35); | |
| color: white; | |
| font: 750 11px/1.3 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.04em; | |
| animation: toast-in 250ms ease both, toast-out 350ms ease 2.45s both; | |
| } | |
| @keyframes toast-in { from { opacity: 0; transform: translateY(-8px) scale(0.96); } } | |
| @keyframes toast-out { to { opacity: 0; transform: translateY(-5px) scale(0.98); } } | |
| #damage-flash { | |
| position: absolute; | |
| inset: 0; | |
| opacity: 0; | |
| background: radial-gradient(circle, transparent 35%, rgba(255, 24, 58, 0.56) 110%); | |
| mix-blend-mode: screen; | |
| transition: opacity 420ms ease; | |
| } | |
| #damage-flash.hit { opacity: 1; transition: none; } | |
| .overlay { | |
| position: absolute; | |
| inset: 0; | |
| z-index: 10; | |
| display: none; | |
| align-items: center; | |
| justify-content: center; | |
| padding: 24px; | |
| background: radial-gradient(circle at 50% 30%, rgba(31, 61, 89, 0.25), rgba(2, 6, 12, 0.82)); | |
| backdrop-filter: blur(8px); | |
| -webkit-backdrop-filter: blur(8px); | |
| } | |
| .overlay.active { display: flex; animation: overlay-in 220ms ease both; } | |
| @keyframes overlay-in { from { opacity: 0; } } | |
| .menu-card { | |
| position: relative; | |
| width: min(620px, 100%); | |
| padding: 46px 48px 42px; | |
| overflow: hidden; | |
| border: 1px solid rgba(255, 255, 255, 0.16); | |
| border-radius: 8px; | |
| background: linear-gradient(145deg, rgba(19, 31, 49, 0.97), rgba(6, 11, 20, 0.98)); | |
| box-shadow: 0 36px 100px rgba(0, 0, 0, 0.58), inset 0 1px rgba(255, 255, 255, 0.06); | |
| } | |
| .menu-card::before { | |
| content: ""; | |
| position: absolute; | |
| width: 260px; | |
| height: 260px; | |
| right: -120px; | |
| top: -130px; | |
| transform: rotate(18deg); | |
| background: repeating-linear-gradient(90deg, rgba(255, 210, 75, 0.08) 0 16px, transparent 16px 31px); | |
| border: 1px solid rgba(255, 211, 90, 0.14); | |
| } | |
| .menu-kicker { | |
| color: var(--sun); | |
| font: 850 10px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.25em; | |
| text-transform: uppercase; | |
| } | |
| .menu-title { | |
| margin: 12px 0 0; | |
| font-size: clamp(48px, 9vw, 76px); | |
| line-height: 0.88; | |
| letter-spacing: -0.065em; | |
| font-weight: 950; | |
| text-transform: uppercase; | |
| text-shadow: 0 6px 0 rgba(0, 0, 0, 0.22); | |
| } | |
| .menu-title em { color: var(--sun); font-style: normal; } | |
| .menu-subtitle { | |
| max-width: 470px; | |
| margin: 20px 0 0; | |
| color: #afbdce; | |
| font-size: 14px; | |
| line-height: 1.65; | |
| } | |
| .menu-actions { | |
| display: flex; | |
| align-items: center; | |
| gap: 18px; | |
| margin-top: 30px; | |
| } | |
| .primary-button { | |
| position: relative; | |
| min-width: 170px; | |
| padding: 14px 21px; | |
| border: 0; | |
| border-radius: 4px; | |
| outline: none; | |
| background: linear-gradient(180deg, #ffe171, #f3ad2a); | |
| color: #2b1a05; | |
| box-shadow: 0 5px 0 #995f15, 0 12px 26px rgba(255, 178, 39, 0.22); | |
| font-weight: 900; | |
| letter-spacing: 0.08em; | |
| text-transform: uppercase; | |
| cursor: pointer; | |
| transition: transform 100ms, box-shadow 100ms, filter 100ms; | |
| } | |
| .primary-button:hover { filter: brightness(1.07); transform: translateY(-1px); box-shadow: 0 6px 0 #995f15, 0 14px 28px rgba(255, 178, 39, 0.3); } | |
| .primary-button:active { transform: translateY(4px); box-shadow: 0 1px 0 #995f15; } | |
| .menu-note { | |
| color: rgba(255, 255, 255, 0.45); | |
| font: 700 10px/1.5 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| } | |
| .controls-grid { | |
| display: grid; | |
| grid-template-columns: repeat(4, 1fr); | |
| gap: 8px; | |
| margin-top: 30px; | |
| padding-top: 24px; | |
| border-top: 1px solid rgba(255, 255, 255, 0.1); | |
| } | |
| .control-item { | |
| min-height: 61px; | |
| padding: 10px; | |
| border: 1px solid rgba(255, 255, 255, 0.08); | |
| border-radius: 4px; | |
| background: rgba(255, 255, 255, 0.025); | |
| } | |
| .key { | |
| color: white; | |
| font: 850 11px/1 ui-monospace, SFMono-Regular, Menlo, monospace; | |
| letter-spacing: 0.1em; | |
| } | |
| .control-item span { | |
| display: block; | |
| margin-top: 7px; | |
| color: rgba(255, 255, 255, 0.45); | |
| font-size: 9px; | |
| text-transform: uppercase; | |
| letter-spacing: 0.08em; | |
| } | |
| .pause-card { width: min(430px, 100%); text-align: center; } | |
| .pause-card .menu-title { font-size: 52px; } | |
| .pause-card .menu-actions { justify-content: center; } | |
| .pause-card .menu-subtitle { margin-inline: auto; } | |
| #dead-screen { background: radial-gradient(circle, rgba(94, 16, 25, 0.4), rgba(8, 2, 4, 0.9)); } | |
| #dead-screen .menu-kicker { color: #ff7888; } | |
| @media (max-width: 820px) { | |
| #mission { top: 16px; right: 16px; width: 240px; } | |
| #brand { top: 16px; left: 16px; } | |
| #world-info { display: none; } | |
| #help { display: none; } | |
| #vitals { left: 16px; bottom: 92px; } | |
| #resource-strip { display: none; } | |
| #hotbar-wrap { bottom: 16px; } | |
| .slot { width: 47px; height: 47px; } | |
| .slot-icon { width: 24px; height: 24px; left: 11px; top: 11px; } | |
| #slot-name { bottom: 60px; } | |
| } | |
| @media (max-width: 600px) { | |
| #brand .brand-copy span, #mission-copy { display: none; } | |
| #mission { width: 180px; } | |
| .menu-card { padding: 35px 25px 30px; } | |
| .controls-grid { grid-template-columns: repeat(2, 1fr); } | |
| .menu-actions { align-items: flex-start; flex-direction: column; } | |
| #vitals { bottom: 82px; } | |
| .slot { width: 42px; height: 42px; } | |
| .slot-icon { width: 21px; height: 21px; left: 10px; top: 10px; } | |
| #hotbar { gap: 3px; padding: 4px; } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <main id="game" aria-label="Solarcraft voxel game"> | |
| <div id="hud"> | |
| <div id="brand"> | |
| <div class="brand-mark" aria-hidden="true"></div> | |
| <div class="brand-copy"><strong>SOLARCRAFT</strong><span>Tiny voxel adventure</span></div> | |
| </div> | |
| <div id="world-info"> | |
| <div class="chip" id="coord-chip">XYZ 0 / 0 / 0</div> | |
| <div class="chip" id="time-chip">DAY 01 · 08:00</div> | |
| </div> | |
| <section id="mission" class="glass"> | |
| <div class="eyebrow">Field objective</div> | |
| <div id="mission-title">Power the old beacon</div> | |
| <div id="mission-copy">Mine 5 coal and defeat 3 Gloomwalkers.</div> | |
| <div id="mission-meter"><span></span></div> | |
| </section> | |
| <div id="crosshair" aria-hidden="true"></div> | |
| <div id="target-label"></div> | |
| <div id="mine-wrap"><div id="mine-fill"></div></div> | |
| <div id="vitals"> | |
| <div> | |
| <div id="hearts" aria-label="Player health"></div> | |
| <div class="vital-caption">Explorer integrity</div> | |
| </div> | |
| <div id="resource-strip"> | |
| <div class="resource">COAL <b id="coal-count">0</b></div> | |
| <div class="resource">IRON <b id="iron-count">0</b></div> | |
| <div class="resource">ESS <b id="essence-count">0</b></div> | |
| </div> | |
| </div> | |
| <div id="hotbar-wrap"> | |
| <div id="slot-name">Dirt</div> | |
| <div id="hotbar" class="glass"></div> | |
| </div> | |
| <div id="help" class="glass"><b>WASD</b> MOVE · <b>SPACE</b> JUMP · <b>SHIFT</b> SPRINT<br><b>LMB</b> MINE / ATTACK · <b>RMB</b> PLACE · <b>1–6</b> SELECT</div> | |
| <div id="toast-stack"></div> | |
| <div id="damage-flash"></div> | |
| </div> | |
| <section id="start-screen" class="overlay active"> | |
| <div class="menu-card"> | |
| <div class="menu-kicker">A one-file voxel expedition</div> | |
| <h1 class="menu-title">Solar<em>craft</em></h1> | |
| <p class="menu-subtitle">The beacon is dark and the lowlands are crawling with Gloomwalkers. Break ground, gather what you need, and hold the line until sunrise.</p> | |
| <div class="menu-actions"> | |
| <button id="start-button" class="primary-button">Enter the wilds</button> | |
| <div class="menu-note">Headphones recommended<br>Click to capture mouse</div> | |
| </div> | |
| <div class="controls-grid"> | |
| <div class="control-item"><div class="key">W A S D</div><span>Move around</span></div> | |
| <div class="control-item"><div class="key">SPACE</div><span>Jump</span></div> | |
| <div class="control-item"><div class="key">LEFT MOUSE</div><span>Mine / fight</span></div> | |
| <div class="control-item"><div class="key">RIGHT MOUSE</div><span>Place block</span></div> | |
| </div> | |
| </div> | |
| </section> | |
| <section id="pause-screen" class="overlay"> | |
| <div class="menu-card pause-card"> | |
| <div class="menu-kicker">Expedition paused</div> | |
| <h2 class="menu-title">Breathe.</h2> | |
| <p class="menu-subtitle">The wilds will be right where you left them.</p> | |
| <div class="menu-actions"><button id="resume-button" class="primary-button">Return to game</button></div> | |
| </div> | |
| </section> | |
| <section id="dead-screen" class="overlay"> | |
| <div class="menu-card pause-card"> | |
| <div class="menu-kicker">Signal lost</div> | |
| <h2 class="menu-title">You fell.</h2> | |
| <p class="menu-subtitle">The beacon still needs you. Your gathered resources are safe.</p> | |
| <div class="menu-actions"><button id="respawn-button" class="primary-button">Respawn</button></div> | |
| </div> | |
| </section> | |
| </main> | |
| <script type="module"> | |
| import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.160.1/build/three.module.js'; | |
| // ---------- DOM and renderer ---------- | |
| const $ = (selector) => document.querySelector(selector); | |
| const game = $('#game'); | |
| const hud = $('#hud'); | |
| const startScreen = $('#start-screen'); | |
| const pauseScreen = $('#pause-screen'); | |
| const deadScreen = $('#dead-screen'); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true, powerPreference: 'high-performance' }); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 1.6)); | |
| renderer.setSize(innerWidth, innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| renderer.shadowMap.type = THREE.PCFSoftShadowMap; | |
| renderer.outputColorSpace = THREE.SRGBColorSpace; | |
| renderer.toneMapping = THREE.ACESFilmicToneMapping; | |
| renderer.toneMappingExposure = 1.05; | |
| game.prepend(renderer.domElement); | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color('#86c9ec'); | |
| scene.fog = new THREE.FogExp2('#86c9ec', 0.022); | |
| const camera = new THREE.PerspectiveCamera(74, innerWidth / innerHeight, 0.08, 110); | |
| camera.rotation.order = 'YXZ'; | |
| scene.add(camera); | |
| const hemi = new THREE.HemisphereLight('#b9e7ff', '#344126', 1.65); | |
| scene.add(hemi); | |
| const sunLight = new THREE.DirectionalLight('#fff1c2', 2.5); | |
| sunLight.position.set(18, 28, 12); | |
| sunLight.castShadow = true; | |
| sunLight.shadow.mapSize.set(1536, 1536); | |
| sunLight.shadow.camera.left = -24; | |
| sunLight.shadow.camera.right = 24; | |
| sunLight.shadow.camera.top = 24; | |
| sunLight.shadow.camera.bottom = -24; | |
| sunLight.shadow.camera.near = 1; | |
| sunLight.shadow.camera.far = 75; | |
| sunLight.shadow.bias = -0.0004; | |
| scene.add(sunLight); | |
| const sunOrb = new THREE.Mesh( | |
| new THREE.BoxGeometry(2.7, 2.7, 0.55), | |
| new THREE.MeshBasicMaterial({ color: '#ffe387', fog: false }) | |
| ); | |
| scene.add(sunOrb); | |
| // ---------- Voxel definitions and textures ---------- | |
| const WORLD_HALF = 17; | |
| const WORLD_TOP = 22; | |
| const REACH = 6; | |
| const world = new Map(); | |
| const keyOf = (x, y, z) => `${x},${y},${z}`; | |
| const getBlock = (x, y, z) => world.get(keyOf(x, y, z)) || null; | |
| const setBlock = (x, y, z, type) => type ? world.set(keyOf(x, y, z), type) : world.delete(keyOf(x, y, z)); | |
| const BLOCKS = { | |
| bedrock: { name: 'Bedrock', hardness: Infinity, color: '#3d4249', texture: 'bedrock' }, | |
| grass: { name: 'Sungrass', hardness: 0.48, color: '#62bd4c', texture: 'grass_top', drop: 'dirt' }, | |
| dirt: { name: 'Dirt', hardness: 0.36, color: '#8e5f3c', texture: 'dirt', drop: 'dirt' }, | |
| stone: { name: 'Stone', hardness: 0.92, color: '#8c949d', texture: 'stone', drop: 'stone' }, | |
| coalOre: { name: 'Coal Ore', hardness: 1.12, color: '#555b62', texture: 'coal_ore', resource: 'coal' }, | |
| ironOre: { name: 'Iron Ore', hardness: 1.34, color: '#a17e67', texture: 'iron_ore', resource: 'iron' }, | |
| sand: { name: 'Sun Sand', hardness: 0.28, color: '#ddca82', texture: 'sand', drop: 'sand' }, | |
| wood: { name: 'Timber', hardness: 0.72, color: '#8c6235', texture: 'log_side', drop: 'wood' }, | |
| leaves: { name: 'Solar Leaves', hardness: 0.2, color: '#3c9c47', texture: 'leaves', drop: 'leaves' } | |
| }; | |
| const HOTBAR = ['dirt', 'stone', 'wood', 'sand', 'leaves', 'grass']; | |
| const inventory = { dirt: 12, stone: 6, wood: 4, sand: 0, leaves: 0, grass: 0 }; | |
| const resources = { coal: 0, iron: 0, essence: 0 }; | |
| let selectedSlot = 0; | |
| const textureCanvases = {}; | |
| const textures = {}; | |
| const materials = {}; | |
| function seeded(seed) { | |
| let n = seed >>> 0; | |
| return () => { | |
| n = (n * 1664525 + 1013904223) >>> 0; | |
| return n / 4294967296; | |
| }; | |
| } | |
| function makeTexture(name, base, flecks, mode = 'noise') { | |
| const canvas = document.createElement('canvas'); | |
| canvas.width = canvas.height = 32; | |
| const ctx = canvas.getContext('2d'); | |
| ctx.fillStyle = base; | |
| ctx.fillRect(0, 0, 32, 32); | |
| const rand = seeded([...name].reduce((a, c) => a + c.charCodeAt(0) * 31, 17)); | |
| if (mode === 'grassSide') { | |
| ctx.fillStyle = '#8f5e38'; | |
| ctx.fillRect(0, 0, 32, 32); | |
| ctx.fillStyle = '#5daf48'; | |
| ctx.fillRect(0, 0, 32, 9); | |
| for (let x = 0; x < 32; x += 3) { | |
| const depth = 3 + Math.floor(rand() * 7); | |
| ctx.fillStyle = rand() > 0.5 ? '#559f40' : '#6ec653'; | |
| ctx.fillRect(x, 7, 2, depth); | |
| } | |
| } else if (mode === 'log') { | |
| for (let x = 1; x < 32; x += 5 + Math.floor(rand() * 4)) { | |
| ctx.fillStyle = flecks[Math.floor(rand() * flecks.length)]; | |
| ctx.fillRect(x, 0, 2, 32); | |
| } | |
| ctx.fillStyle = 'rgba(255,255,255,.08)'; | |
| ctx.fillRect(0, 3, 32, 2); | |
| } else if (mode === 'rings') { | |
| ctx.strokeStyle = flecks[0]; | |
| ctx.lineWidth = 2; | |
| for (let s = 6; s < 28; s += 6) ctx.strokeRect((32 - s) / 2, (32 - s) / 2, s, s); | |
| } else if (mode === 'leaves') { | |
| for (let i = 0; i < 100; i++) { | |
| const size = 2 + Math.floor(rand() * 4); | |
| ctx.fillStyle = flecks[Math.floor(rand() * flecks.length)]; | |
| ctx.fillRect(Math.floor(rand() * 32), Math.floor(rand() * 32), size, size); | |
| } | |
| ctx.fillStyle = 'rgba(255,240,120,.24)'; | |
| for (let i = 0; i < 9; i++) ctx.fillRect(Math.floor(rand() * 31), Math.floor(rand() * 31), 2, 2); | |
| } else { | |
| for (let i = 0; i < 80; i++) { | |
| const size = rand() > 0.78 ? 4 : 2; | |
| ctx.fillStyle = flecks[Math.floor(rand() * flecks.length)]; | |
| ctx.globalAlpha = 0.35 + rand() * 0.52; | |
| ctx.fillRect(Math.floor(rand() * 32), Math.floor(rand() * 32), size, size); | |
| } | |
| ctx.globalAlpha = 1; | |
| } | |
| if (mode === 'ore') { | |
| for (let i = 0; i < 12; i++) { | |
| ctx.fillStyle = flecks[i % flecks.length]; | |
| const x = Math.floor(rand() * 28); | |
| const y = Math.floor(rand() * 28); | |
| ctx.fillRect(x, y, 4 + (i % 2) * 2, 3 + ((i + 1) % 2) * 2); | |
| } | |
| } | |
| const texture = new THREE.CanvasTexture(canvas); | |
| texture.magFilter = THREE.NearestFilter; | |
| texture.minFilter = THREE.NearestMipmapNearestFilter; | |
| texture.colorSpace = THREE.SRGBColorSpace; | |
| textures[name] = texture; | |
| textureCanvases[name] = canvas; | |
| materials[name] = new THREE.MeshLambertMaterial({ map: texture, color: '#ffffff' }); | |
| } | |
| makeTexture('grass_top', '#59b946', ['#4a9f3d', '#79d25a', '#8bdd67', '#367d35']); | |
| makeTexture('grass_side', '#8f5e38', ['#6d472f'], 'grassSide'); | |
| makeTexture('dirt', '#8d5d3b', ['#684329', '#a97349', '#c08857']); | |
| makeTexture('stone', '#8b9298', ['#6d757d', '#a7adb1', '#596169']); | |
| makeTexture('coal_ore', '#81888e', ['#22262b', '#343940', '#11151a'], 'ore'); | |
| makeTexture('iron_ore', '#858b8e', ['#b87855', '#d39a70', '#714c3b'], 'ore'); | |
| makeTexture('sand', '#ddc980', ['#f1dda0', '#baa864', '#fff0b2']); | |
| makeTexture('log_side', '#8d6338', ['#654424', '#ae7a42'], 'log'); | |
| makeTexture('log_top', '#a97b49', ['#694a2b'], 'rings'); | |
| makeTexture('leaves', '#3f9948', ['#2e7839', '#55b959', '#246a33', '#72cb65'], 'leaves'); | |
| makeTexture('bedrock', '#40454a', ['#24282d', '#656b70', '#15191c']); | |
| // ---------- Deterministic terrain ---------- | |
| function hash3(x, y, z) { | |
| const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453123; | |
| return s - Math.floor(s); | |
| } | |
| function terrainHeight(x, z) { | |
| const broad = Math.sin(x * 0.19) * 1.25 + Math.cos(z * 0.17) * 1.15; | |
| const ridges = Math.sin((x + z) * 0.105) * 0.9 + Math.cos((x - z) * 0.12) * 0.55; | |
| return Math.max(3, Math.min(9, Math.floor(5.2 + broad + ridges + hash3(x, 0, z) * 0.85))); | |
| } | |
| function generateWorld() { | |
| world.clear(); | |
| for (let x = -WORLD_HALF; x < WORLD_HALF; x++) { | |
| for (let z = -WORLD_HALF; z < WORLD_HALF; z++) { | |
| const height = terrainHeight(x, z); | |
| const sandy = height <= 4 && (hash3(x, 22, z) > 0.28 || z < -11); | |
| for (let y = 0; y <= height; y++) { | |
| let type = 'stone'; | |
| if (y === 0) type = 'bedrock'; | |
| else if (y === height) type = sandy ? 'sand' : 'grass'; | |
| else if (y >= height - 2) type = sandy ? 'sand' : 'dirt'; | |
| else { | |
| const ore = hash3(x, y * 3.1, z); | |
| if (y <= 5 && ore > 0.953) type = 'coalOre'; | |
| else if (y <= 4 && ore < 0.022) type = 'ironOre'; | |
| } | |
| setBlock(x, y, z, type); | |
| } | |
| } | |
| } | |
| // Sparse trees, with a guaranteed clear landing zone around the origin. | |
| for (let x = -WORLD_HALF + 2; x < WORLD_HALF - 2; x += 2) { | |
| for (let z = -WORLD_HALF + 2; z < WORLD_HALF - 2; z += 2) { | |
| if (Math.abs(x) < 4 && Math.abs(z) < 4) continue; | |
| const y = terrainHeight(x, z); | |
| if (getBlock(x, y, z) !== 'grass' || hash3(x * 2, 9, z * 2) < 0.82) continue; | |
| const trunk = 3 + (hash3(x, 5, z) > 0.55 ? 1 : 0); | |
| for (let i = 1; i <= trunk; i++) setBlock(x, y + i, z, 'wood'); | |
| for (let dx = -2; dx <= 2; dx++) { | |
| for (let dz = -2; dz <= 2; dz++) { | |
| for (let dy = trunk - 1; dy <= trunk + 1; dy++) { | |
| const edge = Math.abs(dx) + Math.abs(dz) + Math.abs(dy - trunk); | |
| if (edge > 4 || (dx === 0 && dz === 0 && dy <= trunk)) continue; | |
| if (hash3(x + dx, y + dy, z + dz) > 0.12) setBlock(x + dx, y + dy, z + dz, 'leaves'); | |
| } | |
| } | |
| } | |
| setBlock(x, y + trunk + 2, z, 'leaves'); | |
| } | |
| } | |
| // Keep the initial landing meadow flat and open even when nearby terrain or | |
| // a tree canopy would otherwise wall in a first-time player. | |
| for (let x = -3; x <= 3; x++) { | |
| for (let z = -3; z <= 3; z++) { | |
| for (let y = 1; y <= 3; y++) setBlock(x, y, z, 'stone'); | |
| setBlock(x, 4, z, 'dirt'); | |
| setBlock(x, 5, z, 'dirt'); | |
| setBlock(x, 6, z, 'grass'); | |
| for (let y = 7; y <= WORLD_TOP; y++) setBlock(x, y, z, null); | |
| } | |
| } | |
| } | |
| const FACES = [ | |
| { n: [1, 0, 0], key: 'px', v: [[1,0,0],[1,1,0],[1,1,1],[1,0,1]] }, | |
| { n: [-1, 0, 0], key: 'nx', v: [[0,0,1],[0,1,1],[0,1,0],[0,0,0]] }, | |
| { n: [0, 1, 0], key: 'py', v: [[0,1,1],[1,1,1],[1,1,0],[0,1,0]] }, | |
| { n: [0, -1, 0], key: 'ny', v: [[0,0,0],[1,0,0],[1,0,1],[0,0,1]] }, | |
| { n: [0, 0, 1], key: 'pz', v: [[1,0,1],[1,1,1],[0,1,1],[0,0,1]] }, | |
| { n: [0, 0, -1], key: 'nz', v: [[0,0,0],[0,1,0],[1,1,0],[1,0,0]] } | |
| ]; | |
| const UVS = [[0,0],[0,1],[1,1],[1,0]]; | |
| const terrainGroup = new THREE.Group(); | |
| scene.add(terrainGroup); | |
| function textureForFace(type, faceKey) { | |
| if (type === 'grass') return faceKey === 'py' ? 'grass_top' : faceKey === 'ny' ? 'dirt' : 'grass_side'; | |
| if (type === 'wood') return faceKey === 'py' || faceKey === 'ny' ? 'log_top' : 'log_side'; | |
| return BLOCKS[type].texture; | |
| } | |
| function rebuildTerrain() { | |
| while (terrainGroup.children.length) { | |
| const old = terrainGroup.children.pop(); | |
| old.geometry.dispose(); | |
| } | |
| const batches = {}; | |
| const batch = (key) => batches[key] ||= { positions: [], normals: [], uvs: [], indices: [], count: 0 }; | |
| for (const [key, type] of world) { | |
| const [x, y, z] = key.split(',').map(Number); | |
| for (const face of FACES) { | |
| if (getBlock(x + face.n[0], y + face.n[1], z + face.n[2])) continue; | |
| const data = batch(textureForFace(type, face.key)); | |
| const start = data.count; | |
| for (let i = 0; i < 4; i++) { | |
| data.positions.push(x + face.v[i][0], y + face.v[i][1], z + face.v[i][2]); | |
| data.normals.push(...face.n); | |
| data.uvs.push(...UVS[i]); | |
| } | |
| data.indices.push(start, start + 1, start + 2, start, start + 2, start + 3); | |
| data.count += 4; | |
| } | |
| } | |
| for (const [textureKey, data] of Object.entries(batches)) { | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.Float32BufferAttribute(data.positions, 3)); | |
| geometry.setAttribute('normal', new THREE.Float32BufferAttribute(data.normals, 3)); | |
| geometry.setAttribute('uv', new THREE.Float32BufferAttribute(data.uvs, 2)); | |
| geometry.setIndex(data.indices); | |
| geometry.computeBoundingSphere(); | |
| const mesh = new THREE.Mesh(geometry, materials[textureKey]); | |
| mesh.receiveShadow = true; | |
| mesh.castShadow = true; | |
| terrainGroup.add(mesh); | |
| } | |
| } | |
| function highestSolid(x, z) { | |
| const bx = Math.floor(x); | |
| const bz = Math.floor(z); | |
| for (let y = WORLD_TOP; y >= 0; y--) if (getBlock(bx, y, bz)) return y; | |
| return -1; | |
| } | |
| // ---------- Atmosphere ---------- | |
| const cloudMaterial = new THREE.MeshLambertMaterial({ color: '#f7fbff', transparent: true, opacity: 0.72, depthWrite: false }); | |
| const cloudGeometry = new THREE.BoxGeometry(1, 1, 1); | |
| const clouds = []; | |
| for (let i = 0; i < 9; i++) { | |
| const cloud = new THREE.Group(); | |
| const rand = seeded(800 + i); | |
| for (let p = 0; p < 3 + Math.floor(rand() * 3); p++) { | |
| const puff = new THREE.Mesh(cloudGeometry, cloudMaterial); | |
| puff.scale.set(2.5 + rand() * 3.2, 0.45 + rand() * 0.42, 1.3 + rand() * 1.3); | |
| puff.position.set((p - 2) * 2.1, rand() * 0.35, (rand() - 0.5) * 1.3); | |
| cloud.add(puff); | |
| } | |
| cloud.position.set(-38 + rand() * 76, 15 + rand() * 4, -28 + rand() * 55); | |
| cloud.userData.speed = 0.35 + rand() * 0.3; | |
| clouds.push(cloud); | |
| scene.add(cloud); | |
| } | |
| const starGeometry = new THREE.BufferGeometry(); | |
| const starPositions = []; | |
| const starRand = seeded(9128); | |
| for (let i = 0; i < 350; i++) { | |
| const theta = starRand() * Math.PI * 2; | |
| const phi = starRand() * Math.PI * 0.44; | |
| const r = 65; | |
| starPositions.push(Math.cos(theta) * Math.sin(phi) * r, Math.cos(phi) * r, Math.sin(theta) * Math.sin(phi) * r); | |
| } | |
| starGeometry.setAttribute('position', new THREE.Float32BufferAttribute(starPositions, 3)); | |
| const starMaterial = new THREE.PointsMaterial({ color: '#e8f2ff', size: 0.22, transparent: true, opacity: 0, fog: false, depthWrite: false }); | |
| const stars = new THREE.Points(starGeometry, starMaterial); | |
| scene.add(stars); | |
| // ---------- Player and camera-held block ---------- | |
| const EYE_HEIGHT = 1.62; | |
| const PLAYER_HEIGHT = 1.82; | |
| const PLAYER_RADIUS = 0.29; | |
| const player = { | |
| velocity: new THREE.Vector3(), | |
| yaw: 0, | |
| pitch: -0.12, | |
| health: 10, | |
| onGround: false, | |
| alive: true, | |
| kills: 0, | |
| attackCooldown: 0, | |
| invulnerable: 0, | |
| bob: 0, | |
| stepTimer: 0 | |
| }; | |
| const hand = new THREE.Group(); | |
| const handBlock = new THREE.Mesh(new THREE.BoxGeometry(0.25, 0.25, 0.25), materials.dirt); | |
| handBlock.rotation.set(0.18, -0.45, 0.08); | |
| hand.add(handBlock); | |
| hand.position.set(0.43, -0.38, -0.72); | |
| hand.userData.swing = 0; | |
| camera.add(hand); | |
| const blockOutline = new THREE.LineSegments( | |
| new THREE.EdgesGeometry(new THREE.BoxGeometry(1.012, 1.012, 1.012)), | |
| new THREE.LineBasicMaterial({ color: '#ffffff', transparent: true, opacity: 0.82 }) | |
| ); | |
| blockOutline.visible = false; | |
| scene.add(blockOutline); | |
| const placeOutline = new THREE.LineSegments( | |
| new THREE.EdgesGeometry(new THREE.BoxGeometry(1.02, 1.02, 1.02)), | |
| new THREE.LineBasicMaterial({ color: '#7dffa0', transparent: true, opacity: 0.42 }) | |
| ); | |
| placeOutline.visible = false; | |
| scene.add(placeOutline); | |
| function respawnPosition() { | |
| const x = 0.5, z = 0.5; | |
| return new THREE.Vector3(x, highestSolid(x, z) + 1 + EYE_HEIGHT, z); | |
| } | |
| function collidesAt(position) { | |
| const minX = Math.floor(position.x - PLAYER_RADIUS); | |
| const maxX = Math.floor(position.x + PLAYER_RADIUS); | |
| const feet = position.y - EYE_HEIGHT; | |
| const head = feet + PLAYER_HEIGHT; | |
| const minY = Math.floor(feet + 0.001); | |
| const maxY = Math.floor(head - 0.001); | |
| const minZ = Math.floor(position.z - PLAYER_RADIUS); | |
| const maxZ = Math.floor(position.z + PLAYER_RADIUS); | |
| for (let x = minX; x <= maxX; x++) { | |
| for (let y = minY; y <= maxY; y++) { | |
| for (let z = minZ; z <= maxZ; z++) if (getBlock(x, y, z)) return true; | |
| } | |
| } | |
| return false; | |
| } | |
| function movePlayerAxis(axis, amount) { | |
| const steps = Math.max(1, Math.ceil(Math.abs(amount) / 0.08)); | |
| const part = amount / steps; | |
| for (let i = 0; i < steps; i++) { | |
| camera.position[axis] += part; | |
| if (collidesAt(camera.position)) { | |
| camera.position[axis] -= part; | |
| if (axis === 'y') { | |
| if (part < 0) player.onGround = true; | |
| player.velocity.y = 0; | |
| } else { | |
| player.velocity[axis] = 0; | |
| } | |
| return; | |
| } | |
| } | |
| } | |
| // ---------- Mobs ---------- | |
| const mobs = []; | |
| const mobBodyMaterial = new THREE.MeshLambertMaterial({ color: '#546b46' }); | |
| const mobShirtMaterial = new THREE.MeshLambertMaterial({ color: '#283b43' }); | |
| const mobEyeMaterial = new THREE.MeshBasicMaterial({ color: '#ffd45a' }); | |
| const mobDarkMaterial = new THREE.MeshLambertMaterial({ color: '#26352a' }); | |
| const boxGeometry = new THREE.BoxGeometry(1, 1, 1); | |
| function mobPart(group, scale, position, material) { | |
| const mesh = new THREE.Mesh(boxGeometry, material); | |
| mesh.scale.set(...scale); | |
| mesh.position.set(...position); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| group.add(mesh); | |
| return mesh; | |
| } | |
| function spawnMob(x, z) { | |
| const group = new THREE.Group(); | |
| const body = mobPart(group, [0.64, 0.72, 0.38], [0, 1.06, 0], mobShirtMaterial); | |
| const head = mobPart(group, [0.55, 0.55, 0.52], [0, 1.69, 0], mobBodyMaterial); | |
| const leftLeg = mobPart(group, [0.24, 0.7, 0.28], [-0.18, 0.36, 0], mobDarkMaterial); | |
| const rightLeg = mobPart(group, [0.24, 0.7, 0.28], [0.18, 0.36, 0], mobDarkMaterial); | |
| const leftArm = mobPart(group, [0.2, 0.72, 0.22], [-0.43, 1.05, -0.07], mobBodyMaterial); | |
| const rightArm = mobPart(group, [0.2, 0.72, 0.22], [0.43, 1.05, -0.07], mobBodyMaterial); | |
| leftArm.rotation.x = rightArm.rotation.x = -0.72; | |
| mobPart(group, [0.11, 0.08, 0.03], [-0.15, 1.75, -0.27], mobEyeMaterial); | |
| mobPart(group, [0.11, 0.08, 0.03], [0.15, 1.75, -0.27], mobEyeMaterial); | |
| const ground = highestSolid(x, z) + 1; | |
| group.position.set(x, ground, z); | |
| scene.add(group); | |
| mobs.push({ | |
| group, body, head, leftLeg, rightLeg, leftArm, rightArm, | |
| health: 3, | |
| dead: false, | |
| hurt: 0, | |
| attackCooldown: 0.4, | |
| wander: hash3(x, 31, z) * Math.PI * 2, | |
| wanderTimer: 1 + hash3(x, 38, z) * 3, | |
| speed: 1.05 + hash3(x, 15, z) * 0.25, | |
| phase: hash3(x, 61, z) * Math.PI * 2 | |
| }); | |
| } | |
| function spawnInitialMobs() { | |
| [[8,7],[-9,6],[11,-7],[-11,-8],[4,-12],[-6,12]].forEach(([x,z]) => spawnMob(x + 0.5, z + 0.5)); | |
| } | |
| // ---------- Ray casting and interactions ---------- | |
| const lookDirection = new THREE.Vector3(); | |
| let blockTarget = null; | |
| let mobTarget = null; | |
| let leftHeld = false; | |
| let miningKey = ''; | |
| let miningProgress = 0; | |
| function raycastVoxels(origin, direction, maxDistance) { | |
| let x = Math.floor(origin.x), y = Math.floor(origin.y), z = Math.floor(origin.z); | |
| const stepX = direction.x >= 0 ? 1 : -1; | |
| const stepY = direction.y >= 0 ? 1 : -1; | |
| const stepZ = direction.z >= 0 ? 1 : -1; | |
| const deltaX = direction.x === 0 ? Infinity : Math.abs(1 / direction.x); | |
| const deltaY = direction.y === 0 ? Infinity : Math.abs(1 / direction.y); | |
| const deltaZ = direction.z === 0 ? Infinity : Math.abs(1 / direction.z); | |
| let maxX = direction.x === 0 ? Infinity : ((stepX > 0 ? x + 1 - origin.x : origin.x - x) * deltaX); | |
| let maxY = direction.y === 0 ? Infinity : ((stepY > 0 ? y + 1 - origin.y : origin.y - y) * deltaY); | |
| let maxZ = direction.z === 0 ? Infinity : ((stepZ > 0 ? z + 1 - origin.z : origin.z - z) * deltaZ); | |
| let distance = 0; | |
| const normal = new THREE.Vector3(); | |
| for (let i = 0; i < 128 && distance <= maxDistance; i++) { | |
| const type = getBlock(x, y, z); | |
| if (type) return { x, y, z, type, distance, normal: normal.clone() }; | |
| if (maxX < maxY && maxX < maxZ) { | |
| x += stepX; distance = maxX; maxX += deltaX; normal.set(-stepX, 0, 0); | |
| } else if (maxY < maxZ) { | |
| y += stepY; distance = maxY; maxY += deltaY; normal.set(0, -stepY, 0); | |
| } else { | |
| z += stepZ; distance = maxZ; maxZ += deltaZ; normal.set(0, 0, -stepZ); | |
| } | |
| } | |
| return null; | |
| } | |
| function raySphereDistance(origin, direction, center, radius) { | |
| const toCenter = center.clone().sub(origin); | |
| const projected = toCenter.dot(direction); | |
| if (projected < 0) return null; | |
| const closestSq = toCenter.lengthSq() - projected * projected; | |
| const radiusSq = radius * radius; | |
| if (closestSq > radiusSq) return null; | |
| return projected - Math.sqrt(radiusSq - closestSq); | |
| } | |
| function updateTarget() { | |
| camera.getWorldDirection(lookDirection); | |
| blockTarget = raycastVoxels(camera.position, lookDirection, REACH); | |
| mobTarget = null; | |
| let nearest = blockTarget ? blockTarget.distance : REACH; | |
| const center = new THREE.Vector3(); | |
| for (const mob of mobs) { | |
| if (mob.dead) continue; | |
| center.copy(mob.group.position).add(new THREE.Vector3(0, 1.05, 0)); | |
| const distance = raySphereDistance(camera.position, lookDirection, center, 0.63); | |
| if (distance !== null && distance < nearest && distance <= REACH) { | |
| nearest = distance; | |
| mobTarget = mob; | |
| } | |
| } | |
| const crosshair = $('#crosshair'); | |
| const targetLabel = $('#target-label'); | |
| if (mobTarget) { | |
| crosshair.classList.add('hostile'); | |
| targetLabel.textContent = `Gloomwalker · ${mobTarget.health}/3`; | |
| blockOutline.visible = false; | |
| placeOutline.visible = false; | |
| } else if (blockTarget) { | |
| crosshair.classList.remove('hostile'); | |
| targetLabel.textContent = BLOCKS[blockTarget.type].name; | |
| blockOutline.visible = true; | |
| blockOutline.position.set(blockTarget.x + 0.5, blockTarget.y + 0.5, blockTarget.z + 0.5); | |
| const place = { | |
| x: blockTarget.x + blockTarget.normal.x, | |
| y: blockTarget.y + blockTarget.normal.y, | |
| z: blockTarget.z + blockTarget.normal.z | |
| }; | |
| placeOutline.visible = canPlaceAt(place.x, place.y, place.z); | |
| placeOutline.position.set(place.x + 0.5, place.y + 0.5, place.z + 0.5); | |
| } else { | |
| crosshair.classList.remove('hostile'); | |
| targetLabel.textContent = ''; | |
| blockOutline.visible = false; | |
| placeOutline.visible = false; | |
| } | |
| } | |
| function blockIntersectsPlayer(x, y, z) { | |
| const feet = camera.position.y - EYE_HEIGHT; | |
| return x + 1 > camera.position.x - PLAYER_RADIUS && x < camera.position.x + PLAYER_RADIUS && | |
| z + 1 > camera.position.z - PLAYER_RADIUS && z < camera.position.z + PLAYER_RADIUS && | |
| y + 1 > feet && y < feet + PLAYER_HEIGHT; | |
| } | |
| function canPlaceAt(x, y, z) { | |
| return x >= -WORLD_HALF && x < WORLD_HALF && z >= -WORLD_HALF && z < WORLD_HALF && | |
| y > 0 && y < WORLD_TOP && !getBlock(x, y, z) && !blockIntersectsPlayer(x, y, z) && inventory[HOTBAR[selectedSlot]] > 0; | |
| } | |
| function placeSelectedBlock() { | |
| if (!blockTarget || mobTarget) return; | |
| const x = blockTarget.x + blockTarget.normal.x; | |
| const y = blockTarget.y + blockTarget.normal.y; | |
| const z = blockTarget.z + blockTarget.normal.z; | |
| const type = HOTBAR[selectedSlot]; | |
| if (!canPlaceAt(x, y, z)) { | |
| tone(105, 0.06, 'square', 0.018); | |
| return; | |
| } | |
| setBlock(x, y, z, type); | |
| inventory[type]--; | |
| rebuildTerrain(); | |
| updateHotbar(); | |
| burstParticles(x + 0.5, y + 0.5, z + 0.5, BLOCKS[type].color, 5, 0.9); | |
| playPlace(); | |
| hand.userData.swing = 1; | |
| } | |
| function mineBlock(target) { | |
| if (!target || target.type === 'bedrock') return; | |
| setBlock(target.x, target.y, target.z, null); | |
| const data = BLOCKS[target.type]; | |
| if (data.resource) { | |
| resources[data.resource]++; | |
| toast(`+1 ${data.resource.toUpperCase()} · resource secured`); | |
| } else if (data.drop) { | |
| inventory[data.drop] = (inventory[data.drop] || 0) + 1; | |
| } | |
| burstParticles(target.x + 0.5, target.y + 0.5, target.z + 0.5, data.color, 12, 2.2); | |
| rebuildTerrain(); | |
| updateHotbar(); | |
| updateMission(); | |
| playBreak(target.type); | |
| miningProgress = 0; | |
| miningKey = ''; | |
| hand.userData.swing = 1; | |
| } | |
| function hitMob(mob) { | |
| if (!mob || mob.dead || player.attackCooldown > 0) return; | |
| player.attackCooldown = 0.42; | |
| mob.health--; | |
| mob.hurt = 0.22; | |
| hand.userData.swing = 1; | |
| const knock = mob.group.position.clone().sub(camera.position).setY(0).normalize().multiplyScalar(0.42); | |
| mob.group.position.add(knock); | |
| burstParticles(mob.group.position.x, mob.group.position.y + 1.2, mob.group.position.z, '#ffd35a', 8, 2.3); | |
| playAttack(mob.health <= 0); | |
| if (mob.health <= 0) killMob(mob); | |
| } | |
| function killMob(mob) { | |
| mob.dead = true; | |
| player.kills++; | |
| resources.essence++; | |
| mob.group.visible = false; | |
| toast('+1 ESSENCE · Gloomwalker dispersed'); | |
| updateMission(); | |
| setTimeout(() => { | |
| const edge = hash3(player.kills, 8, resources.essence) > 0.5 ? 1 : -1; | |
| spawnMob(edge * (12 + hash3(player.kills, 3, 1) * 3), (hash3(player.kills, 4, 2) - 0.5) * 24); | |
| }, 8500); | |
| } | |
| // ---------- Particles ---------- | |
| const particles = []; | |
| const particleGeometry = new THREE.BoxGeometry(0.1, 0.1, 0.1); | |
| function burstParticles(x, y, z, color, count = 8, power = 1.5) { | |
| const material = new THREE.MeshLambertMaterial({ color }); | |
| const rand = seeded(Math.floor((x + 30) * 991 + (y + 8) * 313 + (z + 30) * 127 + performance.now())); | |
| for (let i = 0; i < count; i++) { | |
| const mesh = new THREE.Mesh(particleGeometry, material); | |
| mesh.position.set(x + (rand() - 0.5) * 0.5, y + (rand() - 0.5) * 0.5, z + (rand() - 0.5) * 0.5); | |
| mesh.scale.setScalar(0.7 + rand() * 1.2); | |
| scene.add(mesh); | |
| particles.push({ | |
| mesh, material, | |
| velocity: new THREE.Vector3((rand() - 0.5) * power, rand() * power, (rand() - 0.5) * power), | |
| life: 0.55 + rand() * 0.4 | |
| }); | |
| } | |
| } | |
| function updateParticles(dt) { | |
| for (let i = particles.length - 1; i >= 0; i--) { | |
| const p = particles[i]; | |
| p.life -= dt; | |
| p.velocity.y -= 5.5 * dt; | |
| p.mesh.position.addScaledVector(p.velocity, dt); | |
| p.mesh.rotation.x += dt * 5; | |
| p.mesh.rotation.z += dt * 4; | |
| p.mesh.scale.multiplyScalar(Math.max(0.8, 1 - dt * 1.8)); | |
| if (p.life <= 0) { | |
| scene.remove(p.mesh); | |
| particles.splice(i, 1); | |
| if (!particles.some(other => other.material === p.material)) p.material.dispose(); | |
| } | |
| } | |
| } | |
| // ---------- Tiny procedural audio ---------- | |
| let audio = null; | |
| function ensureAudio() { | |
| if (!audio) audio = new (window.AudioContext || window.webkitAudioContext)(); | |
| if (audio.state === 'suspended') audio.resume(); | |
| } | |
| function tone(frequency, duration, type = 'sine', volume = 0.025, slide = 0) { | |
| if (!audio) return; | |
| const now = audio.currentTime; | |
| const osc = audio.createOscillator(); | |
| const gain = audio.createGain(); | |
| osc.type = type; | |
| osc.frequency.setValueAtTime(frequency, now); | |
| if (slide) osc.frequency.exponentialRampToValueAtTime(Math.max(20, frequency + slide), now + duration); | |
| gain.gain.setValueAtTime(volume, now); | |
| gain.gain.exponentialRampToValueAtTime(0.0001, now + duration); | |
| osc.connect(gain).connect(audio.destination); | |
| osc.start(now); | |
| osc.stop(now + duration); | |
| } | |
| function noise(duration = 0.08, volume = 0.018, cutoff = 800) { | |
| if (!audio) return; | |
| const length = Math.ceil(audio.sampleRate * duration); | |
| const buffer = audio.createBuffer(1, length, audio.sampleRate); | |
| const data = buffer.getChannelData(0); | |
| for (let i = 0; i < length; i++) data[i] = (Math.random() * 2 - 1) * (1 - i / length); | |
| const source = audio.createBufferSource(); | |
| const filter = audio.createBiquadFilter(); | |
| const gain = audio.createGain(); | |
| source.buffer = buffer; | |
| filter.type = 'lowpass'; | |
| filter.frequency.value = cutoff; | |
| gain.gain.value = volume; | |
| source.connect(filter).connect(gain).connect(audio.destination); | |
| source.start(); | |
| } | |
| function playBreak(type) { noise(0.12, 0.04, type === 'stone' ? 650 : 1050); tone(type.includes('Ore') ? 420 : 135, 0.09, 'square', 0.018, -45); } | |
| function playPlace() { noise(0.055, 0.025, 650); tone(165, 0.055, 'square', 0.012, -25); } | |
| function playAttack(final) { noise(0.07, 0.025, 1300); tone(final ? 110 : 185, final ? 0.22 : 0.08, 'sawtooth', 0.026, final ? -50 : 70); } | |
| function playStep() { noise(0.04, 0.012, 520); } | |
| // ---------- HUD ---------- | |
| function updateHearts() { | |
| const hearts = $('#hearts'); | |
| hearts.innerHTML = ''; | |
| for (let i = 0; i < 5; i++) { | |
| const el = document.createElement('span'); | |
| el.className = 'heart'; | |
| el.textContent = '♥'; | |
| if (player.health <= i * 2) el.classList.add('empty'); | |
| else if (player.health === i * 2 + 1) el.classList.add('half'); | |
| hearts.appendChild(el); | |
| } | |
| } | |
| function iconStyle(type) { | |
| const canvas = textureCanvases[BLOCKS[type].texture]; | |
| return `background-image:url(${canvas.toDataURL()});background-size:100% 100%;`; | |
| } | |
| function updateHotbar() { | |
| const hotbar = $('#hotbar'); | |
| hotbar.innerHTML = ''; | |
| HOTBAR.forEach((type, index) => { | |
| const slot = document.createElement('div'); | |
| slot.className = `slot${selectedSlot === index ? ' selected' : ''}`; | |
| slot.innerHTML = `<span class="slot-key">${index + 1}</span><span class="slot-icon" style="${iconStyle(type)}"></span><span class="slot-count">${inventory[type] || 0}</span>`; | |
| hotbar.appendChild(slot); | |
| }); | |
| const selected = HOTBAR[selectedSlot]; | |
| $('#slot-name').textContent = BLOCKS[selected].name; | |
| handBlock.material = materials[BLOCKS[selected].texture]; | |
| $('#coal-count').textContent = resources.coal; | |
| $('#iron-count').textContent = resources.iron; | |
| $('#essence-count').textContent = resources.essence; | |
| } | |
| let objectiveDone = false; | |
| function updateMission() { | |
| const coalProgress = Math.min(resources.coal, 5); | |
| const killProgress = Math.min(player.kills, 3); | |
| const progress = (coalProgress + killProgress) / 8; | |
| $('#mission-meter span').style.width = `${progress * 100}%`; | |
| $('#mission-copy').textContent = `Coal ${coalProgress}/5 · Gloomwalkers ${killProgress}/3`; | |
| updateHotbar(); | |
| if (progress >= 1 && !objectiveDone) { | |
| objectiveDone = true; | |
| $('#mission-title').textContent = 'Beacon signal restored'; | |
| $('#mission-copy').textContent = 'Objective complete — keep exploring and building.'; | |
| toast('BEACON ONLINE · Expedition objective complete'); | |
| tone(392, 0.18, 'sine', 0.035, 130); | |
| setTimeout(() => tone(523, 0.24, 'sine', 0.035, 130), 160); | |
| } | |
| } | |
| function toast(message) { | |
| const el = document.createElement('div'); | |
| el.className = 'toast'; | |
| el.textContent = message; | |
| $('#toast-stack').appendChild(el); | |
| setTimeout(() => el.remove(), 2900); | |
| } | |
| function selectSlot(index) { | |
| selectedSlot = (index + HOTBAR.length) % HOTBAR.length; | |
| updateHotbar(); | |
| tone(310 + selectedSlot * 22, 0.035, 'square', 0.008); | |
| } | |
| function damagePlayer(amount, sourcePosition) { | |
| if (player.invulnerable > 0 || !player.alive) return; | |
| player.health = Math.max(0, player.health - amount); | |
| player.invulnerable = 0.65; | |
| const push = camera.position.clone().sub(sourcePosition).setY(0).normalize().multiplyScalar(3.2); | |
| player.velocity.x += push.x; | |
| player.velocity.z += push.z; | |
| player.velocity.y = 3.3; | |
| $('#damage-flash').classList.add('hit'); | |
| requestAnimationFrame(() => $('#damage-flash').classList.remove('hit')); | |
| tone(95, 0.18, 'sawtooth', 0.038, -40); | |
| updateHearts(); | |
| if (player.health <= 0) die(); | |
| } | |
| function die() { | |
| player.alive = false; | |
| leftHeld = false; | |
| document.exitPointerLock?.(); | |
| pauseScreen.classList.remove('active'); | |
| deadScreen.classList.add('active'); | |
| } | |
| function respawn() { | |
| player.alive = true; | |
| player.health = 10; | |
| player.velocity.set(0, 0, 0); | |
| player.invulnerable = 1.5; | |
| camera.position.copy(respawnPosition()); | |
| updateHearts(); | |
| deadScreen.classList.remove('active'); | |
| renderer.domElement.requestPointerLock(); | |
| toast('Signal reacquired · resources preserved'); | |
| } | |
| // ---------- Input ---------- | |
| const keys = {}; | |
| let started = false; | |
| let locked = false; | |
| addEventListener('keydown', (event) => { | |
| keys[event.code] = true; | |
| if (/^Digit[1-6]$/.test(event.code)) selectSlot(Number(event.code.slice(5)) - 1); | |
| if (event.code === 'KeyQ') selectSlot(selectedSlot - 1); | |
| if (event.code === 'KeyE') selectSlot(selectedSlot + 1); | |
| if (event.code === 'Space') event.preventDefault(); | |
| }); | |
| addEventListener('keyup', (event) => { keys[event.code] = false; }); | |
| addEventListener('wheel', (event) => { | |
| if (!locked) return; | |
| selectSlot(selectedSlot + (event.deltaY > 0 ? 1 : -1)); | |
| }, { passive: true }); | |
| addEventListener('mousemove', (event) => { | |
| if (!locked || !player.alive) return; | |
| player.yaw -= event.movementX * 0.00215; | |
| player.pitch -= event.movementY * 0.00215; | |
| player.pitch = Math.max(-Math.PI / 2 + 0.03, Math.min(Math.PI / 2 - 0.03, player.pitch)); | |
| }); | |
| addEventListener('mousedown', (event) => { | |
| if (!locked || !player.alive) return; | |
| if (event.button === 0) { | |
| leftHeld = true; | |
| if (mobTarget) hitMob(mobTarget); | |
| } | |
| if (event.button === 2) placeSelectedBlock(); | |
| }); | |
| addEventListener('mouseup', (event) => { | |
| if (event.button === 0) { | |
| leftHeld = false; | |
| miningProgress = 0; | |
| miningKey = ''; | |
| } | |
| }); | |
| addEventListener('contextmenu', (event) => event.preventDefault()); | |
| function requestGameLock() { | |
| ensureAudio(); | |
| started = true; | |
| startScreen.classList.remove('active'); | |
| pauseScreen.classList.remove('active'); | |
| renderer.domElement.requestPointerLock(); | |
| } | |
| $('#start-button').addEventListener('click', requestGameLock); | |
| $('#resume-button').addEventListener('click', requestGameLock); | |
| $('#respawn-button').addEventListener('click', respawn); | |
| renderer.domElement.addEventListener('click', () => { | |
| if (started && !locked && player.alive && !pauseScreen.classList.contains('active')) renderer.domElement.requestPointerLock(); | |
| }); | |
| document.addEventListener('pointerlockchange', () => { | |
| locked = document.pointerLockElement === renderer.domElement; | |
| leftHeld = false; | |
| miningProgress = 0; | |
| if (started && player.alive) pauseScreen.classList.toggle('active', !locked); | |
| }); | |
| // ---------- Game simulation ---------- | |
| const clock = new THREE.Clock(); | |
| let elapsed = 0; | |
| function updatePlayer(dt) { | |
| if (!locked || !player.alive) return; | |
| camera.rotation.y = player.yaw; | |
| camera.rotation.x = player.pitch; | |
| const forwardInput = (keys.KeyW ? 1 : 0) - (keys.KeyS ? 1 : 0); | |
| const rightInput = (keys.KeyD ? 1 : 0) - (keys.KeyA ? 1 : 0); | |
| const moving = forwardInput !== 0 || rightInput !== 0; | |
| const sprinting = keys.ShiftLeft || keys.ShiftRight; | |
| const speed = sprinting ? 6.25 : 4.35; | |
| const forward = new THREE.Vector3(-Math.sin(player.yaw), 0, -Math.cos(player.yaw)); | |
| const right = new THREE.Vector3(Math.cos(player.yaw), 0, -Math.sin(player.yaw)); | |
| const desired = forward.multiplyScalar(forwardInput).add(right.multiplyScalar(rightInput)); | |
| if (desired.lengthSq() > 0) desired.normalize().multiplyScalar(speed); | |
| const control = player.onGround ? 14 : 5.5; | |
| player.velocity.x = THREE.MathUtils.damp(player.velocity.x, desired.x, control, dt); | |
| player.velocity.z = THREE.MathUtils.damp(player.velocity.z, desired.z, control, dt); | |
| player.onGround = false; | |
| if (keys.Space && collidesAt(camera.position.clone().add(new THREE.Vector3(0, -0.055, 0)))) { | |
| player.velocity.y = 7.15; | |
| tone(145, 0.07, 'sine', 0.012, 45); | |
| } | |
| player.velocity.y -= 20.5 * dt; | |
| player.velocity.y = Math.max(player.velocity.y, -22); | |
| movePlayerAxis('x', player.velocity.x * dt); | |
| movePlayerAxis('z', player.velocity.z * dt); | |
| movePlayerAxis('y', player.velocity.y * dt); | |
| camera.position.x = THREE.MathUtils.clamp(camera.position.x, -WORLD_HALF + 0.35, WORLD_HALF - 0.35); | |
| camera.position.z = THREE.MathUtils.clamp(camera.position.z, -WORLD_HALF + 0.35, WORLD_HALF - 0.35); | |
| if (camera.position.y < -8) damagePlayer(10, camera.position.clone().add(new THREE.Vector3(1, 0, 0))); | |
| const horizontalSpeed = Math.hypot(player.velocity.x, player.velocity.z); | |
| if (moving && player.onGround && horizontalSpeed > 0.5) { | |
| player.bob += dt * horizontalSpeed * 1.9; | |
| player.stepTimer -= dt; | |
| if (player.stepTimer <= 0) { | |
| playStep(); | |
| player.stepTimer = sprinting ? 0.29 : 0.39; | |
| } | |
| } else { | |
| player.stepTimer = 0; | |
| } | |
| player.attackCooldown = Math.max(0, player.attackCooldown - dt); | |
| player.invulnerable = Math.max(0, player.invulnerable - dt); | |
| const bobX = moving && player.onGround ? Math.sin(player.bob) * 0.012 : 0; | |
| const bobY = moving && player.onGround ? Math.abs(Math.cos(player.bob)) * 0.012 : 0; | |
| const swing = hand.userData.swing; | |
| if (swing > 0) hand.userData.swing = Math.max(0, swing - dt * 4.8); | |
| const arc = Math.sin((1 - hand.userData.swing) * Math.PI); | |
| hand.position.set(0.43 + bobX, -0.38 - bobY - arc * 0.2, -0.72 + arc * 0.09); | |
| hand.rotation.set(-arc * 0.75, arc * 0.25, -arc * 0.35); | |
| } | |
| function updateMining(dt) { | |
| const mineWrap = $('#mine-wrap'); | |
| if (!locked || !leftHeld || mobTarget || !blockTarget || blockTarget.type === 'bedrock') { | |
| miningProgress = 0; | |
| miningKey = ''; | |
| mineWrap.classList.remove('active'); | |
| return; | |
| } | |
| const key = keyOf(blockTarget.x, blockTarget.y, blockTarget.z); | |
| if (key !== miningKey) { | |
| miningKey = key; | |
| miningProgress = 0; | |
| } | |
| miningProgress += dt; | |
| const hardness = BLOCKS[blockTarget.type].hardness; | |
| const fraction = Math.min(1, miningProgress / hardness); | |
| mineWrap.classList.add('active'); | |
| $('#mine-fill').style.width = `${fraction * 100}%`; | |
| hand.userData.swing = Math.max(hand.userData.swing, 0.18 + Math.sin(elapsed * 18) * 0.08); | |
| if (fraction >= 1) mineBlock({ ...blockTarget }); | |
| } | |
| function updateMobs(dt) { | |
| if (!player.alive) return; | |
| const playerFeet = camera.position.clone(); | |
| playerFeet.y -= EYE_HEIGHT; | |
| for (const mob of mobs) { | |
| if (mob.dead) continue; | |
| mob.attackCooldown -= dt; | |
| mob.hurt = Math.max(0, mob.hurt - dt); | |
| mob.group.scale.setScalar(mob.hurt > 0 ? 1.08 : 1); | |
| mob.head.material = mob.hurt > 0 ? mobEyeMaterial : mobBodyMaterial; | |
| const toPlayer = playerFeet.clone().sub(mob.group.position); | |
| const distance = Math.hypot(toPlayer.x, toPlayer.z); | |
| let angle; | |
| let speed = mob.speed; | |
| if (distance < 16) { | |
| angle = Math.atan2(toPlayer.x, toPlayer.z); | |
| speed *= distance < 2 ? 0.55 : 1; | |
| } else { | |
| mob.wanderTimer -= dt; | |
| if (mob.wanderTimer <= 0) { | |
| mob.wander += (hash3(elapsed, mob.group.position.x, mob.group.position.z) - 0.5) * 1.8; | |
| mob.wanderTimer = 1.5 + hash3(mob.group.position.z, elapsed, mob.group.position.x) * 3; | |
| } | |
| angle = mob.wander; | |
| speed *= 0.42; | |
| } | |
| const dx = Math.sin(angle) * speed * dt; | |
| const dz = Math.cos(angle) * speed * dt; | |
| const nx = THREE.MathUtils.clamp(mob.group.position.x + dx, -WORLD_HALF + 0.6, WORLD_HALF - 0.6); | |
| const nz = THREE.MathUtils.clamp(mob.group.position.z + dz, -WORLD_HALF + 0.6, WORLD_HALF - 0.6); | |
| const nextGround = highestSolid(nx, nz) + 1; | |
| if (Math.abs(nextGround - mob.group.position.y) <= 1.05 && !getBlock(Math.floor(nx), Math.floor(nextGround + 0.7), Math.floor(nz))) { | |
| mob.group.position.x = nx; | |
| mob.group.position.z = nz; | |
| mob.group.position.y = THREE.MathUtils.damp(mob.group.position.y, nextGround, 10, dt); | |
| } else { | |
| mob.wander += Math.PI * 0.6; | |
| } | |
| mob.group.rotation.y = angle + Math.PI; | |
| const walk = elapsed * speed * 7 + mob.phase; | |
| const legSwing = Math.sin(walk) * 0.55; | |
| mob.leftLeg.rotation.x = legSwing; | |
| mob.rightLeg.rotation.x = -legSwing; | |
| mob.leftArm.rotation.x = -0.72 - legSwing * 0.3; | |
| mob.rightArm.rotation.x = -0.72 + legSwing * 0.3; | |
| mob.body.position.y = 1.06 + Math.abs(Math.sin(walk)) * 0.025; | |
| if (distance < 1.35 && Math.abs(toPlayer.y) < 2 && mob.attackCooldown <= 0 && locked) { | |
| mob.attackCooldown = 1.05; | |
| damagePlayer(1, mob.group.position); | |
| } | |
| } | |
| } | |
| function updateAtmosphere(dt) { | |
| const cycle = (0.16 + elapsed / 300) % 1; | |
| const angle = cycle * Math.PI * 2; | |
| const elevation = Math.sin(angle); | |
| const daylight = THREE.MathUtils.clamp(elevation * 0.7 + 0.48, 0.1, 1); | |
| const skyDay = new THREE.Color('#84c9ed'); | |
| const skyDusk = new THREE.Color('#d57d69'); | |
| const skyNight = new THREE.Color('#10192f'); | |
| const sky = daylight > 0.5 ? skyDusk.clone().lerp(skyDay, (daylight - 0.5) * 2) : skyNight.clone().lerp(skyDusk, daylight * 2); | |
| scene.background.copy(sky); | |
| scene.fog.color.copy(sky); | |
| hemi.intensity = 0.45 + daylight * 1.25; | |
| sunLight.intensity = 0.25 + daylight * 2.3; | |
| sunLight.color.set(daylight > 0.65 ? '#fff2c6' : '#ff9f71'); | |
| sunLight.position.set(Math.cos(angle) * 30, Math.max(4, elevation * 30), Math.sin(angle) * 24); | |
| sunOrb.position.copy(camera.position).add(new THREE.Vector3(Math.cos(angle) * 55, Math.max(7, elevation * 55), Math.sin(angle) * 50)); | |
| sunOrb.lookAt(camera.position); | |
| starMaterial.opacity = THREE.MathUtils.clamp((0.42 - daylight) * 2.8, 0, 0.85); | |
| stars.position.set(camera.position.x, 0, camera.position.z); | |
| for (const cloud of clouds) { | |
| cloud.position.x += cloud.userData.speed * dt; | |
| if (cloud.position.x > 42) cloud.position.x = -42; | |
| } | |
| const totalMinutes = Math.floor(cycle * 24 * 60); | |
| const hour = String(Math.floor(totalMinutes / 60)).padStart(2, '0'); | |
| const minute = String(totalMinutes % 60).padStart(2, '0'); | |
| $('#time-chip').textContent = `DAY 01 · ${hour}:${minute}`; | |
| } | |
| function updateHud() { | |
| $('#coord-chip').textContent = `XYZ ${Math.floor(camera.position.x)} / ${Math.floor(camera.position.y - EYE_HEIGHT)} / ${Math.floor(camera.position.z)}`; | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const dt = Math.min(clock.getDelta(), 0.05); | |
| elapsed += dt; | |
| if (locked && player.alive) { | |
| updatePlayer(dt); | |
| updateTarget(); | |
| updateMining(dt); | |
| updateMobs(dt); | |
| } | |
| updateParticles(dt); | |
| updateAtmosphere(dt); | |
| updateHud(); | |
| renderer.render(scene, camera); | |
| } | |
| // ---------- Boot ---------- | |
| generateWorld(); | |
| rebuildTerrain(); | |
| camera.position.copy(respawnPosition()); | |
| camera.rotation.set(player.pitch, player.yaw, 0); | |
| spawnInitialMobs(); | |
| updateHearts(); | |
| updateHotbar(); | |
| updateMission(); | |
| hud.classList.add('visible'); | |
| animate(); | |
| addEventListener('resize', () => { | |
| camera.aspect = innerWidth / innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(innerWidth, innerHeight); | |
| renderer.setPixelRatio(Math.min(devicePixelRatio, 1.6)); | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment