| | <!doctype html> | | | <html lang="en"> | | | <head> |
| | <meta charset="utf-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1"> |
| | <meta name="theme-color" content="#151d1b"> |
| | <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 40 40'%3E%3Crect width='40' height='40' rx='8' fill='%23202b22'/%3E%3Cpath d='M20 5 35 31H5ZM20 12v23M12 25h16' fill='none' stroke='%23d9ba77' stroke-width='2'/%3E%3C/svg%3E"> | | | <title>Wilderland — The Green Reach</title> | | | <style> | | | @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@400;500;600;700&family=Marcellus&display=swap'); | | | :root{--bg:#141c19;--panel:#1c2521;--panel2:#232d26;--line:#3a4232;--text:#edeede;--muted:#969f8e;--gold:#d9ba77;--green:#9cba77;--serif:'Marcellus',Georgia,serif;--sans:'DM Sans',system-ui,sans-serif} | | | *{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--text);font:13px var(--sans);overflow:hidden}button{font:inherit;color:inherit;cursor:pointer}button:focus-visible{outline:2px solid var(--gold);outline-offset:3px}button:disabled{cursor:not-allowed;opacity:.42}button svg{pointer-events:none}.icon{width:20px;height:20px;fill:none;stroke:currentColor;stroke-width:1.6;stroke-linecap:round;stroke-linejoin:round;flex-shrink:0} | | | header{height:78px;display:flex;align-items:center;justify-content:space-between;padding:0 28px;border-bottom:1px solid #48513b66;background:linear-gradient(110deg,#1f2822,#17201d);position:relative;z-index:4;gap:20px}.brand{display:flex;align-items:center;gap:13px;min-width:252px}.brand-mark{width:38px;height:45px;color:var(--gold)}.brand-name{font:27px var(--serif);letter-spacing:1.4px;line-height:1.1}.brand-sub{font-size:8px;letter-spacing:3px;color:#a8ae99;margin-top:6px}.resources{display:flex;align-items:center;gap:33px}.resource{display:flex;gap:11px;align-items:center;min-width:94px}.resource .icon{width:27px;height:27px;color:var(--gold)}.resource.wood .icon{color:#c8b791}.resource.pop .icon{color:#b5c4a0}.resource-value{font-size:18px;line-height:1.2;font-weight:500;font-variant-numeric:tabular-nums}.resource-label{font-size:9px;letter-spacing:1.7px;text-transform:uppercase;color:var(--muted);margin-top:4px}.resource-plus{font-size:10px;color:var(--green);margin-left:5px}.header-right{display:flex;gap:18px;align-items:center}.season{color:var(--muted);display:flex;align-items:center;gap:8px;font-size:11px}.season .icon{color:var(--gold);width:17px}.header-divider{height:25px;width:1px;background:var(--line)}.text-button{background:none;border:0;display:flex;gap:8px;align-items:center;color:#c3c8b7;font-size:11px;padding:7px 0}.text-button:hover{color:var(--gold)}.square-button{width:33px;height:33px;border:1px solid #49523d;border-radius:5px;background:#252e25;display:grid;place-items:center}.square-button:hover{background:#384232}.square-button .icon{width:16px;height:16px} | | | #battlefield{position:absolute;inset:78px 0 0;background:#202d25;overflow:hidden}#world{width:100%;height:100%;display:block;touch-action:none;cursor:default}.vignette{position:absolute;inset:0;pointer-events:none;box-shadow:inset 0 0 150px 10px #0a14196b}.region-name{position:absolute;left:50%;top:23px;transform:translateX(-50%);display:flex;align-items:center;gap:13px;color:#c4cbb0;letter-spacing:3px;font-size:9px;text-shadow:0 2px 5px #000}.region-name:before,.region-name:after{content:'';width:38px;height:1px;background:#bac29b44}.region-name .icon{width:12px;height:12px;color:var(--gold)}.objectives{position:absolute;top:25px;left:24px;width:239px;background:linear-gradient(130deg,#1b251eef,#19241bdd);border:1px solid #64744844;border-radius:7px;padding:18px 18px 16px;box-shadow:0 6px 28px #08120833;backdrop-filter:blur(10px)}.eyebrow{font-size:9px;letter-spacing:1.8px;text-transform:uppercase;color:var(--gold);display:flex;align-items:center;gap:7px}.eyebrow .icon{width:13px;height:13px}.objectives h1{font:20px var(--serif);margin:11px 0 7px}.objectives p{font-size:11px;line-height:1.65;color:#a9b29a;margin:0 0 17px}.quest{display:flex;gap:10px;align-items:center;margin:13px 0;color:#b9c1ac;font-size:10px}.check{width:13px;height:13px;border:1px solid #657151;border-radius:3px;flex-shrink:0;display:grid;place-items:center;font-size:10px;color:#142017}.quest.done .check{background:var(--green);border-color:var(--green)}.quest.done{color:#d7e0c8}.quest-value{margin-left:auto;color:#89957d;font-size:9px}.quest-progress{height:3px;background:#3a4631;border-radius:2px;margin-top:16px;overflow:hidden}.quest-progress>div{background:var(--green);height:100%;width:0;transition:width .5s}.quest-foot{display:flex;justify-content:space-between;margin-top:9px;color:#87957e;font-size:9px}.quest-foot span:last-child{color:#bfd0a8}.collapse-quest{position:absolute;top:13px;right:12px;border:0;background:none;color:#8e9a80;padding:4px}.collapse-quest .icon{width:13px;height:13px}.objectives.collapsed .quest-body{display:none}.objectives.collapsed h1{margin-bottom:0}.map-controls{position:absolute;right:24px;top:26px;display:flex;flex-direction:column;gap:7px}.map-controls button{background:#1c2820df;border-color:#5c6a434d;color:#bcc6a5}.map-controls .control-gap{height:6px}.map-controls button.active{color:var(--gold);border-color:#ab925b}.camera-note{position:absolute;right:23px;top:198px;color:#8f9b82;writing-mode:vertical-rl;font-size:8px;letter-spacing:2px}.map-bottom{position:absolute;left:26px;bottom:244px;display:flex;align-items:center;gap:8px;color:#bbc5a9;font-size:10px}.live-dot{width:5px;height:5px;background:#a8c485;border-radius:50%;box-shadow:0 0 8px #b3d28b55}.map-bottom .sep{color:#647354;margin:0 4px}.world-hint{position:absolute;bottom:251px;left:50%;transform:translateX(-50%);padding:9px 16px;border:1px solid #abb88038;border-radius:5px;background:#1d2a21dd;color:#e0e6cf;font-size:11px;pointer-events:none;white-space:nowrap;display:none}.world-hint.show{display:block}.world-hint kbd{margin-left:9px;color:var(--gold)} | | | .hud{position:absolute;bottom:39px;left:20px;right:20px;height:188px;background:linear-gradient(110deg,#202a23f7,#1a241ff7);border:1px solid #59604488;border-radius:8px;display:grid;grid-template-columns:260px minmax(180px,.82fr) minmax(340px,1.35fr) 200px;box-shadow:0 8px 35px #06100766;backdrop-filter:blur(14px)}.hud-section{padding:17px 19px;position:relative;min-width:0}.hud-section+.hud-section{border-left:1px solid #52603d55}.section-heading{font-size:9px;letter-spacing:1.7px;color:#a7af96;display:flex;justify-content:space-between;align-items:center;height:15px;margin-bottom:13px}.section-heading small{font-size:8px;color:#76866b;letter-spacing:.5px}.selection-main{display:flex;align-items:center;gap:12px}.portrait{height:71px;width:76px;border:1px solid #786a444d;border-radius:5px;background:radial-gradient(ellipse at 50% 90%,#63754455,#151e19 85%);image-rendering:auto;flex-shrink:0}.selection-name{font:19px var(--serif);margin-bottom:6px}.selection-main>div{min-width:0}.selection-type{color:#929f84;font-size:9px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.health-bar{background:#384730;height:4px;margin-top:10px;border-radius:2px;width:119px;overflow:hidden}.health-fill{background:#a4be79;height:100%;transition:width .3s}.health-text{display:flex;justify-content:space-between;color:#93a67c;font-size:8px;margin-top:5px}.selection-desc{font-size:10px;line-height:1.6;color:#9ba68e;margin-top:13px;max-width:230px}.action-buttons{display:flex;gap:8px}.action-card{height:81px;width:77px;position:relative;background:linear-gradient(#3a432d66,#2c35275c);border:1px solid #66744755;border-radius:4px;display:flex;flex-direction:column;align-items:center;justify-content:center;transition:background .15s,border .15s}.action-card:hover{background:#455234;border-color:#99a469}.action-card .icon{height:29px;width:29px;margin:1px 0 7px;color:#c5cca6}.action-label{font-size:9px;color:#d3d9c3}.action-cost{font-size:8px;color:var(--gold);margin-top:4px}.keycap{position:absolute;right:5px;top:4px;font:8px var(--sans);color:#a3ab8c}.context-note{font-size:9px;color:#7f8e72;margin-top:12px;display:flex;gap:6px;align-items:center;white-space:nowrap}.context-note .icon{width:12px;height:12px}.queue-label{color:var(--gold);letter-spacing:0;font-size:9px}.queue-track{height:3px;position:absolute;bottom:0;left:0;background:var(--gold);border-radius:0 0 3px 3px}.build-buttons{display:grid;grid-template-columns:repeat(4,1fr);gap:8px}.build-card{height:107px;border:1px solid #6674474d;background:#33402a45;border-radius:5px;padding:2px 2px 8px;position:relative;text-align:center;transition:background .15s,border .15s}.build-card:hover{background:#45533488;border-color:#98a56a}.build-card.active{border-color:var(--gold);background:#75643b44}.build-card img{width:68px;height:62px;display:block;object-fit:contain;margin:0 auto 1px;pointer-events:none}.build-name{font-size:10px;display:block;margin-bottom:6px}.build-cost{display:flex;justify-content:center;align-items:center;gap:4px;font-size:8px;color:#c4b580}.build-cost .icon{height:10px;width:10px}.build-cost .wood-cost{color:#aeba93;margin-left:3px}.build-section .context-note{margin-top:8px;font-size:8px}.minimap-section{padding:13px}.minimap-heading{font-size:8px;letter-spacing:1.7px;color:#a8b595;display:flex;justify-content:space-between;margin-bottom:8px}.minimap-heading span:last-child{color:var(--gold);letter-spacing:0}.minimap-wrap{height:133px;position:relative;overflow:hidden;background:#111d17;border:1px solid #626f445e;border-radius:3px}.minimap-wrap:after{content:'N';position:absolute;right:7px;top:5px;font:9px var(--serif);color:#99ae7d;pointer-events:none}#minimap{width:100%;height:100%;display:block;cursor:crosshair}.minimap-caption{font-size:8px;color:#6e8066;text-align:center;letter-spacing:.3px;margin-top:6px}.footer{height:39px;position:absolute;left:25px;right:25px;bottom:0;display:flex;justify-content:space-between;align-items:center;color:#839077;font-size:9px;gap:12px}.shortcuts{display:flex;align-items:center;gap:19px}.shortcut{display:flex;align-items:center;gap:6px}kbd{font:8px var(--sans);display:inline-flex;align-items:center;justify-content:center;color:#b2bda1;min-width:17px;height:16px;padding:0 4px;border:1px solid #5a664447;border-radius:3px;background:#29352488}.shortcut .icon{width:12px;height:12px;color:#a5b291}.footer-status{display:flex;align-items:center;gap:7px;font-size:8px;color:#748368}.footer-status .icon{width:11px;height:11px}.toast-stack{position:absolute;right:73px;top:28px;display:flex;flex-direction:column;gap:8px;pointer-events:none;z-index:7}.toast{max-width:300px;background:#1e2b22ee;border:1px solid #78885366;border-left:2px solid var(--gold);border-radius:4px;padding:12px 16px;font-size:11px;color:#dde4cd;animation:toast-in .2s ease-out;box-shadow:0 4px 15px #0003}.toast.error{border-left-color:#d49679}@keyframes toast-in{from{opacity:0;transform:translateY(-8px)}to{opacity:1;transform:translateY(0)}} | | | .modal-backdrop{position:fixed;inset:0;background:#08120bd4;backdrop-filter:blur(6px);display:none;align-items:center;justify-content:center;z-index:15;padding:20px}.modal-backdrop.open{display:flex}.modal{position:relative;width:530px;max-height:90vh;overflow:auto;background:linear-gradient(145deg,#293426,#19261e);border:1px solid #7c845151;border-radius:10px;padding:34px;box-shadow:0 25px 100px #0008}.modal h2{font:32px var(--serif);margin:13px 0 10px}.modal-intro{color:#b1bca3;font-size:12px;line-height:1.8}.modal-close{position:absolute;right:18px;top:18px;border:0;background:transparent;color:#a6b491;font-size:22px}.help-grid{display:grid;grid-template-columns:1fr 1fr;gap:20px 26px;margin:27px 0}.help-item{font-size:11px;color:#abb99c;line-height:1.7}.help-item b{display:block;color:#e1e8d2;font-size:12px;margin-bottom:5px}.help-item .icon{width:18px;height:18px;vertical-align:middle;margin-right:6px;color:var(--gold)}.primary-button{padding:12px 20px;background:#bdab72;border:1px solid #d3c28d;color:#1d281d;border-radius:4px;font-weight:600;font-size:12px}.primary-button:hover{background:#d3c18a}.secondary-button{padding:12px 20px;background:#394630;border:1px solid #5e6c45;color:#c7d3b7;border-radius:4px;font-size:12px}.modal-actions{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}.help-bottom{border-top:1px solid #697c404d;padding-top:17px;font-size:10px;color:#96a883;line-height:1.9}.-info{color:#a7b69a;font-size:11px;line-height:1.8;margin:19px 0}.mobile-tip{display:none} | | | @media(min-width:1650px){.hud{grid-template-columns:300px minmax(230px,1fr) minmax(390px,1.5fr) 225px;height:198px}.hud-section{padding:20px 24px}.minimap-section{padding:13px}.minimap-wrap{height:142px}.map-bottom{bottom:255px}.world-hint{bottom:259px}} | | | @media(max-width:1150px){header{padding:0 20px}.brand{min-width:206px}.brand-name{font-size:24px}.resources{gap:20px}.header-right{gap:12px}.season{display:none}.hud{left:12px;right:12px;grid-template-columns:218px minmax(160px,.8fr) minmax(300px,1.4fr) 165px}.hud-section{padding:16px 12px}.minimap-section{padding:12px}.health-bar{width:100px}.portrait{width:63px}.selection-name{font-size:17px}.build-buttons{gap:5px}.build-card img{width:60px}.action-card{width:66px}.shortcuts{gap:12px}.selection-desc{font-size:9px}.footer{left:18px;right:18px}.footer-status{display:none}.objectives{width:220px;left:20px}.build-section .context-note{font-size:7px}} | | | @media(max-width:900px){header{height:65px;padding:0 14px;gap:14px}.brand{min-width:0}.brand-name{font-size:21px}.brand-sub{font-size:6px;letter-spacing:2px}.brand-mark{width:28px}.resources{gap:15px}.resource{min-width:63px;gap:7px}.resource .icon{width:21px;height:21px}.resource-value{font-size:15px}.resource-label{font-size:7px}.resource-plus{display:none}.text-button span,.header-divider{display:none}#battlefield{top:65px}.hud{height:180px;grid-template-columns:190px 145px minmax(275px,1fr)}.minimap-section{position:absolute;right:0;bottom:196px;background:#1c2820dd;width:158px;border:1px solid #59604466!important;border-radius:6px}.minimap-wrap{height:110px}.minimap-caption{display:none}.map-bottom{bottom:235px}.world-hint{bottom:236px}.hud-section{padding:15px 10px}.selection-main{gap:8px}.portrait{width:56px;height:63px}.selection-name{font-size:16px}.health-bar{width:91px}.section-heading{font-size:8px;letter-spacing:1.2px}.action-card{width:59px}.action-buttons{gap:5px}.selection-desc{font-size:9px}.build-name{font-size:9px}.build-card img{width:55px}.context-note{font-size:8px}.shortcuts{gap:10px}.shortcut{font-size:8px}.shortcut.optional{display:none}.objectives{top:19px}.region-name{left:58%;font-size:8px}.map-controls{right:15px;top:18px}.camera-note{display:none}} | | | @media(max-width:640px){header{height:61px;gap:8px}.brand{gap:6px}.brand-mark{width:24px}.brand-name{font-size:18px;letter-spacing:.5px}.brand-sub{display:none}.resources{gap:10px}.resource{min-width:0;gap:4px}.resource .icon{width:16px;height:16px}.resource-value{font-size:13px}.resource-label{font-size:6px;letter-spacing:1px}.header-right{gap:7px}.header-right .text-button{display:none}.square-button{width:29px;height:29px}#battlefield{top:61px}.objectives{top:12px;left:12px;padding:13px;width:190px}.objectives h1{font-size:17px}.objectives p{font-size:10px;margin-bottom:12px}.quest{font-size:9px;margin:10px 0}.eyebrow{font-size:8px}.region-name{display:none}.hud{height:233px;bottom:32px;left:8px;right:8px;grid-template-columns:1fr 1fr;grid-template-rows:105px 127px}.hud-section{padding:10px 12px}.selection-section .section-heading,.selection-desc,.health-text{display:none}.selection-main{height:82px}.selection-name{font-size:16px}.health-bar{width:90px}.portrait{height:65px;width:54px}.actions-section .section-heading{margin-bottom:6px}.actions-section .context-note{display:none}.action-card{height:61px;width:60px}.action-card .icon{width:20px;height:20px;margin:0 0 3px}.action-label{font-size:8px}.action-cost{font-size:7px;margin-top:2px}.build-section{grid-column:1/3;border-left:0!important;border-top:1px solid #52603d55}.build-section .section-heading{margin-bottom:7px}.build-card{height:80px;padding:0 2px 4px}.build-card img{height:44px;width:51px;margin-bottom:0}.build-name{margin-bottom:3px}.build-section .context-note{display:none}.minimap-section{bottom:250px;right:0;width:119px;padding:8px}.minimap-wrap{height:83px}.minimap-heading{font-size:6px;letter-spacing:.8px;margin-bottom:5px}.map-bottom{left:14px;bottom:281px;font-size:8px}.world-hint{bottom:282px;font-size:9px;max-width:92%;white-space:normal;text-align:center}.footer{height:32px;left:13px;right:13px}.shortcuts{gap:13px}.shortcut.desktop-only{display:none}.mobile-tip{display:flex}.toast-stack{top:12px;right:51px;max-width:220px}.toast{font-size:10px}.modal{padding:25px}.modal h2{font-size:27px}.help-grid{gap:16px}.help-item{font-size:10px}.map-controls{right:12px;top:12px}} | | | </style> | | | </head> | | | <body> | | | <svg width="0" height="0" aria-hidden="true" style="position:absolute"><defs> | | | <symbol id="i-wood" viewBox="0 0 24 24"><path d="m4 14 10-9 7 5-10 9zM4 14v5l7 3v-3M21 10v5l-10 7M7 11l7 5M10 8l7 5"/><ellipse cx="7.5" cy="17" rx="1.1" ry="1.7" transform="rotate(-35 7.5 17)"/></symbol> |
| | <symbol id="i-gold" viewBox="0 0 24 24"><path d="m5 8 5-4h5l5 5-1 9-8 3-7-6zM5 8l7 2 8-1M12 10l-1 11M10 4l2 6M4 15l8-5 7 8"/></symbol> |
| | <symbol id="i-people" viewBox="0 0 24 24"><circle cx="9" cy="7" r="3"/><path d="M3 20v-4a6 6 0 0 1 12 0v4M17 4a3 3 0 0 1 0 6M18 13a5 5 0 0 1 3 5v2M7 20v-4M11 20v-4"/></symbol> |
| | <symbol id="i-worker" viewBox="0 0 32 32"><path d="M6 11h17M9 10V7a6 6 0 0 1 12 0v3M15 2v6M10 12v3a5 5 0 0 0 10 0v-3M5 30v-6c0-4 4-5 10-5s10 1 10 5v6M11 20v6h8v-6M11 26v4M19 26v4M27 10v15M24 11l3-4 4 4"/></symbol> |
| | <symbol id="i-scout" viewBox="0 0 32 32"><path d="m10 14-2-5 8-7 8 7-2 5M10 11l6-3 6 3v5a6 6 0 0 1-12 0zM5 30l2-9 9-2 9 2 2 9M10 21l6 7 6-7M16 28v3M27 5v12m-2-10 2-2 2 2"/></symbol> |
| | <symbol id="i-sword" viewBox="0 0 24 24"><path d="m14 3 7-1-1 7L9 20l-5-5zM5 13l7 7M6 19l-4 3M15 8l3-3"/></symbol> |
| | <symbol id="i-compass" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="m16 8-2 6-6 2 2-6zM12 1v3M12 20v3M1 12h3M20 12h3"/></symbol> |
| | <symbol id="i-flag" viewBox="0 0 24 24"><path d="M5 22V3c5-6 9 6 15 0v10c-6 6-10-6-15 0"/></symbol> |
| | <symbol id="i-sun" viewBox="0 0 24 24"><circle cx="12" cy="12" r="4"/><path d="M12 1v3M12 20v3M1 12h3M20 12h3M4 4l2 2M18 18l2 2M4 20l2-2M18 6l2-2"/></symbol> |
| | <symbol id="i-help" viewBox="0 0 24 24"><circle cx="12" cy="12" r="9"/><path d="M9 8a3 3 0 0 1 6 1c0 2-3 2-3 5M12 17v.1"/></symbol> |
| | <symbol id="i-" viewBox="0 0 24 24"><path d="M8 5v14M16 5v14" stroke-width="3"/></symbol> |
| | <symbol id="i-home" viewBox="0 0 24 24"><path d="m3 11 9-8 9 8M5 10v11h14V10M10 21v-7h4v7"/></symbol> |
| | <symbol id="i-plus" viewBox="0 0 24 24"><path d="M12 5v14M5 12h14"/></symbol> |
| | <symbol id="i-minus" viewBox="0 0 24 24"><path d="M5 12h14"/></symbol> |
| | <symbol id="i-grid" viewBox="0 0 24 24"><path d="M3 3h18v18H3zM3 9h18M3 15h18M9 3v18M15 3v18"/></symbol> |
| | <symbol id="i-arrow" viewBox="0 0 24 24"><path d="m8 4 8 8-8 8"/></symbol> |
| | <symbol id="i-chevron" viewBox="0 0 24 24"><path d="m6 14 6-6 6 6"/></symbol> |
| | <symbol id="i-stop" viewBox="0 0 24 24"><path d="M6 6h12v12H6z"/></symbol> |
| | <symbol id="i-mouse" viewBox="0 0 24 24"><rect x="6" y="2" width="12" height="20" rx="6"/><path d="M12 3v6M6 10h12"/></symbol> |
| | <symbol id="i-save" viewBox="0 0 24 24"><path d="M4 3h13l4 4v14H3V3zM7 3v6h9V3M7 21v-8h10v8M13 3v4"/></symbol> |
| | <symbol id="i-leaf" viewBox="0 0 24 24"><path d="M5 19C-2 9 9 2 21 3c0 11-4 20-14 15M3 22 16 9M8 17v-5M12 13h5"/></symbol> |
| | <symbol id="i-hammer" viewBox="0 0 24 24"><path d="m4 22 10-12M10 5l5-4 7 6-4 5-4-3-2 2-4-3z"/></symbol> |
| | </defs></svg> |
| | <header> | | | <div class="brand"><svg class="brand-mark" viewBox="0 0 44 48" fill="none"><path d="M22 2 40 12v24L22 46 4 36V12Z" stroke="currentColor" opacity=".6"/><path d="M22 7v33M10 31l12-19 12 19M13 28h18M17 21h10" stroke="currentColor" stroke-width="1.5"/><path d="m22 1 3 5-3 5-3-5Z" fill="currentColor"/><circle cx="22" cy="34" r="2" fill="currentColor"/></svg><div><div class="brand-name">Wilderland</div><div class="brand-sub">A WORLD WAITING TO BE YOURS</div></div></div> | | | <div class="resources" aria-label="Settlement resources"> | | | <div class="resource"><svg class="icon"><use href="#i-gold"/></svg><div><div class="resource-value"><span id="gold">500</span><span class="resource-plus" id="gold-rate"></span></div><div class="resource-label">Gold</div></div></div> | | | <div class="resource wood"><svg class="icon"><use href="#i-wood"/></svg><div><div class="resource-value"><span id="wood">350</span><span class="resource-plus" id="wood-rate"></span></div><div class="resource-label">Timber</div></div></div> | | | <div class="resource pop"><svg class="icon"><use href="#i-people"/></svg><div><div class="resource-value"><span id="population">4</span><span style="color:#69795e;font-size:13px"> / <span id="capacity">12</span></span></div><div class="resource-label">Population</div></div></div> | | | </div> | | | <div class="header-right"><div class="season"><svg class="icon"><use href="#i-sun"/></svg><span>Day <span id="day">1</span> · A new beginning</span></div><div class="header-divider"></div><button class="text-button" id="help-button" title="How to play (?)"><svg class="icon"><use href="#i-help"/></svg><span>How to play</span></button><button class="square-button" id="-button" title=" game (P)" aria-label=" game"><svg class="icon"><use href="#i-"/></svg></button></div> | | | </header> |
| | <main id="battlefield"> |
| | <canvas id="world" aria-label="Isometric strategy game map. Click to select units. Right-click to move or gather resources." tabindex="0"></canvas><div class="vignette"></div> |
| | <div class="region-name"><svg class="icon"><use href="#i-leaf"/></svg> THE GREEN REACH</div> |
| | <aside class="objectives" id="objectives"><button class="collapse-quest" id="collapse-quest" title="Toggle objectives" aria-label="Toggle objectives"><svg class="icon"><use href="#i-chevron"/></svg></button><div class="eyebrow"><svg class="icon"><use href="#i-flag"/></svg> CHAPTER I · NEW ROOTS</div><h1>A foothold in the wild</h1><div class="quest-body"><p>Gather what the land offers.<br>Build a home. Discover what lies beyond.</p><div class="quest" id="quest-gather"><span class="check"></span> Gather 200 resources <span class="quest-value" id="gather-count">0 / 200</span></div><div class="quest" id="quest-build"><span class="check"></span> Build a barracks <span class="quest-value" id="barracks-count">0 / 1</span></div><div class="quest" id="quest-explore"><span class="check"></span> Explore the Green Reach <span class="quest-value" id="explore-count">0%</span></div><div class="quest-progress"><div id="exploration-bar"></div></div><div class="quest-foot"><span>LAND DISCOVERED</span><span id="explored-label">0%</span></div></div></aside> | | | <div class="map-controls"><button class="square-button" id="home-button" title="Return to settlement (H)" aria-label="Return to settlement"><svg class="icon"><use href="#i-home"/></svg></button><span class="control-gap"></span><button class="square-button" id="zoom-in" title="Zoom in" aria-label="Zoom in"><svg class="icon"><use href="#i-plus"/></svg></button><button class="square-button" id="zoom-out" title="Zoom out" aria-label="Zoom out"><svg class="icon"><use href="#i-minus"/></svg></button><button class="square-button" id="grid-button" title="Toggle building grid (G)" aria-label="Toggle grid"><svg class="icon"><use href="#i-grid"/></svg></button></div> | | | <div class="camera-note">SCROLL TO EXPLORE</div><div class="toast-stack" id="toasts" aria-live="polite"></div> | | | <div class="map-bottom"><span class="live-dot"></span><span id="world-state">A peaceful frontier</span><span class="sep">·</span><span id="clock">00:00</span><span class="sep">·</span><span id="zoom-label">100%</span></div> |
| | <div class="world-hint" id="world-hint"></div> |
| | <section class="hud" aria-label="Settlement commands"> |
| | <div class="hud-section selection-section"><div class="section-heading">YOUR SELECTION <small id="selection-count">01</small></div><div class="selection-main"><img class="portrait" id="portrait" alt="Selected building"><div><div class="selection-name" id="selection-name">Town Hall</div><div class="selection-type" id="selection-type">Settlement · Main building</div><div class="health-bar"><div class="health-fill" id="health-fill" style="width:100%"></div></div><div class="health-text"><span id="health-text">1,500 / 1,500</span><span id="selection-badge">◆</span></div></div></div><div class="selection-desc" id="selection-desc">The heart of your new settlement.<br>Train workers and store your resources.</div></div> | | | <div class="hud-section actions-section"><div class="section-heading"><span id="action-title">RECRUIT & COMMAND</span><span id="queue-label" class="queue-label"></span></div><div class="action-buttons" id="actions"></div><div class="context-note" id="context-note"><svg class="icon"><use href="#i-people"/></svg>Every great settlement starts small.</div></div> | | | <div class="hud-section build-section"><div class="section-heading">BUILD YOUR SETTLEMENT <small>SELECT & PLACE</small></div><div class="build-buttons" id="build-buttons"></div><div class="context-note"><svg class="icon"><use href="#i-hammer"/></svg> A nearby worker will begin construction.</div></div> | | | <div class="hud-section minimap-section"><div class="minimap-heading"><span>THE GREEN REACH</span><span id="minimap-percent">0%</span></div><div class="minimap-wrap"><canvas id="minimap" aria-label="Minimap. Click to navigate." tabindex="0"></canvas></div><div class="minimap-caption">Click the map to travel</div></div> | | | </section> | | | <footer class="footer"><div class="shortcuts"><span class="shortcut desktop-only"><svg class="icon"><use href="#i-mouse"/></svg> Click to select</span><span class="shortcut desktop-only"><svg class="icon"><use href="#i-mouse"/></svg> Right-click to command</span><span class="shortcut desktop-only"><kbd>DRAG</kbd> Select units</span><span class="shortcut desktop-only"><kbd>W A S D</kbd> Pan map</span><span class="shortcut optional"><kbd>SPACE</kbd> Find selection</span><span class="shortcut optional"><kbd>?</kbd> Help</span><span class="shortcut mobile-tip">Tap to select · Tap ground to move</span><span class="shortcut"><kbd>ESC</kbd> Cancel</span></div><span class="footer-status" id="save-status"><svg class="icon"><use href="#i-save"/></svg> Your world saves automatically</span></footer> | | | </main> | | | <div class="modal-backdrop" id="help-modal" role="dialog" aria-modal="true" aria-labelledby="help-title"><div class="modal"><button class="modal-close" data-close="help-modal" aria-label="Close help">×</button><div class="eyebrow">YOUR FIELD GUIDE</div><h2 id="help-title">A world of your own.</h2><p class="modal-intro">An unhurried strategy game. No enemies, no rush. Grow a settlement and uncover every corner of the Green Reach.</p><div class="help-grid"><div class="help-item"><b><svg class="icon"><use href="#i-mouse"/></svg> Lead your people</b>Click a unit to select it, or drag a box around several. Shift-click adds to your selection. Right-click the ground to move. On touch screens, tap the ground.</div><div class="help-item"><b><svg class="icon"><use href="#i-wood"/></svg> Gather resources</b>Select workers, then right-click a tree or gold deposit. They'll gather and bring resources home automatically. Each trip delivers 10 resources.</div><div class="help-item"><b><svg class="icon"><use href="#i-hammer"/></svg> Grow your settlement</b>Choose a building, then click a clear, discovered tile. A worker will construct it. Houses add 6 population. Sawmills accept timber and make gathering faster.</div><div class="help-item"><b><svg class="icon"><use href="#i-compass"/></svg> Venture beyond</b>Train scouts at a barracks and choose Explore (E). They automatically uncover the whole map. Watchtowers reveal a wide area. Soldiers can explore too.</div></div><div class="help-bottom"><kbd>W A S D</kbd> / <kbd>↑ ↓ ← →</kbd> Pan · Scroll to zoom · Middle-drag to pan<br><kbd>H</kbd> Home · <kbd>SPACE</kbd> Center selection · <kbd>I</kbd> Idle worker · <kbd>G</kbd> Grid · <kbd>P</kbd> <br><kbd>1–4</kbd> Build · <kbd>R</kbd> Train worker / scout · <kbd>E</kbd> Explore · <kbd>X</kbd> Stop · <kbd>ESC</kbd> Cancel<br>Your progress is saved in this browser. Manage your world from the menu.</div><div class="modal-actions"><button class="primary-button" data-close="help-modal">Make yourself at home <span style="margin-left:15px">→</span></button></div></div></div> | | | <div class="modal-backdrop" id="-modal" role="dialog" aria-modal="true" aria-labelledby="-title"><div class="modal"><div class="eyebrow">TAKE A BREATHER</div><h2 id="-title">The wild can wait.</h2><p class="modal-intro">Your settlement is d.</p><p class="-info" id="-info"></p><button class="text-button" id="-help"><svg class="icon"><use href="#i-help"/></svg>Open the field guide</button><div class="modal-actions"><button class="primary-button" id="resume-button">Return to the Reach →</button><button class="secondary-button" id="save-button">Save world</button><button class="secondary-button" id="new-button">New world</button></div><p id="reset-warning" style="display:none;color:#d6b393;font-size:11px;line-height:1.7">Starting a new world replaces your saved settlement. <button class="text-button" id="confirm-new" style="color:#e2b07f;display:inline">Start a new world</button> · <button class="text-button" id="cancel-new" style="display:inline">Keep this world</button></p></div></div> | | | <div class="modal-backdrop" id="victory-modal" role="dialog" aria-modal="true" aria-labelledby="victory-title"><div class="modal"><div class="eyebrow"><svg class="icon"><use href="#i-flag"/></svg> CHAPTER COMPLETE</div><h2 id="victory-title">From a spark, a settlement.</h2><p class="modal-intro">Every corner of the Green Reach is on your map. Your people have made a home in the wilderness. This land is yours to keep shaping.</p><div class="modal-actions"><button class="primary-button" data-close="victory-modal">Keep building →</button></div></div></div> | | | <script> | | | 'use strict'; |
| | (() => { |
| | const $=id=>document.getElementById(id), canvas=$('world'), ctx=canvas.getContext('2d'), mini=$('minimap'), mctx=mini.getContext('2d'); |
| | const N=48, TW=64, TH=32, SAVE_KEY='wilderland-world-v1'; |
| | const defs={ |
| | townhall:{name:'Town Hall',w:3,h:3,hp:1500,gold:0,wood:0,time:0,vision:8,desc:'The heart of your new settlement. Train workers and store your resources.',type:'Settlement · Main building'}, |
| | house:{name:'House',w:2,h:2,hp:500,gold:0,wood:80,time:9,vision:5,desc:'A little place to call home. Provides room for 6 more people.',type:'Settlement · Population +6'}, |
| | barracks:{name:'Barracks',w:3,h:2,hp:1000,gold:100,wood:150,time:16,vision:6,desc:'Prepare for the path ahead. Train swift scouts and sturdy soldiers.',type:'Military · Unit training'}, |
| | sawmill:{name:'Sawmill',w:2,h:2,hp:700,gold:60,wood:100,time:12,vision:5,desc:'A local drop-off for timber. All workers gather wood 50% faster.',type:'Economy · Timber gathering'}, |
| | tower:{name:'Watchtower',w:1,h:1,hp:650,gold:60,wood:120,time:12,vision:11,desc:'A watchful eye above the trees. Reveals a wide stretch of the frontier.',type:'Exploration · Extended vision'} |
| | }; |
| | const unitsDef={worker:{name:'Worker',hp:60,gold:50,wood:0,time:6,speed:2.0,vision:5,icon:'worker',desc:'Ready to lend a hand. Right-click trees or gold to gather resources.'},scout:{name:'Scout',hp:90,gold:80,wood:20,time:8,speed:3.4,vision:8,icon:'scout',desc:'A restless spirit with a keen eye. Explore the wilds and chart new ground.'},soldier:{name:'Soldier',hp:120,gold:100,wood:30,time:10,speed:2.4,vision:6,icon:'sword',desc:'A steadfast guardian of your people. Send them to explore the frontier.'}}; | | | let state,cam={x:0,y:0,zoom:1},viewW=0,viewH=0,dpr=1,selected=[],placement=null,mouse={x:0,y:0,inside:false},drag=null,keys=new Set(),showGrid=false,d=false,last=0,uiTimer=0,visionTimer=0,saveTimer=0,effects=[],hover=null,lastSelectionKey='',lastTouch=0; |
| | let terrain=[],nodes=[],buildings=[],units=[],explored=[],visible=[],visionStrength=[],reachable=[],exploredTotal=0,blockers=new Uint8Array(N*N); |
| | let rngSeed=83219; |
| | function random(){rngSeed=(rngSeed*1664525+1013904223)>>>0;return rngSeed/4294967296;} |
| | function hash(x,y,s=0){let v=Math.sin(x*127.1+y*311.7+s*74.13)*43758.5453;return v-Math.floor(v);} |
| | function index(x,y){return y*N+x;} function inMap(x,y){return x>=0&&y>=0&&x<N&&y<N;} |
| | function project(x,y){return{x:(x-y)*TW/2,y:(x+y)*TH/2};} |
| | function screen(x,y){const p=project(x,y);return{x:(p.x-cam.x)*cam.zoom+viewW/2,y:(p.y-cam.y)*cam.zoom+viewH*.42};} |
| | function unproject(sx,sy){const px=(sx-viewW/2)/cam.zoom+cam.x,py=(sy-viewH*.42)/cam.zoom+cam.y;return{x:py/TH+px/TW,y:py/TH-px/TW};} |
| | function home(){const b=buildings.find(b=>b.type==='townhall');if(b){const p=project(b.x+1.5,b.y+1.5);cam.x=p.x;cam.y=p.y+30;}} |
| | function notify(msg,error=false){const el=document.createElement('div');el.className='toast'+(error?' error':'');el.textContent=msg;$('toasts').append(el);setTimeout(()=>el.remove(),4200);} |
| | function icon(name){return '<svg class="icon"><use href="#i-'+name+'"/></svg>';} |
| | function generate(){ |
| | rngSeed=83219;terrain=[];nodes=[];let nid=1; |
| | for(let y=0;y<N;y++)for(let x=0;x<N;x++){ |
| | const river=35+Math.sin(y*.18)*3.1;const water=Math.abs(x-river)<1.25&&y>3&&y<44;const bridge=y===21\|\|y===22; |
| | terrain.push({x,y,water:water&&!bridge,bridge:water&&bridge,r:random(),flowers:random()<.11}); |
| | const dist=Math.hypot(x-21.5,y-22.5),cluster=Math.sin(x*.51)+Math.cos(y*.43)+Math.sin((x+y)*.29); |
| | let tree=!water&&dist>5.5&&(cluster>.65?random()<.49:random()<.045); |
| | if(x===0\|\|y===0\|\|x===47\|\|y===47\|\|x%9===0\|\|y%10===0)tree=false; |
| | if(tree)nodes.push({id:'n'+nid++,type:'wood',x:x+.5,y:y+.5,amount:220+Math.floor(random()*160),r:random()}); |
| | } | | | const goldSites=[[16,20],[26,29],[11,9],[39,13],[8,37],[40,39],[24,5],[18,42]]; | | | for(const [x,y] of goldSites){nodes=nodes.filter(n=>Math.hypot(n.x-x-.5,n.y-y-.5)>1.6);terrain[index(x,y)].water=false;nodes.push({id:'n'+nid++,type:'gold',x:x+.5,y:y+.5,amount:3000,r:random()});} | | | // Guarantee a nearby, distinct starting grove. | | | for(const [x,y] of [[25,19],[26,19],[27,18],[28,19],[26,18],[25,17],[27,17],[29,18],[28,16],[29,16]]){nodes=nodes.filter(n=>Math.floor(n.x)!==x||Math.floor(n.y)!==y);terrain[index(x,y)].water=false;nodes.push({id:'n'+nid++,type:'wood',x:x+.5,y:y+.5,amount:340,r:random()});} | | | } |
| | function newState(){ |
| | generate();state={gold:500,wood:350,time:0,gathered:0,nextId:10,won:false,trained:0}; |
| | buildings=[makeBuilding('townhall',20,21,true,1),makeBuilding('house',23,25,true,2)]; |
| | units=[makeUnit('worker',19.5,23.6,3),makeUnit('worker',22.5,25,4),makeUnit('worker',24,23.9,5),makeUnit('scout',19,26,6)]; |
| | explored=new Array(N*N).fill(false);selected=[{kind:'building',id:1}];effects=[];placement=null;recomputeReachable();updateVision();home();lastSelectionKey=''; |
| | } | | | function makeBuilding(type,x,y,complete,id){const d=defs[type];return{id:id??state.nextId++,type,x,y,progress:complete?1:0,hp:d.hp,queue:[],rally:null};} | | | function makeUnit(type,x,y,id){return{id:id??state.nextId++,type,x,y,hp:unitsDef[type].hp,task:'idle',path:[],target:null,carry:0,carryType:null,work:0,auto:false,animation:random()*10,exploreGoal:null};} |
| | function getEntity(s){return s.kind==='unit'?units.find(u=>u.id===s.id):s.kind==='building'?buildings.find(b=>b.id===s.id):nodes.find(n=>n.id===s.id);} |
| | function populationCap(){return 6+buildings.filter(b=>b.type==='house'&&b.progress>=1).length*6;} |
| | function reservedPop(){return units.length+buildings.reduce((a,b)=>a+b.queue.length,0);} |
| | function rebuildBlockers(){blockers=new Uint8Array(N*N);for(const t of terrain)if(t.water)blockers[index(t.x,t.y)]=1;for(const n of nodes)if(n.amount>0)blockers[index(Math.floor(n.x),Math.floor(n.y))]=1;for(const b of buildings){const d=defs[b.type];for(let y=b.y;y<b.y+d.h;y++)for(let x=b.x;x<b.x+d.w;x++)blockers[index(x,y)]=1;}} |
| | function occupied(x,y){return !inMap(x,y)\|\|blockers[index(x,y)]===1;} |
| | function recomputeReachable(){ |
| | rebuildBlockers(); |
| | reachable=new Array(N*N).fill(false);const town=buildings.find(b=>b.type==='townhall');if(!town)return; |
| | let start=null;for(let y=town.y-1;y<=town.y+3&&!start;y++)for(let x=town.x-1;x<=town.x+3;x++)if(!occupied(x,y)){start={x,y};break;}if(!start)return; |
| | const q=[start];reachable[index(start.x,start.y)]=true;for(let i=0;i<q.length;i++){const p=q[i];for(const [dx,dy] of [[1,0],[-1,0],[0,1],[0,-1]]){let x=p.x+dx,y=p.y+dy,k=index(x,y);if(inMap(x,y)&&!reachable[k]&&!occupied(x,y)){reachable[k]=true;q.push({x,y});}}} | | | } |
| | function pathfind(sx,sy,tx,ty,adjacent=false){ |
| | sx=Math.max(0,Math.min(N-1,Math.floor(sx)));sy=Math.max(0,Math.min(N-1,Math.floor(sy)));tx=Math.max(0,Math.min(N-1,Math.floor(tx)));ty=Math.max(0,Math.min(N-1,Math.floor(ty))); |
| | let goals=[];if(!occupied(tx,ty)&&!adjacent)goals=[{x:tx,y:ty}];else{for(let r=1;r<=5&&goals.length===0;r++)for(let y=ty-r;y<=ty+r;y++)for(let x=tx-r;x<=tx+r;x++)if((Math.abs(x-tx)===r||Math.abs(y-ty)===r)&&!occupied(x,y))goals.push({x,y});} | | | if(!goals.length)return null;const goalSet=new Set(goals.map(p=>index(p.x,p.y))),start=index(sx,sy),prev=new Int32Array(NN).fill(-1),q=[start];prev[start]=start;let end=-1; | | | for(let i=0;i<q.length;i++){const k=q[i];if(goalSet.has(k)){end=k;break;}let x=k%N,y=Math.floor(k/N);for(const [dx,dy] of [[1,0],[0,1],[-1,0],[0,-1],[1,1],[-1,1],[1,-1],[-1,-1]]){const nx=x+dx,ny=y+dy,nk=index(nx,ny);if(!inMap(nx,ny)||prev[nk]!==-1||occupied(nx,ny))continue;if(dx&&dy&&(occupied(x+dx,y)||occupied(x,y+dy)))continue;prev[nk]=k;q.push(nk);}} | | | if(end===-1)return null;let result=[];while(end!==start){result.push({x:end%N+.5,y:Math.floor(end/N)+.5});end=prev[end];}return result.reverse(); | | | } | | | function pathToBuilding(u,b){const d=defs[b.type];let best=null;for(let y=b.y-1;y<=b.y+d.h;y++)for(let x=b.x-1;x<=b.x+d.w;x++)if(x===b.x-1||y===b.y-1||x===b.x+d.w||y===b.y+d.h){if(occupied(x,y))continue;const p=pathfind(u.x,u.y,x,y);if(p&&(!best||p.length<best.length))best=p;}return best;} | | | function stopUnit(u){u.path=[];u.task='idle';u.target=null;u.auto=false;u.work=0;u.exploreGoal=null;} | | | function orderMove(list,x,y){let count=0;list.forEach((u,i)=>{const ox=list.length>1?(i%3-1).8:0,oy=list.length>1?(Math.floor(i/3)%3-1)*.8:0;const p=pathfind(u.x,u.y,x+ox,y+oy);if(p){stopUnit(u);u.path=p;u.task='move';count++;}});if(count){effects.push({type:'command',x,y,t:0,max:1,color:'#d8df99'});}else notify('There is no clear path to that location.',true);} | | | function orderGather(list,node){let count=0;list.filter(u=>u.type==='worker').forEach(u=>{const p=pathfind(u.x,u.y,node.x,node.y,true);if(p){stopUnit(u);u.target=node.id;if(u.carry>0&&(u.carryType!==node.type||u.carry>=10))deposit(u);else{u.path=p;u.task='gather';}count++;}});if(!count)notify('Select a worker to gather these resources.',true);else effects.push({type:'command',x:node.x,y:node.y,t:0,max:1,color:'#e5c773'});} |
| | function orderBuild(u,b){const p=pathToBuilding(u,b);if(p===null)return false;stopUnit(u);u.path=p;u.task='build';u.target=b.id;return true;} |
| | function chooseExplorerGoal(u){ |
| | let best=null,bestScore=-Infinity;const r=unitsDef[u.type].vision; |
| | for(let y=0;y<N;y+=2)for(let x=0;x<N;x+=2){if(!reachable[index(x,y)]||occupied(x,y))continue;let gain=0;for(let dy=-r;dy<=r;dy+=2)for(let dx=-r;dx<=r;dx+=2){if(dxdx+dydy<(r-.75)*2&&inMap(x+dx,y+dy)&&!explored[index(x+dx,y+dy)])gain++;}if(!gain)continue;const distance=Math.hypot(x-u.x,y-u.y);let score=gain/(4+distance);for(const other of units)if(other!==u&&other.auto&&other.exploreGoal&&Math.hypot(other.exploreGoal.x-x,other.exploreGoal.y-y)<r)score=.25;if(score>bestScore){bestScore=score;best={x,y};}} | | | // Inspect all walkable cells near the remaining fog to finish even tiny pockets. | | | if(!best&&exploredTotal<NN){for(let k=0;k<NN&&!best;k++){if(explored[k])continue;const fx=k%N,fy=Math.floor(k/N);let dist=Infinity;for(let dy=-r+1;dy<r;dy++)for(let dx=-r+1;dx<r;dx++){const x=fx+dx,y=fy+dy;if(inMap(x,y)&&dxdx+dydy<(r-.5)**2&&reachable[index(x,y)]&&!occupied(x,y)){const dd=Math.hypot(x-u.x,y-u.y);if(dd<dist){dist=dd;best={x,y};}}}}} |
| | if(best){const p=pathfind(u.x,u.y,best.x,best.y);if(p){u.path=p;u.task='explore';u.exploreGoal=best;return;}} |
| | stopUnit(u);if(exploredTotal>=N*N)notify('The Green Reach is fully charted.');else notify('No reachable unexplored land remains. Clear trees or extend your watchtowers.'); |
| | } | | | function orderExplore(list){if(!list.length)return;let count=0;list.forEach(u=>{stopUnit(u);u.auto=true;chooseExplorerGoal(u);if(u.auto)count++;});if(count)notify(count===1?'Charting a course into the unknown.':count+' explorers are charting the frontier.');updateUI(true);} |
| | function updateVision(){ |
| | visible=new Array(N*N).fill(false);visionStrength=new Float32Array(N*N); |
| | const sources=[...units.map(u=>({x:u.x,y:u.y,r:unitsDef[u.type].vision})),...buildings.map(b=>({x:b.x+defs[b.type].w/2,y:b.y+defs[b.type].h/2,r:b.progress>=1?defs[b.type].vision:3}))]; |
| | for(const s of sources)for(let y=Math.max(0,Math.floor(s.y-s.r));y<=Math.min(N-1,s.y+s.r);y++)for(let x=Math.max(0,Math.floor(s.x-s.r));x<=Math.min(N-1,s.x+s.r);x++){const dist=Math.hypot(x+.5-s.x,y+.5-s.y);if(dist<s.r){const k=index(x,y);visible[k]=true;explored[k]=true;visionStrength[k]=Math.max(visionStrength[k],Math.min(1,(s.r-dist)/1.7));}} | | | exploredTotal=explored.filter(Boolean).length; | | | } | | | function validPlacement(type,x,y){const d=defs[type];for(let yy=y;yy<y+d.h;yy++)for(let xx=x;xx<x+d.w;xx++){if(!inMap(xx,yy)||!explored[index(xx,yy)]||occupied(xx,yy)||units.some(u=>Math.floor(u.x)===xx&&Math.floor(u.y)===yy))return false;}return true;} | | | function startPlacement(type){if(d)return;const d=defs[type];if(state.gold<d.gold||state.wood<d.wood){notify('Not enough resources for a '+d.name.toLowerCase()+'.',true);return;}if(!units.some(u=>u.type==='worker')){notify('Train a worker at your Town Hall first.',true);return;}placement=type;canvas.style.cursor='crosshair';$('world-hint').innerHTML='Place '+d.name+' on clear, discovered land. <kbd>ESC</kbd> to cancel';$('world-hint').classList.add('show');updateUI(true);} | | | function cancelPlacement(){placement=null;canvas.style.cursor='default';$('world-hint').classList.remove('show');updateUI(true);} | | | function placeBuilding(x,y){const type=placement,d=defs[type];x=Math.floor(x);y=Math.floor(y);if(!validPlacement(type,x,y)){notify('Choose clear, discovered ground for this building.',true);return;}if(state.gold<d.gold||state.wood<d.wood){notify('You need more resources to build this.',true);return;} | | | const b=makeBuilding(type,x,y,false);buildings.push(b);rebuildBlockers();const chosen=selected.filter(s=>s.kind==='unit').map(getEntity).filter(u=>u&&u.type==='worker');const candidates=[...new Set([...chosen,...units.filter(u=>u.type==='worker').sort((a,c)=>(a.task==='idle'?-100:0)+Math.hypot(a.x-x,a.y-y)-((c.task==='idle'?-100:0)+Math.hypot(c.x-x,c.y-y)))])];const worker=candidates.find(u=>orderBuild(u,b));if(!worker){buildings.pop();rebuildBlockers();notify('Your workers cannot reach this building site.',true);return;} | | | state.gold-=d.gold;state.wood-=d.wood;selected=[{kind:'building',id:b.id}];cancelPlacement();recomputeReachable();notify(d.name+' construction started.');updateVision();updateUI(true);saveGame(false); | | | } | | | function train(type){if(d)return;const d=unitsDef[type],required=type==='worker'?'townhall':'barracks';let b=selected.map(getEntity).find(e=>e&&e.type===required&&e.queue&&e.progress>=1)||buildings.find(e=>e.type===required&&e.progress>=1);if(!b){notify('Build a '+defs[required].name.toLowerCase()+' first.',true);return;}if(b.queue.length>=5){notify('The training queue is full.',true);return;}if(reservedPop()>=populationCap()){notify('Your settlement needs more houses.',true);return;}if(state.gold<d.gold||state.wood<d.wood){notify('Not enough resources to train a '+d.name.toLowerCase()+'.',true);return;}state.gold-=d.gold;state.wood-=d.wood;b.queue.push({type,progress:0});notify(d.name+' added to the training queue.');updateUI(true);} | | | function deposit(u){const options=buildings.filter(b=>b.progress>=1&&(b.type==='townhall'||(u.carryType==='wood'&&b.type==='sawmill')));options.sort((a,b)=>Math.hypot(a.x-u.x,a.y-u.y)-Math.hypot(b.x-u.x,b.y-u.y));for(const b of options){const p=pathToBuilding(u,b);if(p!==null){u.path=p;u.task='deposit';u.dropoff=b.id;return;}}u.task='idle';notify('This worker cannot reach a resource drop-off.',true);} | | | function resumeGather(u){let n=nodes.find(n=>n.id===u.target&&n.amount>0);if(!n){const nearby=nodes.filter(n=>n.amount>0&&n.type===u.carryType&&explored[index(Math.floor(n.x),Math.floor(n.y))]).sort((a,b)=>Math.hypot(a.x-u.x,a.y-u.y)-Math.hypot(b.x-u.x,b.y-u.y));n=nearby.find(n=>pathfind(u.x,u.y,n.x,n.y,true)!==null);}if(n){const p=pathfind(u.x,u.y,n.x,n.y,true);if(p!==null){u.target=n.id;u.path=p;u.task='gather';return;}}u.task='idle';u.target=null;} |
| | function update(dt){ |
| | state.time+=dt; |
| | for(const b of buildings){if(b.progress<1||!b.queue.length)continue;let q=b.queue[0];q.progress+=dt/unitsDef[q.type].time;if(q.progress>=1){const d=defs[b.type];let spawn=null;for(let y=b.y+d.h;y>=b.y-1&&!spawn;y--)for(let x=b.x+d.w;x>=b.x-1;x--)if(!occupied(x,y)){spawn={x:x+.5,y:y+.5};break;}if(spawn){const u=makeUnit(q.type,spawn.x,spawn.y);units.push(u);b.queue.shift();state.trained++;effects.push({type:'float',x:u.x,y:u.y,text:unitsDef[u.type].name+' ready',t:0,max:2.5,color:'#e0e3bb'});notify(unitsDef[u.type].name+' ready for orders.');if(b.rally)orderMove([u],b.rally.x,b.rally.y);}}} | | | for(const u of units){u.animation+=dt;let moving=false; | | | if(u.path.length){let budget=unitsDef[u.type].speeddt;while(budget>0&&u.path.length){const p=u.path[0],dx=p.x-u.x,dy=p.y-u.y,dist=Math.hypot(dx,dy);if(occupied(Math.floor(p.x),Math.floor(p.y))){const end=u.path[u.path.length-1],replacement=pathfind(u.x,u.y,end.x,end.y);u.path=replacement||[];break;}if(dist<=budget){u.x=p.x;u.y=p.y;u.path.shift();budget-=dist;}else{u.x+=dx/distbudget;u.y+=dy/dist*budget;budget=0;}}moving=true;} |
| | if(u.path.length)continue; |
| | if(u.task==='move')u.task='idle'; |
| | else if(u.task==='explore'){if(u.auto)chooseExplorerGoal(u);else u.task='idle';} |
| | else if(u.task==='gather'){ |
| | const n=nodes.find(n=>n.id===u.target&&n.amount>0);if(!n){if(u.carry>0)deposit(u);else resumeGather(u);continue;} |
| | if(Math.hypot(u.x-n.x,u.y-n.y)>2){const p=pathfind(u.x,u.y,n.x,n.y,true);if(p===null){u.task='idle';continue;}u.path=p;continue;} |
| | u.work+=dt*(n.type==='wood'&&buildings.some(b=>b.type==='sawmill'&&b.progress>=1)?1.5:1); |
| | if(u.work>=.7){u.work=0;const amount=Math.min(2,n.amount,10-u.carry);n.amount-=amount;u.carry+=amount;u.carryType=n.type;if(n.amount<=0){recomputeReachable();effects.push({type:'float',x:n.x,y:n.y,text:'Cleared',t:0,max:1.8,color:'#d8dab2'});}if(u.carry>=10||n.amount<=0)deposit(u);} | | | }else if(u.task==='deposit'){const b=buildings.find(b=>b.id===u.dropoff);if(!b){deposit(u);continue;}const d=defs[b.type];if(u.x<b.x-1.3||u.y<b.y-1.3||u.x>b.x+d.w+1.3||u.y>b.y+d.h+1.3){deposit(u);continue;}state[u.carryType]+=u.carry;state.gathered+=u.carry;effects.push({type:'float',x:u.x,y:u.y,text:'+'+u.carry+' '+(u.carryType==='wood'?'timber':'gold'),t:0,max:2,color:u.carryType==='wood'?'#d7e5aa':'#f3d485'});u.carry=0;resumeGather(u); | | | }else if(u.task==='build'){const b=buildings.find(b=>b.id===u.target);if(!b||b.progress>=1){u.task='idle';u.target=null;continue;}const d=defs[b.type];if(u.x<b.x-1.3||u.y<b.y-1.3||u.x>b.x+d.w+1.3||u.y>b.y+d.h+1.3){if(!orderBuild(u,b))u.task='idle';continue;}b.progress=Math.min(1,b.progress+dt/d.time);if(b.progress>=1){notify(d.name+' complete.');effects.push({type:'float',x:b.x+d.w/2,y:b.y+d.h/2,text:'Construction complete',t:0,max:3,color:'#e2e8b4'});u.task='idle';u.target=null;updateVision();updateUI(true);}} | | | } |
| | effects.forEach(e=>e.t+=dt);effects=effects.filter(e=>e.t<e.max); |
| | visionTimer+=dt;if(visionTimer>.25){visionTimer=0;updateVision();} |
| | if(!state.won&&state.gathered>=200&&buildings.some(b=>b.type==='barracks'&&b.progress>=1)&&exploredTotal===N*N){state.won=true;openModal('victory-modal');saveGame(false);} |
| | } | | | function poly(c,points,fill,stroke,width=1){c.beginPath();points.forEach((p,i)=>i?c.lineTo(p[0],p[1]):c.moveTo(p[0],p[1]));c.closePath();if(fill){c.fillStyle=fill;c.fill();}if(stroke){c.strokeStyle=stroke;c.lineWidth=width;c.stroke();}} |
| | function ellipse(c,x,y,rx,ry,fill){c.beginPath();c.ellipse(x,y,rx,ry,0,0,Math.PI*2);c.fillStyle=fill;c.fill();} |
| | function line(c,x1,y1,x2,y2,color,width=1){c.strokeStyle=color;c.lineWidth=width;c.beginPath();c.moveTo(x1,y1);c.lineTo(x2,y2);c.stroke();} |
| | function isoBox(c,x,y,w,d,h,top,left,right){poly(c,[[x,y],[x+w,y-w*.5],[x+w+d,y+(d-w).5],[x+d,y+d.5]],top);poly(c,[[x,y],[x+d,y+d*.5],[x+d,y+d*.5-h],[x,y-h]],left);poly(c,[[x+d,y+d*.5],[x+w+d,y+(d-w).5],[x+w+d,y+(d-w).5-h],[x+d,y+d*.5-h]],right);poly(c,[[x,y-h],[x+w,y-w*.5-h],[x+w+d,y+(d-w).5-h],[x+d,y+d.5-h]],top);} |
| | function drawTree(c,x,y,r,small=false){ |
| | const s=small?.7:.86+r*.34; c.save();c.translate(x,y);c.scale(s,s);ellipse(c,10,4,21,10,'#12281848');line(c,0,0,0,-39,'#4b4430',5);line(c,-1,-4,-1,-36,'#807044',1.5); |
| | const pine=r>.24; |
| | if(pine){const colors=r>.65?['#355c35','#4b7140','#64844c']:['#285737','#3e6d40','#587e48'];for(let i=0;i<3;i++){const y=-16-i15,w=24-i5;poly(c,[[-w,y+2],[0,y-35],[w,y+2],[4,y+12]],colors[0]);poly(c,[[0,y-35],[w,y+2],[4,y+12],[-3,y+2]],colors[1]);poly(c,[[-w,y+2],[0,y-35],[-3,y+2],[-12,y+6]],colors[2]);line(c,0,y-33,4,y+8,'#8a9d5633',1);}} | | | else{const foliage=['#42673c','#5c8048','#76944e'];poly(c,[[-25,-31],[-27,-50],[-13,-68],[7,-75],[27,-61],[33,-40],[20,-23],[-3,-19]],foliage[0]);poly(c,[[-27,-50],[-13,-68],[7,-75],[16,-59],[3,-42],[-16,-37]],foliage[2]);poly(c,[[7,-75],[27,-61],[33,-40],[10,-35],[3,-42],[16,-59]],foliage[1]);poly(c,[[-16,-37],[3,-42],[10,-35],[-3,-19],[-25,-31]],'#527440');} | | | c.restore(); | | | } | | | function drawGold(c,x,y,r){c.save();c.translate(x,y);ellipse(c,6,1,30,13,'#17271955');poly(c,[[-29,-2],[-26,-19],[-15,-29],[4,-33],[23,-23],[30,-6],[16,8],[-6,12]],'#6b7460');poly(c,[[-26,-19],[-15,-29],[4,-33],[7,-15],[-10,-7]],'#a4a78a');poly(c,[[4,-33],[23,-23],[30,-6],[7,-15]],'#858d72');poly(c,[[-29,-2],[-26,-19],[-10,-7],[-6,12]],'#828972');poly(c,[[7,-15],[30,-6],[16,8],[-6,12],[-10,-7]],'#555f4e'); | | | for(const [gx,gy,sz] of [[-13,-19,6],[4,-22,7],[14,-9,5],[-6,-3,5],[1,5,3]]){poly(c,[[gx-sz,gy],[gx-2,gy-sz],[gx+sz,gy-sz*.5],[gx+sz*.6,gy+sz*.6],[gx-sz*.5,gy+sz*.7]],'#c29b4d');poly(c,[[gx-sz,gy],[gx-2,gy-sz],[gx+sz,gy-sz*.5],[gx,gy+1]],'#efd17a');line(c,gx,gy+1,gx+sz*.6,gy+sz*.6,'#8a6d33',1);} |
| | c.restore();} |
| | function drawRoof(c,x,y,w,d,h,color='#a26442'){ |
| | // A ridge roof with individually drawn seams, lit from the upper left. |
| | const a=[x,y],b=[x+w,y-w*.5],cc=[x+w+d,y+(d-w)*.5],dd=[x+d,y+d*.5],r1=[x+d*.5,y+d*.25-h],r2=[x+w+d*.5,y-w*.5+d*.25-h]; |
| | poly(c,[a,b,r2,r1],'#bb8155','#6d4b34',1);poly(c,[r1,r2,cc,dd],color,'#704a32',1);poly(c,[a,r1,dd],'#d2b986','#7b6548',1); |
| | for(let t=.16;t<1;t+=.16){const ix=r1[0]+(dd[0]-r1[0])*t,iy=r1[1]+(dd[1]-r1[1])*t;line(c,ix,iy,ix+w,iy-w*.5,'#583e303d',1);} |
| | for(let t=.14;t<1;t+=.14){line(c,r1[0]+w*t,r1[1]-w*.5*t,dd[0]+w*t,dd[1]-w*.5*t,'#d49c6336',1);} |
| | line(c,r1[0],r1[1],r2[0],r2[1],'#ddac72',2);line(c,dd[0],dd[1],cc[0],cc[1],'#593e2e',2); |
| | } |
| | function drawBuilding(c,b,preview=false){ |
| | const d=defs[b.type],p=project(b.x+d.w/2,b.y+d.h/2);c.save();c.translate(p.x,p.y);let type=b.type; |
| | if(!preview&&b.progress<1){const w=d.wTW/2-5,h=d.hTH/2-3;poly(c,[[-w,0],[0,-h],[w,0],[0,h]],'#8a886052','#c2bb7b',1);for(let i=-1;i<=1;i++){line(c,i20-27,13,i20-27,-31,'#b49c63',3);line(c,i20-27,-25,i20+27,2,'#91794b',2);}line(c,-47,-7,26,-43,'#c3ab6c',3);line(c,27,29,27,-18,'#bba269',4);isoBox(c,5,9,14,9,6,'#c2b78f','#8f8464','#a49972');c.globalAlpha=.25+.5*b.progress;} |
| | ellipse(c,10,12,type==='townhall'?80:type==='barracks'?71:type==='tower'?32:53,type==='townhall'?33:25,'#182a1944'); |
| | if(type==='townhall'){ |
| | poly(c,[[-80,10],[-10,-25],[80,17],[11,51]],'#928d6538');isoBox(c,-59,0,63,49,8,'#a09e7a','#666b53','#7a7d5d'); |
| | isoBox(c,-53,-8,57,42,43,'#cfc4a0','#afa887','#c6bb94'); |
| | for(let i=0;i<3;i++){line(c,-53,-16-i*12,-11,5-i*12,'#827c5c44',1);line(c,-11,5-i*12,46,-23-i*12,'#8c815c44',1);} |
| | line(c,-49,-47,-49,-6,'#716245',4);line(c,-14,-29,-14,11,'#716245',4);line(c,40,-56,40,-17,'#716245',4); |
| | drawRoof(c,-61,-51,68,53,25); |
| | // Front door and covered entry. |
| | poly(c,[[-37,-11],[-37,-35],[-25,-29],[-25,-5]],'#504e36');line(c,-31,-9,-31,-29,'#776b49',1);ellipse(c,-28,-19,1,1.5,'#e4c875'); |
| | isoBox(c,-42,4,18,14,4,'#c1b792','#7a7c5b','#929477'); |
| | for(const x of [4,24]){const y=-x*.5;poly(c,[[x,-20+y],[x+9,-24.5+y],[x+9,-38+y],[x,-33.5+y]],'#4c6354');line(c,x+4.5,-22+y,x+4.5,-36+y,'#d0b884',1.5);line(c,x,-28+y,x+9,-32.5+y,'#d0b884',1);} |
| | // Small bell tower. | | | isoBox(c,-8,-76,18,17,23,'#ded2a9','#aaa080','#c9bd93');poly(c,[[1,-84],[1,-97],[8,-100.5],[8,-87.5]],'#4f5947');drawRoof(c,-12,-102,24,24,20,'#527b6a');line(c,12,-129,12,-150,'#b4ad7a',1.8);const flutter=Math.sin(state?.time*2||0)*2;poly(c,[[13,-150],[34,-145+flutter],[28,-139+flutter],[13,-142]],'#dbbc72'); | | | // Settlement banner. | | | line(c,45,-14,45,-61,'#5d6041',2);poly(c,[[45,-59],[57,-53],[57,-31],[51,-36],[45,-36]],'#477665');line(c,51,-54,51,-36,'#d5c179',2); | | | isoBox(c,36,7,12,9,9,'#b29760','#796844','#917c4e'); |
| | }else if(type==='house'){ |
| | isoBox(c,-40,-1,39,33,5,'#a9a77d','#71785a','#888e6a');isoBox(c,-34,-7,31,29,29,'#d0c198','#b0a37f','#cabb91');drawRoof(c,-41,-36,43,38,25,'#9b6549'); |
| | line(c,-31,-32,-31,-8,'#77674b',3);line(c,-7,-20,-7,3,'#77674b',3);poly(c,[[-25,-9],[-25,-27],[-15,-22],[-15,-4]],'#51553b');poly(c,[[3,-10],[13,-15],[13,-25],[3,-20]],'#62735d');line(c,8,-12,8,-22,'#d2bc83',1.5);line(c,3,-15,13,-20,'#d2bc83',1);isoBox(c,15,-55,7,7,18,'#b0ad8e','#818773','#a0a38a'); |
| | }else if(type==='barracks'){ |
| | isoBox(c,-57,1,68,37,7,'#a09e7d','#6a755a','#828b68');isoBox(c,-50,-6,59,31,31,'#c6bd96','#9a9c7a','#b6b48c');drawRoof(c,-58,-36,76,39,24,'#5b7e6c'); |
| | poly(c,[[-43,-7],[-43,-29],[-28,-22],[-28,0]],'#4c5742');line(c,-36,-26,-36,-5,'#b5a87c',2);for(const x of [-7,16,35]){const y=-x*.5;poly(c,[[x,-12+y],[x+6,-15+y],[x+6,-25+y],[x,-22+y]],'#4c6351');} | | | line(c,40,11,40,-47,'#80764b',3);poly(c,[[41,-47],[61,-43],[59,-19],[49,-15],[41,-24]],'#bda462');line(c,48,-39,54,-24,'#ece0ae',2);line(c,55,-37,47,-27,'#ece0ae',2);isoBox(c,-3,23,21,10,9,'#ae9867','#766a49','#91805a'); |
| | }else if(type==='sawmill'){ |
| | isoBox(c,-40,1,42,32,5,'#9da077','#6d7858','#838e65');for(const [x,y] of [[-32,-3],[-3,12],[30,-4],[1,-18]])line(c,x,y,x,y-37,'#a48d58',5);drawRoof(c,-43,-38,44,39,19,'#83744e'); |
| | for(let i=0;i<4;i++){line(c,-32+i*5,14+i*2,1+i*5,-2+i*2,'#6a583c',7);ellipse(c,-32+i*5,14+i*2,3.5,4,'#c0a474');ellipse(c,-32+i*5,14+i*2,1.6,2,'#8d734c');} |
| | c.save();c.translate(26,-4);c.scale(.7,1);c.beginPath();c.arc(0,0,15,0,Math.PI2);c.strokeStyle='#858d77';c.lineWidth=5;c.stroke();for(let i=0;i<8;i++){const a=iMath.PI/4+(state?.time||0)*.1;line(c,0,0,Math.cos(a)*15,Math.sin(a)15,'#a0aa8b',2);}c.restore(); | | | }else if(type==='tower'){ | | | isoBox(c,-22,3,23,22,7,'#a4a883','#788361','#8f9974');for(const [x,y] of [[-15,0],[0,8],[16,0],[0,-8]]){line(c,x,y,x.85,y-65,'#947d4b',5);}line(c,-15,-8,13,-54,'#b19a62',3);line(c,14,-6,-12,-52,'#aa925b',3);isoBox(c,-23,-55,24,23,14,'#a39969','#6f7652','#8a9064');drawRoof(c,-30,-71,31,29,25,'#557b62');line(c,0,-103,0,-118,'#b2a675',1.5);poly(c,[[1,-118],[17,-114],[13,-107],[1,-110]],'#d4b974'); | | | } | | | c.restore(); | | | } |
| | function drawUnit(c,u,portrait=false){ |
| | const p=project(u.x,u.y);c.save();c.translate(p.x,p.y);const moving=u.path.length>0,bob=moving?Math.sin(u.animation*15)*1.4:Math.sin(u.animation*2)*.3; |
| | ellipse(c,2,2,9,4,'#19291b65');c.translate(0,bob);const walk=moving?Math.sin(u.animation*15)*3:0; |
| | line(c,-2,-4,-3+walk,1,'#443f32',3);line(c,3,-4,3-walk,1,'#443f32',3); |
| | if(u.type==='worker'){ |
| | poly(c,[[-6,-16],[3,-18],[7,-11],[5,-4],[-4,-3],[-7,-10]],'#bdad78');poly(c,[[-6,-14],[-2,-12],[-1,-4],[-5,-4]],'#737a52');poly(c,[[1,-15],[6,-13],[5,-4],[0,-5]],'#809472');ellipse(c,0,-20,5,5,'#d8bc8e');ellipse(c,0,-23,7,2.8,'#bda675');poly(c,[[-4,-23],[-3,-27],[3,-27],[5,-23]],'#dac18c'); |
| | const working=u.task==='gather'&&!moving\|\|u.task==='build'&&!moving,a=working?Math.sin(u.animation*11)*.7:0; |
| | c.save();c.translate(5,-12);c.rotate(a);line(c,-1,2,9,-6,'#d4ba85',3);line(c,8,-2,13,-17,'#877342',2);line(c,7,-17,16,-14,'#afb8a2',3);c.restore(); |
| | }else if(u.type==='scout'){ |
| | poly(c,[[-5,-17],[4,-17],[8,-2],[-7,-2]],'#446e57');poly(c,[[-5,-16],[-1,-14],[1,-3],[-7,-2]],'#6a8d65');ellipse(c,1,-20,4,4,'#d2b485');poly(c,[[-6,-21],[-4,-28],[2,-30],[7,-22],[1,-24]],'#6f926a');line(c,-6,-14,4,-6,'#b7a575',2);line(c,8,-3,10,-26,'#a09262',1.5);c.strokeStyle='#b7ab79';c.lineWidth=1.5;c.beginPath();c.arc(8,-14,10,-1.5,1.5);c.stroke(); | | | }else{ | | | poly(c,[[-6,-16],[5,-17],[7,-5],[-5,-4]],'#94a59a');poly(c,[[-5,-15],[-2,-13],[-1,-4],[-6,-4]],'#456854');ellipse(c,0,-20,5,5,'#c5b795');poly(c,[[-6,-20],[-5,-27],[2,-29],[6,-24],[5,-20]],'#aeb9a7');line(c,-3,-25,4,-24,'#dae0c1',1);poly(c,[[-9,-15],[-3,-12],[-4,-3],[-9,0],[-12,-6]],'#587c63','#bdc197',1);line(c,8,-4,12,-24,'#bfcabd',2.5);line(c,6,-9,13,-8,'#ac9e68',2); | | | } |
| | if(u.carry>0){if(u.carryType==='wood'){line(c,-9,-9,-9,-19,'#77613b',5);line(c,-7,-9,-7,-18,'#bfa271',2);}else{ellipse(c,-8,-9,4,5,'#c8a458');}} |
| | c.restore(); |
| | } | | | const grassColors=['#6f8150','#728452','#748553','#708151','#768653','#798956','#758653','#6b7e4e']; | | | function drawTerrain(c,t){const p=project(t.x,t.y),x=p.x,y=p.y; | | | if(t.water){poly(c,[[x,y],[x+32,y+16],[x,y+32],[x-32,y+16]],['#4b7970','#4d7d74','#527f76'][Math.floor(t.r3)]);const wave=Math.sin(state.time1.4+t.x+t.y)4;line(c,x-9+wave,y+12,x+5+wave,y+12,'#92b4a13e',1);line(c,x+1-wave,y+23,x+10-wave,y+23,'#a5c6ac30',1);return;} | | | if(t.bridge){poly(c,[[x,y],[x+32,y+16],[x,y+32],[x-32,y+16]],'#a59a70');for(let i=0;i<5;i++)line(c,x-25+i6,y+13+i3,x+6+i6,y-3+i3,'#6e6d4d',1.5);return;} | | | let color=grassColors[Math.floor(t.rgrassColors.length)];const path=(Math.abs(t.x-22)<1&&t.y>=23&&t.y<30)||(t.y===25&&t.x>=17&&t.x<=23);if(path)color=['#9b9866','#999769','#939263'][Math.floor(t.r*3)]; |
| | poly(c,[[x,y-.3],[x+32.4,y+16],[x,y+32.3],[x-32.4,y+16]],color); |
| | for(let i=0;i<3;i++){const rx=hash(t.x,t.y,i)*35-17,ry=hash(t.y,t.x,i+6)*12+10;line(c,x+rx,y+ry,x+rx+3,y+ry+1,'#b4bd7937',.8);if(t.r>.55){line(c,x+rx,y+ry,x+rx-1,y+ry-2,'#445b3438',.8);}} |
| | if(t.flowers&&!path){for(let i=0;i<3;i++){const xx=x+hash(t.x,t.y,i+10)*25-12,yy=y+hash(t.x,t.y,i+20)*10+10;ellipse(c,xx,yy,1,.65,t.r>.5?'#d8d09b88':'#b8bfd477');}} |
| | if(t.r<.028&&!path){poly(c,[[x+5,y+19],[x+8,y+14],[x+14,y+15],[x+16,y+20],[x+10,y+22]],'#939c77');poly(c,[[x+5,y+19],[x+8,y+14],[x+14,y+15],[x+10,y+18]],'#b3b795');} |
| | } | | | function drawSelection(c,s){const e=getEntity(s);if(!e)return;const p=project(e.x+(s.kind==='building'?defs[e.type].w/2:0),e.y+(s.kind==='building'?defs[e.type].h/2:0));c.save();c.strokeStyle='#d2d995';c.lineWidth=1.5;if(s.kind==='building'){const d=defs[e.type],a=project(e.x-.1,e.y-.1),b=project(e.x+d.w+.1,e.y-.1),cc=project(e.x+d.w+.1,e.y+d.h+.1),dd=project(e.x-.1,e.y+d.h+.1);poly(c,[[a.x,a.y],[b.x,b.y],[cc.x,cc.y],[dd.x,dd.y]],'#e7e39a10','#d6d994',1.5);}else{c.beginPath();c.ellipse(p.x,p.y+1,s.kind==='node'?32:12,s.kind==='node'?15:6,0,0,Math.PI*2);c.fillStyle='#d3e09812';c.fill();c.stroke();}c.restore();} |
| | function draw(){ |
| | ctx.setTransform(dpr,0,0,dpr,0,0);ctx.fillStyle='#202e25';ctx.fillRect(0,0,viewW,viewH);ctx.save();ctx.translate(viewW/2,viewH*.42);ctx.scale(cam.zoom,cam.zoom);ctx.translate(-cam.x,-cam.y); |
| | const margin=150,worldLeft=cam.x-viewW/(2*cam.zoom)-margin,worldRight=cam.x+viewW/(2*cam.zoom)+margin,worldTop=cam.y-viewH*.42/cam.zoom-margin,worldBottom=cam.y+viewH*.58/cam.zoom+margin; |
| | const tileOnScreen=t=>{const p=project(t.x,t.y);return p.x>worldLeft&&p.x<worldRight&&p.y>worldTop&&p.y<worldBottom;}; |
| | // Paint the remembered terrain first; objects retain their silhouettes beneath the mist. | | | for(const t of terrain){if(!tileOnScreen(t))continue;const k=index(t.x,t.y);if(explored[k])drawTerrain(ctx,t);else{const p=project(t.x,t.y);poly(ctx,[[p.x,p.y-.5],[p.x+32.5,p.y+16],[p.x,p.y+32.5],[p.x-32.5,p.y+16]],grassColors[Math.floor(t.r*grassColors.length)]);}} | | | if(showGrid||placement){for(const t of terrain){if(!tileOnScreen(t)||!explored[index(t.x,t.y)])continue;const p=project(t.x,t.y);poly(ctx,[[p.x,p.y],[p.x+32,p.y+16],[p.x,p.y+32],[p.x-32,p.y+16]],null,'#d0d79b27',.65);}} |
| | for(const s of selected)drawSelection(ctx,s); |
| | const renderables=[]; |
| | for(const n of nodes){if(n.amount<=0\|\|!explored[index(Math.floor(n.x),Math.floor(n.y))])continue;renderables.push({kind:'node',e:n,depth:n.x+n.y});} |
| | for(const b of buildings)renderables.push({kind:'building',e:b,depth:b.x+b.y+defs[b.type].w/2+defs[b.type].h/2+.4}); |
| | for(const u of units)renderables.push({kind:'unit',e:u,depth:u.x+u.y});renderables.sort((a,b)=>a.depth-b.depth); |
| | for(const o of renderables){const e=o.e,p=project(e.x,e.y);if(p.x<worldLeft\|\|p.x>worldRight\|\|p.y<worldTop\|\|p.y>worldBottom)continue; |
| | if(o.kind==='node'){if(e.type==='wood')drawTree(ctx,p.x,p.y,e.r);else drawGold(ctx,p.x,p.y,e.r);}else if(o.kind==='building')drawBuilding(ctx,e);else drawUnit(ctx,e); |
| | } | | | // Smooth fog is painted as a low-resolution interpolated light field. | | | drawFog(); | | | // Selection information stays clear of terrain and mist. | | | for(const s of selected){const e=getEntity(s);if(!e)continue;const b=s.kind==='building',p=project(e.x+(b?defs[e.type].w/2:0),e.y+(b?defs[e.type].h/2:0)),top=b?(e.type==='townhall'?163:e.type==='tower'?130:96):s.kind==='unit'?40:e.type==='wood'?90:44; | | | if(s.kind==='unit'){ctx.fillStyle='#273d24';ctx.fillRect(p.x-12,p.y-top,24,3);ctx.fillStyle='#c4d991';ctx.fillRect(p.x-12,p.y-top,24e.hp/unitsDef[e.type].hp,3);} | | | else if(b){const label=defs[e.type].name+(e.progress<1?' · '+Math.floor(e.progress100)+'%':'');ctx.font='10px "DM Sans",sans-serif';const w=ctx.measureText(label).width+23;ctx.fillStyle='#1b2a22dd';ctx.beginPath();ctx.roundRect(p.x-w/2,p.y-top-9,w,24,4);ctx.fill();ctx.strokeStyle='#b0be7866';ctx.lineWidth=.8;ctx.stroke();ctx.textAlign='center';ctx.fillStyle='#dce5c1';ctx.fillText(label,p.x,p.y-top+7);ctx.textAlign='start';if(e.progress<1){ctx.fillStyle='#d7bc75';ctx.fillRect(p.x-w/2+3,p.y-top+12,(w-6)e.progress,2);}} | | | } | | | if(placement&&mouse.inside){const mp=unproject(mouse.x,mouse.y),x=Math.floor(mp.x),y=Math.floor(mp.y),d=defs[placement],valid=validPlacement(placement,x,y);const a=project(x,y),b=project(x+d.w,y),cc=project(x+d.w,y+d.h),dd=project(x,y+d.h);poly(ctx,[[a.x,a.y],[b.x,b.y],[cc.x,cc.y],[dd.x,dd.y]],valid?'#d7e89e44':'#d38e7944',valid?'#d4e3a1':'#eeaa8a',2);ctx.globalAlpha=.55;drawBuilding(ctx,{type:placement,x,y,progress:1},true);ctx.globalAlpha=1;} | | | for(const e of effects){const p=project(e.x,e.y),f=e.t/e.max;ctx.globalAlpha=1-f;if(e.type==='command'){ctx.strokeStyle=e.color;ctx.lineWidth=2;ctx.beginPath();ctx.ellipse(p.x,p.y,9+f20,4+f10,0,0,Math.PI2);ctx.stroke();for(let i=0;i<4;i++){const a=i*Math.PI/2;line(ctx,p.x+Math.cos(a)*5,p.y+Math.sin(a)*3,p.x+Math.cos(a)11,p.y+Math.sin(a)6,e.color,1);}}else{ctx.font='bold 11px "DM Sans",sans-serif';ctx.textAlign='center';ctx.fillStyle='#142416';ctx.fillText(e.text,p.x+1,p.y-37-f30+1);ctx.fillStyle=e.color;ctx.fillText(e.text,p.x,p.y-37-f30);ctx.textAlign='start';}ctx.globalAlpha=1;} | | | ctx.restore(); | | | if(drag&&drag.mode==='select'&&drag.moved){const x=Math.min(drag.startX,mouse.x),y=Math.min(drag.startY,mouse.y),w=Math.abs(mouse.x-drag.startX),h=Math.abs(mouse.y-drag.startY);ctx.fillStyle='#c5d59415';ctx.strokeStyle='#c5d594aa';ctx.lineWidth=1;ctx.fillRect(x,y,w,h);ctx.strokeRect(x+.5,y+.5,w,h);} | | | drawMinimap(); | | | } |
| | const fogCanvas=document.createElement('canvas');fogCanvas.width=N+2;fogCanvas.height=N+2;const fctx=fogCanvas.getContext('2d'),fogImage=fctx.createImageData(N+2,N+2); |
| | function drawFog(){ |
| | const data=fogImage.data;for(let y=0;y<N+2;y++)for(let x=0;x<N+2;x++){const k=index(x-1,y-1),i=(y*(N+2)+x)*4,valid=inMap(x-1,y-1);data[i]=24;data[i+1]=39;data[i+2]=29;data[i+3]=!valid?225:!explored[k]?213:Math.round(visible[k]?15+(1-visionStrength[k])*86:122);} | | | fctx.putImageData(fogImage,0,0);ctx.save();ctx.transform(TW/2,TH/2,-TW/2,TH/2,0,0);ctx.imageSmoothingEnabled=true;ctx.drawImage(fogCanvas,-1,-1,N+2,N+2);ctx.restore(); | | | } |
| | function drawMinimap(){ |
| | const w=mini.width,h=mini.height;mctx.clearRect(0,0,w,h);mctx.fillStyle='#152319';mctx.fillRect(0,0,w,h);const scale=Math.min(w/(N*2),h/N)*.91,ox=w/2,oy=(h-N*scale)/2; |
| | for(const t of terrain){const k=index(t.x,t.y);if(!explored[k])continue;const x=ox+(t.x-t.y)*scale,y=oy+(t.x+t.y)scale/2;mctx.fillStyle=t.water?'#648e80':visible[k]?'#859c62':'#455e3b';mctx.fillRect(x-scale,y,scale2+.4,scale+.4);} | | | for(const n of nodes){if(n.type!=='gold'||n.amount<=0||!explored[index(Math.floor(n.x),Math.floor(n.y))])continue;mctx.fillStyle='#c9b269';mctx.fillRect(ox+(n.x-n.y)*scale-1.5,oy+(n.x+n.y)*scale/2-1.5,3,3);} |
| | for(const b of buildings){const d=defs[b.type],x=b.x+d.w/2,y=b.y+d.h/2;mctx.fillStyle=b.progress<1?'#b39b63':'#dfd7a1';mctx.fillRect(ox+(x-y)*scale-3,oy+(x+y)*scale/2-2,6,4);} |
| | for(const u of units){mctx.fillStyle=selected.some(s=>s.kind==='unit'&&s.id===u.id)?'#f3eeb6':'#c1d499';mctx.fillRect(ox+(u.x-u.y)*scale-1,oy+(u.x+u.y)*scale/2-1,2.5,2.5);} |
| | const corners=[[0,0],[viewW,0],[viewW,Math.max(0,viewH-240)],[0,Math.max(0,viewH-240)]].map(([x,y])=>{const p=unproject(x,y);return[ox+(p.x-p.y)scale,oy+(p.x+p.y)scale/2];});poly(mctx,corners,'#e9df9f07','#d9d5a699',.8); | | | } | | | function makePortrait(type){const c=document.createElement('canvas');c.width=160;c.height=140;const g=c.getContext('2d');if(defs[type]){g.translate(80,type==='tower'?118:112);const scale=type==='townhall'?.72:type==='barracks'?.94:type==='tower'?.86:1.12;g.scale(scale,scale);const d=defs[type],p=project(d.w/2,d.h/2);g.translate(-p.x,-p.y);drawBuilding(g,{type,x:0,y:0,progress:1},true);}else if(unitsDef[type]){g.translate(80,116);g.scale(3.1,3.1);drawUnit(g,{...makeUnit(type,0,0,0),animation:0},true);}else if(type==='gold'){g.translate(80,102);g.scale(1.8,1.8);drawGold(g,0,0,.5);}else if(type==='wood'){g.translate(80,126);g.scale(1.35,1.35);drawTree(g,0,0,.7);}else{g.strokeStyle='#b9c690';g.lineWidth=3;g.beginPath();g.arc(80,70,31,0,Math.PI2);g.stroke();poly(g,[[94,48],[86,76],[63,89],[71,62]],'#bdc993');}return c.toDataURL();} | | | let portraits={}; | | | function actionButton(label,ic,key,action,cost='',disabled=false){return '<button class="action-card" data-action="'+action+'" title="'+label+' ('+key+')" '+(disabled?'disabled':'')+'><span class="keycap">'+key+'</span>'+icon(ic)+'<span class="action-label">'+label+'</span>'+(cost?'<span class="action-cost">'+cost+'</span>':'')+'</button>';} | | | function updateUI(force=false){ | | | $('gold').textContent=Math.floor(state.gold).toLocaleString();$('wood').textContent=Math.floor(state.wood).toLocaleString();$('population').textContent=units.length;$('capacity').textContent=populationCap(); | | | const goldWorkers=units.filter(u=>u.type==='worker'&&(u.task==='gather'||u.task==='deposit')&&nodes.find(n=>n.id===u.target)?.type==='gold').length,woodWorkers=units.filter(u=>u.type==='worker'&&(u.task==='gather'||u.task==='deposit')&&nodes.find(n=>n.id===u.target)?.type==='wood').length; | | | $('gold-rate').textContent=goldWorkers?'↗ '+goldWorkers:'';$('wood-rate').textContent=woodWorkers?'↗ '+woodWorkers:''; | | | const percent=exploredTotal===NN?100:Math.floor(exploredTotal/(NN)100);$('explore-count').textContent=percent+'%';$('explored-label').textContent=percent+'%';$('minimap-percent').textContent=percent+'%';$('exploration-bar').style.width=percent+'%';$('gather-count').textContent=Math.min(200,state.gathered)+' / 200';const barracks=buildings.some(b=>b.type==='barracks'&&b.progress>=1);$('barracks-count').textContent=(barracks?1:0)+' / 1'; | | | for(const [id,done] of [['quest-gather',state.gathered>=200],['quest-build',barracks],['quest-explore',exploredTotal===NN]]){$(id).classList.toggle('done',done);$(id).querySelector('.check').textContent=done?'✓':'';} | | | $('clock').textContent=Math.floor(state.time/60).toString().padStart(2,'0')+':'+Math.floor(state.time%60).toString().padStart(2,'0');$('day').textContent=1+Math.floor(state.time/300);$('zoom-label').textContent=Math.round(cam.zoom100)+'%'; | | | selected=selected.filter(s=>getEntity(s));const entry=selected[0],e=entry&&getEntity(entry),kind=entry?.kind,selectionKey=JSON.stringify(selected)+(e?.progress<1?'building':'ready'); | | | if(force||selectionKey!==lastSelectionKey){lastSelectionKey=selectionKey;$('selection-count').textContent=String(selected.length).padStart(2,'0');const type=selected.length>1?'group':e?.type||'empty';$('portrait').src=portraits[type]||portraits.scout;$('portrait').alt=selected.length>1?'Selected units':e?(defs[e.type]||unitsDef[e.type])?.name||e.type:'No selection'; | | | if(selected.length>1){$('selection-name').textContent=selected.length+' units';$('selection-type').textContent='Your people · Group selected';$('selection-desc').textContent='Right-click to move together. Workers can gather resources and build.';} | | | else if(e){const d=defs[e.type]||unitsDef[e.type];$('selection-name').textContent=d?.name||(e.type==='wood'?'Ancient pine':'Gold deposit');$('selection-type').textContent=d?.type||(kind==='unit'?'Your people · '+(e.type==='worker'?'Economy':'Exploration'):'Resource · '+(e.type==='wood'?'Timber':'Gold'));$('selection-desc').textContent=d?.desc||'Select workers and right-click here to gather. Resources are delivered to your settlement.';} | | | else{$('selection-name').textContent='The Green Reach';$('selection-type').textContent='A world waiting to be yours';$('selection-desc').textContent='Select a building or one of your people to see available commands.';} |
| | let actions='',title='FIELD COMMANDS',note='Right-click the ground to move.'; |
| | if(kind==='building'&&e.progress<1){title='CONSTRUCTION';actions=actionButton('Assign worker','hammer','R','assign');note='Workers make progress while on site.';} |
| | else if(kind==='building'&&e.type==='townhall'){title='RECRUIT & COMMAND';actions=actionButton('Worker','worker','R','worker','50 gold')+actionButton('Find idle','people','I','idle');note='Every great settlement starts small.';} | | | else if(kind==='building'&&e.type==='barracks'){title='TRAIN YOUR PEOPLE';actions=actionButton('Scout','scout','R','scout','80 G · 20 T')+actionButton('Soldier','sword','T','soldier','100 G · 30 T');note='Right-click ground to set a rally point.';} | | | else if(kind==='unit'){actions=actionButton('Explore','compass','E','explore')+actionButton('Stop','stop','X','stop');if(e.type==='worker'&&selected.length===1)note='Right-click a tree or gold to gather.';} |
| | else if(kind==='node'){title='GATHER RESOURCES';actions=actionButton('Send worker','worker','R','gather');note='A nearby worker will gather here.';} |
| | else{actions=actionButton('Find idle','people','I','idle')+actionButton('Go home','home','H','home');note=kind==='building'?'Your settlement is growing.':'Select a unit to give it orders.';} |
| | $('action-title').textContent=title;$('actions').innerHTML=actions;$('context-note').innerHTML=icon(kind==='unit'?'flag':'people')+note; |
| | } | | | if(e){let ratio=1,label='';if(kind==='building'){ratio=e.progress<1?e.progress:1;label=e.progress<1?'Building · '+Math.floor(e.progress100)+'%':e.hp.toLocaleString()+' / '+defs[e.type].hp.toLocaleString();}else if(kind==='unit'){ratio=e.hp/unitsDef[e.type].hp;label=selected.length>1?selected.length+' units selected':e.hp+' / '+unitsDef[e.type].hp;$('selection-type').textContent=selected.length>1?'Your people · Group selected':({idle:'Awaiting your orders',move:'On the move',gather:'Gathering '+(nodes.find(n=>n.id===e.target)?.type==='wood'?'timber':'gold'),deposit:'Bringing resources home',build:'Building your settlement',explore:'Exploring the frontier'}[e.task]||'Your people');}else{ratio=e.amount/(e.type==='gold'?3000:400);label=Math.floor(e.amount)+' '+(e.type==='wood'?'timber':'gold')+' remaining';}$('health-fill').style.width=Math.max(0,ratio100)+'%';$('health-text').textContent=label;}else{$('health-fill').style.width='0';$('health-text').textContent='Explore. Build. Belong.';} | | | $('queue-label').textContent=e?.queue?.length?'QUEUED '+e.queue.length+'/5':'';document.querySelectorAll('.queue-track').forEach(el=>el.remove());if(e?.queue?.length){const btn=$('actions').querySelector('[data-action="'+e.queue[0].type+'"]');if(btn){const p=document.createElement('div');p.className='queue-track';p.style.width=Math.min(100,e.queue[0].progress100)+'%';btn.append(p);}} | | | document.querySelectorAll('.build-card').forEach(b=>{b.classList.toggle('active',b.dataset.build===placement);const d=defs[b.dataset.build];b.style.opacity=state.gold<d.gold||state.wood<d.wood?'.5':'1';}); | | | } | | | function handleAction(action){const list=selected.filter(s=>s.kind==='unit').map(getEntity).filter(Boolean);if(['worker','scout','soldier'].includes(action))train(action);else if(action==='explore')orderExplore(list);else if(action==='stop'){list.forEach(stopUnit);updateUI(true);}else if(action==='idle')findIdle();else if(action==='home')home();else if(action==='assign'){const b=getEntity(selected[0]);const candidates=units.filter(u=>u.type==='worker').sort((a,c)=>(a.task==='idle'?0:100)+Math.hypot(a.x-b.x,a.y-b.y)-((c.task==='idle'?0:100)+Math.hypot(c.x-b.x,c.y-b.y)));if(candidates.some(u=>orderBuild(u,b)))notify('A worker is on the way.');else notify('No worker can reach this construction site.',true);}else if(action==='gather'){const n=getEntity(selected[0]);const candidates=units.filter(u=>u.type==='worker').sort((a,b)=>(a.task==='idle'?0:100)+Math.hypot(a.x-n.x,a.y-n.y)-((b.task==='idle'?0:100)+Math.hypot(b.x-n.x,b.y-n.y)));const u=candidates.find(u=>pathfind(u.x,u.y,n.x,n.y,true)!==null);if(u){orderGather([u],n);selected=[{kind:'unit',id:u.id}];updateUI(true);}else notify('No worker can reach these resources.',true);}} | | | function findIdle(){const idle=units.filter(u=>u.type==='worker'&&u.task==='idle'),current=selected[0]?.id,i=idle.findIndex(u=>u.id===current),u=idle[(i+1)%idle.length];if(!u){notify('All workers have a task.');return;}selected=[{kind:'unit',id:u.id}];centerSelection();updateUI(true);} | | | function centerSelection(){const e=selected[0]&&getEntity(selected[0]);if(!e)return;const d=defs[e.type],p=project(e.x+(d?d.w/2:0),e.y+(d?d.h/2:0));cam.x=p.x;cam.y=p.y+20;} | | | function hitTest(sx,sy){let best=null,bd=Infinity;for(const u of units){const p=screen(u.x,u.y),distance=Math.hypot(sx-p.x,(sy-(p.y-12cam.zoom)).8);if(distance<18cam.zoom&&distance<bd){bd=distance;best={kind:'unit',id:u.id};}}if(best)return best; | | | const wp=unproject(sx,sy);for(const b of [...buildings].sort((a,b)=>b.x+b.y-a.x-a.y)){const d=defs[b.type];if(wp.x>=b.x&&wp.x<=b.x+d.w&&wp.y>=b.y&&wp.y<=b.y+d.h)return{kind:'building',id:b.id};const p=screen(b.x+d.w/2,b.y+d.h/2);if(Math.abs(sx-p.x)<(b.type==='tower'?21:41)cam.zoom&&sy<p.y+9cam.zoom&&sy>p.y-(b.type==='townhall'?125:b.type==='tower'?100:65)cam.zoom)return{kind:'building',id:b.id};} | | | for(const n of nodes){if(n.amount<=0||!explored[index(Math.floor(n.x),Math.floor(n.y))])continue;const p=screen(n.x,n.y),dist=Math.hypot((sx-p.x),((sy-(p.y-(n.type==='wood'?34:13)cam.zoom))).65);if(dist<(n.type==='wood'?25:27)cam.zoom&&dist<bd){bd=dist;best={kind:'node',id:n.id};}} | | | return best; | | | } | | | function command(sx,sy){if(d)return;if(placement){cancelPlacement();return;}const point=unproject(sx,sy),hit=hitTest(sx,sy),list=selected.filter(s=>s.kind==='unit').map(getEntity).filter(Boolean);if(!inMap(Math.floor(point.x),Math.floor(point.y))){notify('That is beyond the edge of the Reach.',true);return;}if(list.length){if(hit?.kind==='node'){orderGather(list,getEntity(hit));const nonWorkers=list.filter(u=>u.type!=='worker');if(nonWorkers.length)orderMove(nonWorkers,point.x,point.y);}else if(hit?.kind==='building'&&getEntity(hit).progress<1){let assigned=0;list.filter(u=>u.type==='worker').forEach(u=>{if(orderBuild(u,getEntity(hit)))assigned++;});if(!assigned)notify('Select a worker to finish construction.',true);}else orderMove(list,point.x,point.y);updateUI(true);}else if(selected[0]?.kind==='building'){const b=getEntity(selected[0]);if(b.type==='townhall'||b.type==='barracks'){b.rally={x:point.x,y:point.y};effects.push({type:'command',...point,t:0,max:1.5,color:'#ddbe78'});notify('Rally point set. New units will gather here.');}}else notify('Select a unit before giving an order.');} | | | function selectAt(sx,sy,add=false,isTouch=false){if(d)return;const p=unproject(sx,sy);if(placement){placeBuilding(p.x,p.y);return;}const hit=hitTest(sx,sy);if(isTouch&&selected.some(s=>s.kind==='unit')&&(!hit||hit.kind==='node'||hit.kind==='building'&&getEntity(hit).progress<1)){command(sx,sy);return;}if(hit){if(add&&hit.kind==='unit'){selected=selected.filter(s=>s.kind==='unit');const i=selected.findIndex(s=>s.id===hit.id);if(i>=0)selected.splice(i,1);else selected.push(hit);}else selected=[hit];}else if(!add)selected=[];updateUI(true);} | | | function zoom(delta,sx=viewW/2,sy=viewH.42){const before=unproject(sx,sy);cam.zoom=Math.max(.55,Math.min(1.8,cam.zoom+delta));const after=unproject(sx,sy),p1=project(before.x,before.y),p2=project(after.x,after.y);cam.x+=p1.x-p2.x;cam.y+=p1.y-p2.y;updateUI();} | | | function resize(){const r=canvas.getBoundingClientRect();viewW=r.width;viewH=r.height;dpr=Math.min(2,window.devicePixelRatio||1);canvas.width=Math.round(viewWdpr);canvas.height=Math.round(viewHdpr);const mr=mini.getBoundingClientRect();mini.width=Math.round(mr.width2);mini.height=Math.round(mr.height2);} | | | function openModal(id){d=true;keys.clear();$(id).classList.add('open');if(id==='-modal'){$('-info').textContent=units.length+' people · '+buildings.filter(b=>b.progress>=1).length+' buildings · '+Math.floor(exploredTotal/(NN)100)+'% of the land discovered';$('reset-warning').style.display='none';}$(id).querySelector('button')?.focus();} | | | function closeModal(id){$(id).classList.remove('open');d=!!document.querySelector('.modal-backdrop.open');last=performance.now();canvas.focus({preventScroll:true});} | | | function saveGame(manual=true){try{const data={version:1,state,units,buildings,nodes,explored,cam};localStorage.setItem(SAVE_KEY,JSON.stringify(data));$('save-status').innerHTML=icon('save')+'Saved just now';if(manual)notify('Your world has been saved.');return true;}catch(e){$('save-status').textContent='Saving unavailable in this browser';if(manual)notify('Could not save. Browser storage may be full or disabled.',true);return false;}} | | | function loadGame(){try{const raw=localStorage.getItem(SAVE_KEY);if(!raw)return false;const data=JSON.parse(raw);if(data.version!==1||!Array.isArray(data.explored)||data.explored.length!==NN||!data.state||!Array.isArray(data.units)||!Array.isArray(data.buildings)||!Array.isArray(data.nodes)||!data.buildings.some(b=>b.type==='townhall'))return false;generate();state=data.state;units=data.units;buildings=data.buildings;nodes=data.nodes;explored=data.explored;cam=data.cam||cam;selected=[{kind:'building',id:buildings.find(b=>b.type==='townhall').id}];recomputeReachable();updateVision();return true;}catch(e){return false;}} | | | canvas.addEventListener('contextmenu',e=>{e.preventDefault();const r=canvas.getBoundingClientRect();command(e.clientX-r.left,e.clientY-r.top);}); | | | canvas.addEventListener('pointerdown',e=>{if(d||e.button===2)return;canvas.focus({preventScroll:true});const r=canvas.getBoundingClientRect();mouse={x:e.clientX-r.left,y:e.clientY-r.top,inside:true};drag={startX:mouse.x,startY:mouse.y,oldX:mouse.x,oldY:mouse.y,mode:e.button===1||e.altKey?'pan':e.pointerType==='touch'?'touch':'select',moved:false,shift:e.shiftKey,id:e.pointerId};canvas.setPointerCapture(e.pointerId);if(e.button===1)e.preventDefault();}); | | | canvas.addEventListener('pointermove',e=>{const r=canvas.getBoundingClientRect();mouse={x:e.clientX-r.left,y:e.clientY-r.top,inside:true};if(drag){if(Math.hypot(mouse.x-drag.startX,mouse.y-drag.startY)>6)drag.moved=true;if(drag.mode==='pan'||drag.mode==='touch'&&drag.moved){cam.x-=(mouse.x-drag.oldX)/cam.zoom;cam.y-=(mouse.y-drag.oldY)/cam.zoom;}drag.oldX=mouse.x;drag.oldY=mouse.y;}else if(!placement){hover=hitTest(mouse.x,mouse.y);canvas.style.cursor=hover?'pointer':'default';}}); | | | canvas.addEventListener('pointerup',e=>{if(!drag)return;const d=drag;drag=null;if(canvas.hasPointerCapture(e.pointerId))canvas.releasePointerCapture(e.pointerId);if(d)return;if(d.mode==='select'&&d.moved&&!placement){const left=Math.min(d.startX,mouse.x),right=Math.max(d.startX,mouse.x),top=Math.min(d.startY,mouse.y),bottom=Math.max(d.startY,mouse.y);const picked=units.filter(u=>{const p=screen(u.x,u.y);return p.x>=left&&p.x<=right&&p.y-12cam.zoom>=top&&p.y-12cam.zoom<=bottom;}).map(u=>({kind:'unit',id:u.id}));selected=d.shift?[...selected.filter(s=>s.kind==='unit'),...picked.filter(p=>!selected.some(s=>s.kind==='unit'&&s.id===p.id))]:picked;updateUI(true);}else if(!d.moved&&d.mode!=='pan')selectAt(mouse.x,mouse.y,d.shift,d.mode==='touch');}); | | | canvas.addEventListener('pointercancel',()=>drag=null);canvas.addEventListener('pointerleave',()=>{mouse.inside=false;});canvas.addEventListener('wheel',e=>{e.preventDefault();const r=canvas.getBoundingClientRect();zoom(e.deltaY>0?-.08:.08,e.clientX-r.left,e.clientY-r.top);},{passive:false}); | | | canvas.addEventListener('dblclick',e=>{if(placement||d)return;const r=canvas.getBoundingClientRect(),hit=hitTest(e.clientX-r.left,e.clientY-r.top);if(hit?.kind==='unit'){const u=getEntity(hit);selected=units.filter(e=>e.type===u.type).map(e=>({kind:'unit',id:e.id}));updateUI(true);}}); | | | mini.addEventListener('pointerdown',e=>{const r=mini.getBoundingClientRect(),sx=(e.clientX-r.left)mini.width/r.width,sy=(e.clientY-r.top)mini.height/r.height,scale=Math.min(mini.width/(N2),mini.height/N).91,ox=mini.width/2,oy=(mini.height-Nscale)/2;const x=(sy-oy)/scale+(sx-ox)/(2scale),y=(sy-oy)/scale-(sx-ox)/(2scale);if(inMap(x,y)){const p=project(x,y);cam.x=p.x;cam.y=p.y;}}); | | | mini.addEventListener('contextmenu',e=>{e.preventDefault();const r=mini.getBoundingClientRect(),sx=(e.clientX-r.left)mini.width/r.width,sy=(e.clientY-r.top)mini.height/r.height,scale=Math.min(mini.width/(N2),mini.height/N).91,ox=mini.width/2,oy=(mini.height-Nscale)/2,x=(sy-oy)/scale+(sx-ox)/(2scale),y=(sy-oy)/scale-(sx-ox)/(2scale);if(!d&&inMap(x,y)){orderMove(selected.filter(s=>s.kind==='unit').map(getEntity),x,y);updateUI(true);}}); | | | document.addEventListener('keydown',e=>{if(e.target.matches('input,textarea'))return;const k=e.key.toLowerCase();if([' ','arrowup','arrowdown','arrowleft','arrowright'].includes(k))e.preventDefault();if(k==='escape'){if(document.querySelector('.modal-backdrop.open'))closeModal(document.querySelector('.modal-backdrop.open').id);else if(placement)cancelPlacement();else{selected=[];updateUI(true);}return;}if(k==='p'&&!e.repeat){if($('-modal').classList.contains('open'))closeModal('-modal');else if(!document.querySelector('.modal-backdrop.open'))openModal('-modal');return;}if(k==='?'&&!e.repeat){if($('help-modal').classList.contains('open'))closeModal('help-modal');else if(!d)openModal('help-modal');return;}if(d)return;keys.add(k);if(e.repeat)return;if(['1','2','3','4'].includes(k))startPlacement(['house','barracks','sawmill','tower'][Number(k)-1]);else if(k==='h')home();else if(k===' ')centerSelection();else if(k==='g')toggleGrid();else if(k==='i')findIdle();else if(k==='e')handleAction('explore');else if(k==='x')handleAction('stop');else if(k==='r'){const btn=$('actions').querySelector('button');if(btn)handleAction(btn.dataset.action);}else if(k==='t'&&selected.some(s=>getEntity(s)?.type==='barracks'))train('soldier');}); | | | document.addEventListener('keyup',e=>keys.delete(e.key.toLowerCase()));window.addEventListener('blur',()=>{keys.clear();drag=null;saveGame(false);});document.addEventListener('visibilitychange',()=>{if(document.hidden){keys.clear();saveGame(false);}last=performance.now();});window.addEventListener('pagehide',()=>saveGame(false)); | | | function toggleGrid(){showGrid=!showGrid;$('grid-button').classList.toggle('active',showGrid);$('grid-button').setAttribute('aria-pressed',showGrid);} | | | $('-help').onclick=()=>{closeModal('-modal');openModal('help-modal');};$('grid-button').onclick=toggleGrid;$('home-button').onclick=home;$('zoom-in').onclick=()=>zoom(.15);$('zoom-out').onclick=()=>zoom(-.15);$('help-button').onclick=()=>openModal('help-modal');$('-button').onclick=()=>openModal('-modal');$('resume-button').onclick=()=>closeModal('-modal');$('save-button').onclick=()=>saveGame(true);$('new-button').onclick=()=>{$('reset-warning').style.display='block';};$('cancel-new').onclick=()=>{$('reset-warning').style.display='none';};$('confirm-new').onclick=()=>{newState();cam.zoom=1;home();updateUI(true);saveGame(false);closeModal('-modal');notify('A new chapter begins. Welcome to the Green Reach.');};$('collapse-quest').onclick=()=>{$('objectives').classList.toggle('collapsed');$('collapse-quest').setAttribute('aria-expanded',!$('objectives').classList.contains('collapsed'));};document.querySelectorAll('[data-close]').forEach(b=>b.onclick=()=>closeModal(b.dataset.close)); | | | $('actions').addEventListener('click',e=>{const b=e.target.closest('[data-action]');if(b&&!d)handleAction(b.dataset.action);});$('build-buttons').addEventListener('click',e=>{const b=e.target.closest('[data-build]');if(b)placement===b.dataset.build?cancelPlacement():startPlacement(b.dataset.build);}); |
| | window.addEventListener('resize',resize); |
| | const loaded=loadGame();if(!loaded)newState();for(const type of [...Object.keys(defs),...Object.keys(unitsDef),'gold','wood','empty','group'])portraits[type]=makePortrait(type); |
| | $('build-buttons').innerHTML=['house','barracks','sawmill','tower'].map((type,i)=>{const d=defs[type];return '<button class="build-card" data-build="'+type+'" title="'+d.name+' ('+(i+1)+') — '+d.desc+'"><span class="keycap">'+(i+1)+'</span><img src="'+portraits[type]+'" alt=""><span class="build-name">'+d.name+'</span><span class="build-cost">'+(d.gold?icon('gold')+d.gold:'')+'<span class="wood-cost">'+icon('wood')+'</span>'+d.wood+'</span></button>';}).join('');resize();if(!loaded)home();if(innerWidth<=640){$('objectives').classList.add('collapsed');$('collapse-quest').setAttribute('aria-expanded','false');}updateUI(true); | | | function frame(now){let dt=Math.min(.06,(now-(last||now))/1000);last=now;if(!d&&!document.hidden){const speed=440dt/cam.zoom;if(keys.has('w')||keys.has('arrowup'))cam.y-=speed;if(keys.has('s')||keys.has('arrowdown'))cam.y+=speed;if(keys.has('a')||keys.has('arrowleft'))cam.x-=speed;if(keys.has('d')||keys.has('arrowright'))cam.x+=speed;cam.x=Math.max(-NTW/2,Math.min(NTW/2,cam.x));cam.y=Math.max(-130,Math.min(NTH+130,cam.y));update(dt);uiTimer+=dt;saveTimer+=dt;if(uiTimer>.2){uiTimer=0;updateUI();}if(saveTimer>15){saveTimer=0;saveGame(false);}}draw();requestAnimationFrame(frame);} | | | requestAnimationFrame(frame); | | | // A compact inspection surface for deterministic gameplay verification. | | | window.Wilderland={get state(){return state;},get units(){return units;},get buildings(){return buildings;},get nodes(){return nodes;},get explored(){return explored;},get selected(){return selected;},get camera(){return cam;},get d(){return d;},get terrain(){return terrain;},screen,unproject,pathfind,validPlacement,save:saveGame}; | | | })(); | | | </script> | | | </body> | | | </html> |