This commit is contained in:
33333-33333 2026-06-19 20:52:40 +09:00
commit 8eb66f2e66
12 changed files with 2715 additions and 522 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 114 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 770 B

After

Width:  |  Height:  |  Size: 2.3 KiB

Before After
Before After

View file

@ -50,16 +50,40 @@ button {
margin-bottom: 16px;
}
.top-left {
display: flex;
align-items: center;
gap: 12px;
min-width: 0;
}
.brand {
display: flex;
align-items: center;
gap: 10px;
flex-wrap: nowrap;
min-width: 0;
}
.room-link {
text-decoration: none;
white-space: nowrap;
background: rgba(255,255,255,0.86);
}
h1 {
margin: 0 0 3px;
margin: 0;
font-size: clamp(24px, 3.4vw, 42px);
letter-spacing: 0.03em;
white-space: nowrap;
}
.subtitle {
margin: 0;
color: var(--muted);
font-size: 14px;
font-size: clamp(11px, 1.1vw, 13px);
line-height: 1.35;
white-space: nowrap;
}
.top-actions {
@ -115,6 +139,7 @@ h1 {
min-height: var(--field-min-height, 620px);
display: block;
cursor: crosshair;
background: linear-gradient(180deg, #cfe3d3, #d9d0ad);
}
.field-dialog {
@ -161,6 +186,71 @@ h1 {
margin-top: 12px;
display: flex;
justify-content: flex-end;
gap: 8px;
}
.save-text-dialog-panel {
width: min(760px, calc(100vw - 30px));
}
.save-text-area {
width: 100%;
min-height: 180px;
margin-top: 10px;
padding: 10px;
border-radius: 12px;
border: 1px solid var(--line);
background: rgba(255,255,255,0.82);
color: var(--ink);
font: 12px/1.45 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
resize: vertical;
word-break: break-all;
}
.save-text-actions { flex-wrap: wrap; }
.archive-reset-dialog-panel {
width: min(420px, 100%);
}
.archive-reset-actions {
justify-content: space-between;
gap: 10px;
}
.archive-reset-actions .btn {
min-width: 132px;
}
.ecology-dialog-panel {
width: min(760px, 100%);
}
.ecology-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 10px;
}
.ecology-card {
display: grid;
grid-template-columns: 84px minmax(0, 1fr);
gap: 10px;
align-items: center;
padding: 12px;
border-radius: 14px;
background: linear-gradient(180deg, rgba(255,255,255,0.88), rgba(243,237,225,0.76));
border: 1px solid rgba(84,68,48,0.12);
}
.ecology-card img {
width: 84px;
height: 84px;
object-fit: contain;
filter: drop-shadow(0 8px 18px rgba(56, 44, 32, 0.10));
}
.ecology-card strong {
display: block;
margin-bottom: 4px;
font-size: 15px;
}
.ecology-card p {
margin: 0;
color: var(--muted);
font-size: 12px;
line-height: 1.55;
}
.toast {
@ -258,7 +348,50 @@ h1 {
.tool[data-tool="fence_v"] { --tool-icon: url("../assets/ui/tool_fence_v.png"); }
.tool[data-tool="fence_h"] { --tool-icon: url("../assets/ui/tool_fence_h.png"); }
.tool[data-tool="bed"] { --tool-icon: url("../assets/ui/tool_bed.png"); }
.tool[data-tool="poke"] { --tool-icon: url("../assets/ui/tool_poke.png"); }
.tool[data-tool="ball"] { --tool-icon: none; }
.tool[data-tool="ball"]::before {
content: "⚽";
background: none;
font-size: 24px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool[data-tool="ball"]::after {
content: "⚽";
background: none;
font-size: 48px;
line-height: 1;
opacity: 0.10;
right: -6px;
left: auto;
top: 50%;
bottom: auto;
transform: translateY(-50%);
width: 54px;
height: 54px;
}
.tool[data-tool="poke"] { --tool-icon: none; }
.tool[data-tool="poke"]::before {
content: "👈";
background: none;
font-size: 25px;
line-height: 1;
filter: drop-shadow(0 2px 3px rgba(52,40,26,0.12));
}
.tool[data-tool="poke"]::after {
content: "👈";
background: none;
font-size: 48px;
line-height: 1;
opacity: 0.11;
right: -6px;
left: auto;
top: 50%;
bottom: auto;
transform: translateY(-50%);
width: 54px;
height: 54px;
}
.tool[data-tool="delete"] { --tool-icon: url("../assets/ui/tool_delete.png"); }
.tool[data-tool="new"] { --tool-icon: url("../assets/ui/tool_new.png"); }
.tool.selected {
@ -388,6 +521,7 @@ meter {
flex-direction: column;
gap: 7px;
font-size: 12px;
overscroll-behavior: contain;
}
.log-entry {
padding: 8px 9px;
@ -422,6 +556,8 @@ meter {
@media (max-width: 980px) {
#app { padding: 10px; }
.topbar { align-items: flex-start; flex-direction: column; }
.top-left { align-items: flex-start; flex-direction: column; }
.brand { align-items: center; flex-direction: row; gap: 10px; }
.layout { grid-template-columns: 1fr; }
.panel { max-height: none; overflow: visible; }
#gameCanvas { height: var(--field-mobile-height, 66vh); min-height: var(--field-mobile-min-height, 460px); }
@ -458,15 +594,34 @@ meter {
margin-bottom: 6px;
}
.lineage-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 8px;
flex-wrap: wrap;
}
.lineage-head h2 { margin-bottom: 0; }
.lineage-main-content {
max-height: 560px;
min-height: 320px;
height: min(78vh, 980px);
min-height: 520px;
max-height: calc(100vh - 120px);
overflow: auto;
overscroll-behavior: auto;
padding: 8px 4px 16px;
overscroll-behavior: contain;
padding: 10px 6px 24px;
background: rgba(255,250,244,0.48);
border-radius: 14px;
border: 1px solid rgba(84,68,48,0.10);
scrollbar-gutter: stable both-edges;
}
.lineage-forest {
display: flex;
flex-direction: column;
gap: 12px;
}
.lineage-main-content .lineage-tree {
@ -513,70 +668,19 @@ body::before {
box-shadow: inset 0 1px 0 rgba(255,255,255,0.55);
}
/* Compact family board: parent ┳ parent / child, without unrelated founders. */
.family-board { padding: 0; overflow: visible; }
.family-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(246px, 1fr));
gap: 10px 12px;
align-items: start;
padding: 2px 2px 10px;
}
.family-cluster {
position: relative;
min-height: 106px;
padding: 7px 7px 8px;
border-radius: 14px;
background: rgba(255,253,248,0.56);
border: 1px solid rgba(84,68,48,0.09);
contain: layout paint;
}
.family-parents {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
align-items: center;
}
.family-child-slot {
display: flex;
justify-content: center;
margin-top: 14px;
}
/* Family tree node primitives. The archive deliberately uses these nodes inside an SVG-backed tree, not the old card/grid UI. */
.family-node {
display: grid;
grid-template-columns: 22px minmax(0, 1fr);
align-items: center;
gap: 5px;
position: relative;
display: block;
min-width: 0;
min-height: 34px;
padding: 4px 6px;
padding: 6px 8px;
border-radius: 10px;
background: linear-gradient(180deg, rgba(255,253,248,0.96), rgba(248,241,229,0.90));
border: 1px solid rgba(84,68,48,0.14);
box-shadow: 0 3px 8px rgba(64,48,32,0.045);
}
.family-child-slot .family-node { width: min(150px, 68%); }
.family-connector {
position: relative;
height: 12px;
margin: 0 42px;
}
.family-connector::before {
content: "";
position: absolute;
left: 0;
right: 0;
top: 7px;
height: 2px;
background: rgba(107, 88, 61, 0.55);
}
.family-connector span {
position: absolute;
left: calc(50% - 1px);
top: 7px;
width: 2px;
height: 16px;
background: rgba(107, 88, 61, 0.55);
overflow: hidden;
isolation: isolate;
}
.family-node.child {
border-color: rgba(103, 178, 44, 0.38);
@ -584,10 +688,26 @@ body::before {
}
.family-node[data-family-tarinai-id] { cursor: pointer; transition: transform .12s ease, border-color .12s ease, box-shadow .12s ease; }
.family-node[data-family-tarinai-id]:hover { transform: translateY(-1px); border-color: rgba(112,178,66,0.42); box-shadow: 0 5px 12px rgba(64,48,32,0.08); }
.family-node.dead { opacity: 0.68; filter: grayscale(0.35); }
.family-node.dead { opacity: 0.9; filter: grayscale(0.32); }
.family-node.missing { opacity: 0.48; border-style: dashed; }
.family-node img { width: 22px; height: 22px; object-fit: contain; }
.family-copy { min-width: 0; line-height: 1.12; }
.family-portrait {
position: absolute;
right: -6px;
bottom: -8px;
width: 64px;
height: 64px;
z-index: 0;
pointer-events: none;
}
.family-node.alive .family-portrait { opacity: 0.80; }
.family-node.dead .family-portrait { opacity: 0.20; }
.family-portrait img {
width: 100%;
height: 100%;
object-fit: contain;
filter: saturate(0.95);
}
.family-copy { position: relative; z-index: 1; min-width: 0; line-height: 1.12; max-width: calc(100% - 8px); }
.family-copy strong, .family-copy span {
display: block;
overflow: hidden;
@ -595,21 +715,15 @@ body::before {
white-space: nowrap;
}
.family-copy .death-reason {
white-space: normal;
overflow: visible;
text-overflow: clip;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
line-height: 1.12;
margin-top: 1px;
color: rgba(126, 80, 64, 0.92);
}
.family-copy strong { font-size: 11px; }
.family-copy span { color: var(--muted); font-size: 8.5px; margin-top: 1px; }
@media (max-width: 760px) {
.family-grid { grid-template-columns: 1fr; }
.family-cluster { min-height: 102px; }
}
.colony-card { overflow: visible; min-height: 0; flex: 0 0 auto; }
@ -678,193 +792,134 @@ body::before {
font-size: 13px;
color: rgba(64,52,38,0.86);
}
.lineage-generation {
display: grid;
grid-template-columns: 62px 1fr;
gap: 8px;
align-items: start;
padding: 8px 0;
border-top: 1px dashed rgba(84,68,48,0.13);
/* Family archive: actual top-down tree diagram. */
.lineage-family-tree {
overflow-x: auto;
overflow-y: visible;
padding: 10px 10px 22px;
-webkit-overflow-scrolling: touch;
overscroll-behavior-x: contain;
}
.lineage-generation:first-of-type { border-top: 0; }
.lineage-generation-label {
.lineage-family-tree h3 {
position: sticky;
left: 0;
z-index: 3;
width: max-content;
padding-right: 12px;
background: rgba(255,253,248,0.82);
border-radius: 8px;
}
.lineage-tree-hint {
position: sticky;
left: 0;
z-index: 3;
width: max-content;
max-width: 100%;
margin: -2px 0 8px;
padding: 5px 8px;
border-radius: 9px;
color: var(--muted);
font-size: 11px;
color: var(--muted);
padding-top: 8px;
background: rgba(255,255,255,0.62);
border: 1px solid rgba(84,68,48,0.08);
}
.lineage-generation-nodes {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: stretch;
}
.family-node.lineage-node {
width: 132px;
min-height: 54px;
align-items: flex-start;
}
.lineage-children {
display: block;
margin-top: 3px;
font-size: 9px;
color: rgba(117,106,94,0.84);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.lineage-empty-family {
color: var(--muted);
font-size: 12px;
padding: 10px;
}
.lineage-tree-panel {
.lineage-family-tree .lineage-tree-panel {
position: relative;
overflow: visible;
min-width: 360px;
margin-top: 6px;
border-radius: 14px;
background:
linear-gradient(90deg, rgba(84,68,48,0.045) 1px, transparent 1px),
linear-gradient(180deg, rgba(84,68,48,0.040) 1px, transparent 1px),
rgba(255,250,244,0.48);
background-size: 34px 34px, 34px 34px, auto;
border: 1px solid rgba(84,68,48,0.10);
}
.lineage-links {
.lineage-family-tree .lineage-links {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
overflow: visible;
pointer-events: none;
z-index: 1;
}
.lineage-links path {
.lineage-render-status {
position: sticky;
top: 0;
left: 0;
z-index: 6;
width: max-content;
margin: 0 0 8px;
padding: 6px 10px;
border-radius: 999px;
font-size: 12px;
color: var(--muted);
background: rgba(255,255,255,0.88);
border: 1px solid rgba(84,68,48,0.10);
box-shadow: 0 4px 10px rgba(64,48,32,0.06);
}
.lineage-family-tree .lineage-links path {
fill: none;
stroke: rgba(107, 88, 61, 0.58);
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.lineage-links .partner-link { stroke-width: 2.4; stroke: rgba(98, 80, 56, 0.66); }
.lineage-links .child-link { stroke-width: 1.9; stroke: rgba(107, 88, 61, 0.54); }
.tree-node {
.lineage-family-tree .lineage-links .partner-link {
stroke-width: 2.4;
stroke: rgba(98, 80, 56, 0.68);
stroke-dasharray: 5 4;
}
.lineage-family-tree .lineage-links .child-link {
stroke-width: 2;
stroke: rgba(107, 88, 61, 0.58);
}
.lineage-family-tree .tree-node {
position: absolute;
width: 132px;
min-height: 54px;
height: 68px;
min-height: 68px;
box-sizing: border-box;
align-items: flex-start;
z-index: 2;
}
.tree-generation-label {
.lineage-family-tree .tree-node.child {
border-color: rgba(103,178,44,0.34);
}
.lineage-family-tree .tree-generation-label {
position: absolute;
left: 0;
left: 10px;
width: 54px;
padding: 3px 0;
border-radius: 999px;
font-size: 10px;
color: var(--muted);
z-index: 1;
text-align: center;
background: rgba(255,255,255,0.70);
border: 1px solid rgba(84,68,48,0.08);
z-index: 2;
}
.lineage-family { overflow-x: auto; }
.lineage-family-readable {
overflow: visible;
}
.lineage-readable-grid {
display: grid;
grid-template-columns: minmax(300px, 1.05fr) minmax(280px, 0.95fr);
gap: 12px;
align-items: start;
}
.lineage-member-column,
.lineage-reproduction-column {
min-width: 0;
}
.lineage-generation-block {
padding: 8px 0;
border-top: 1px dashed rgba(84,68,48,0.12);
}
.lineage-generation-block:first-child { border-top: 0; padding-top: 0; }
.lineage-generation-block > b,
.lineage-column-title {
display: block;
margin: 0 0 7px;
color: rgba(84,68,48,0.72);
font-size: 11px;
}
.lineage-person-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(132px, 1fr));
gap: 7px;
}
.family-node.lineage-person {
width: auto;
min-height: 46px;
}
.lineage-pair-list {
display: grid;
gap: 8px;
}
.lineage-pair-card {
padding: 8px;
border-radius: 12px;
border: 1px solid rgba(84,68,48,0.12);
background: rgba(255,253,248,0.70);
}
.lineage-pair-parents,
.lineage-pair-children {
display: flex;
flex-wrap: wrap;
gap: 5px;
align-items: center;
}
.lineage-pair-parents {
padding-bottom: 5px;
}
.lineage-pair-children {
padding-top: 5px;
border-top: 1px solid rgba(84,68,48,0.08);
}
.lineage-plus {
color: rgba(84,68,48,0.44);
font-weight: 700;
line-height: 28px;
}
.lineage-birth-arrow {
color: rgba(107, 88, 61, 0.62);
font-size: 14px;
line-height: 1;
padding: 1px 0 3px 13px;
}
.lineage-mini {
display: inline-grid;
grid-template-columns: 20px minmax(0, 1fr);
grid-template-rows: auto auto;
gap: 0 4px;
align-items: center;
max-width: 132px;
min-width: 88px;
padding: 4px 6px;
.lineage-family-tree .lineage-empty-family {
position: absolute;
left: 72px;
bottom: 8px;
padding: 5px 8px;
border-radius: 9px;
border: 1px solid rgba(84,68,48,0.12);
background: rgba(255,255,255,0.64);
background: rgba(255,255,255,0.70);
}
.lineage-mini.child {
border-color: rgba(103,178,44,0.28);
background: rgba(246,255,235,0.62);
}
.lineage-mini.dead { opacity: 0.64; filter: grayscale(0.25); }
.lineage-mini img {
grid-row: 1 / span 2;
width: 20px;
height: 20px;
object-fit: contain;
}
.lineage-mini b,
.lineage-mini small {
display: block;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.lineage-mini b { font-size: 10px; line-height: 1.1; }
.lineage-mini small { color: var(--muted); font-size: 8px; line-height: 1.1; }
@media (max-width: 760px) {
.lineage-readable-grid { grid-template-columns: 1fr; }
@media (max-width: 720px) {
.ecology-grid { grid-template-columns: 1fr; }
.ecology-card { grid-template-columns: 68px minmax(0, 1fr); }
.ecology-card img { width: 68px; height: 68px; }
}
@media (max-width: 720px) {
.lineage-main-content {
height: 70vh;
min-height: 420px;
max-height: none;
}
}

View file

@ -3,22 +3,25 @@
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;&#x7BB1;</title>
<title>たりない観察</title>
<link rel="stylesheet" href="css/styles.css" />
</head>
<body>
<div id="app">
<header class="topbar">
<div>
<h1>&#x305F;&#x308A;&#x306A;&#x3044;&#x89B3;&#x5BDF;&#x7BB1;</h1>
<p class="subtitle">&#x305F;&#x308A;&#x306A;&#x3044;&#x304C;&#x66AE;&#x3089;&#x3059;&#x5C0F;&#x3055;&#x306A;&#x5EAD;&#x3092;&#x89B3;&#x5BDF;&#x3057;&#x307E;&#x3059;&#x3002;&#x98DF;&#x3079;&#x3001;&#x7720;&#x308A;&#x3001;&#x95A2;&#x4FC2;&#x3092;&#x5909;&#x3048;&#x307E;&#x3059;&#x3002;</p>
<div class="top-left">
<a class="btn room-link" href="https://host.nishi.boats/~333/">333の部屋に移動</a>
<div class="brand">
<h1>たりない観察</h1>
<p class="subtitle">操作: 左クリックで選択・配置 / つまむでドラッグ / 右ドラッグでパン / ホイールで拡大縮小</p>
</div>
</div>
<div class="top-actions">
<button id="pauseBtn" class="btn primary">&#x4E00;&#x6642;&#x505C;&#x6B62;</button>
<button id="speedBtn" class="btn">&#x901F;&#x5EA6; x1</button>
<button id="ecologyBtn" class="btn">&#x305F;&#x308A;&#x306A;&#x3044;&#x306E;&#x751F;&#x614B;</button>
<button id="soundBtn" class="btn">&#x97F3; OFF</button>
<button id="saveBtn" class="btn">&#x4FDD;&#x5B58;</button>
<button id="loadBtn" class="btn">&#x8AAD;&#x8FBC;</button>
<button id="creditsBtn" class="btn">クレジット</button>
<button id="resetBtn" class="btn danger">&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
</div>
</header>
@ -40,6 +43,7 @@
<button class="tool selected" data-tool="observe"><span>O</span>&#x89B3;&#x5BDF;</button>
<button class="tool" data-tool="delete"><span>D</span>&#x524A;&#x9664;</button>
<button class="tool" data-tool="poke"><span>P</span>&#x3064;&#x3064;&#x304F;</button>
<button class="tool" data-tool="pinch"><span>T</span>&#x3064;&#x307E;&#x3080;</button>
<button class="tool" data-tool="new"><span>N</span>&#x8FFD;&#x52A0;</button>
</div>
</section>
@ -48,6 +52,8 @@
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="zunchi"><span>Z</span>&#x305A;&#x3093;&#x3061;</button>
<button class="tool" data-tool="sweet"><span>S</span>&#x305A;&#x3093;&#x3060;&#x9905;</button>
<button class="tool" data-tool="love_mochi"><span>M</span>&#x5A9A;&#x85AC;&#x9905;</button>
<button class="tool" data-tool="fight_mochi"><span>K</span>&#x55A7;&#x5629;&#x9905;</button>
<button class="tool" data-tool="water"><span>W</span>&#x6C34;</button>
<button class="tool" data-tool="grass"><span>G</span>&#x8349;</button>
</div>
@ -57,12 +63,8 @@
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="stone"><span>R</span>&#x77F3;</button>
<button class="tool" data-tool="bed"><span>B</span>&#x5E72;&#x8349;&#x5BDD;&#x5E8A;</button>
<button class="tool" data-tool="ball"><span>L</span>&#x30DC;&#x30FC;&#x30EB;</button>
<button class="tool" data-tool="firecracker"><span>F</span>&#x7206;&#x7AF9;</button>
</div>
</section>
<section class="tool-category" data-tool-category="fence">
<button class="tool-category-toggle" type="button" aria-expanded="true">&#x67F5;</button>
<div class="tool-grid tool-category-body">
<button class="tool" data-tool="fence_v"><span>V</span>&#x7E26;&#x306E;&#x67F5;</button>
<button class="tool" data-tool="fence_h"><span>H</span>&#x6A2A;&#x306E;&#x67F5;</button>
</div>
@ -76,9 +78,9 @@
<h2>&#x30B3;&#x30ED;&#x30CB;&#x30FC;</h2>
<div class="stats colony-stats">
<div class="stat"><span>&#x500B;&#x4F53;&#x6570;</span><strong id="statPop">0</strong></div>
<div class="stat"><span>&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div>
<div class="stat"><span>&#x7D2F;&#x8A08;&#x6B7B;&#x4EA1;&#x6570;</span><strong id="statDead">0</strong></div>
</div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">&#x751F;&#x6D3B;</button><button data-chart="events">&#x4E8B;&#x4EF6;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div>
<div id="colonyChartTabs" class="chart-tabs"><button data-chart="population" class="active">&#x4EBA;&#x53E3;</button><button data-chart="life">&#x751F;&#x6D3B;</button><button data-chart="events">&#x30A4;&#x30D9;&#x30F3;&#x30C8;</button><button data-chart="objects">&#x30AA;&#x30D6;&#x30B8;&#x30A7;&#x30AF;&#x30C8;</button></div>
<div id="colonyChartLegend" class="colony-chart-legend"></div>
<div id="colonyChart" class="colony-chart" aria-label="&#x30B3;&#x30ED;&#x30CB;&#x30FC;&#x306E;&#x63A8;&#x79FB;&#x30B0;&#x30E9;&#x30D5;" role="img"></div>
</section>
@ -89,15 +91,17 @@
</section>
<section class="card log-card">
<h2>&#x8A18;&#x9332;</h2>
<h2>&#x30A4;&#x30D9;&#x30F3;&#x30C8;</h2>
<div id="log" class="log"></div>
</section>
</aside>
</main>
<section class="card lineage-main-card">
<h2>&#x5BB6;&#x7CFB;&#x56F3;</h2>
<p class="hint">&#x5B50;&#x3069;&#x3082;&#x306F;&#x6210;&#x9577;&#x3059;&#x308B;&#x307E;&#x3067;&#x89AA;&#x306B;&#x3064;&#x3044;&#x3066;&#x3044;&#x304D;&#x307E;&#x3059;&#x3002;&#x3053;&#x306E;&#x9818;&#x57DF;&#x3092;&#x30B9;&#x30AF;&#x30ED;&#x30FC;&#x30EB;&#x3057;&#x3066;&#x5BB6;&#x7CFB;&#x3092;&#x78BA;&#x8A8D;&#x3067;&#x304D;&#x307E;&#x3059;&#x3002;</p>
<div class="lineage-head">
<h2>&#x5BB6;&#x7CFB;&#x56F3;</h2>
<button id="archiveResetBtn" class="btn danger" type="button">&#x5BB6;&#x7CFB;&#x56F3;&#x3092;&#x30EA;&#x30BB;&#x30C3;&#x30C8;</button>
</div>
<div id="archiveContent" class="archive-content lineage-main-content"></div>
</section>
</div>
@ -116,6 +120,68 @@
</div>
</div>
<div id="creditsDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="creditsDialogTitle">
<div class="field-dialog-panel credits-dialog-panel">
<h2 id="creditsDialogTitle">クレジット</h2>
<div class="credits-content">
<p><strong>製作:</strong> 333</p>
<p><strong>たりない:</strong> むらさき氏</p>
<p><strong>効果音:</strong> VOICEVOX: ずんだもん 効果音ラボ: <a href="https://soundeffect-lab.info/" target="_blank" rel="noopener">https://soundeffect-lab.info/</a></p>
</div>
<div class="field-dialog-actions credits-actions">
<button id="creditsCloseBtn" class="btn" type="button">閉じる</button>
</div>
</div>
</div>
<div id="archiveResetDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="archiveResetDialogTitle">
<div class="field-dialog-panel archive-reset-dialog-panel">
<h2 id="archiveResetDialogTitle">家系図をリセットしますか?</h2>
<p class="hint">現在の親子関係と世代情報を消去します。個体そのものや庭の状態は消えません。</p>
<div class="field-dialog-actions archive-reset-actions">
<button id="archiveResetCancelBtn" class="btn" type="button">キャンセル</button>
<button id="archiveResetConfirmBtn" class="btn danger" type="button">リセットする</button>
</div>
</div>
</div>
<div id="ecologyDialog" class="field-dialog hidden" role="dialog" aria-modal="true" aria-labelledby="ecologyDialogTitle">
<div class="field-dialog-panel ecology-dialog-panel">
<h2 id="ecologyDialogTitle">たりないの生態</h2>
<div class="ecology-grid">
<section class="ecology-card">
<img src="assets/sprites/tarinai_05_drool.png" alt="">
<div><strong>食べる</strong><p>お腹がすくと食べ物や草を探します。食べられない時間が長いと弱っていきます。</p></div>
</section>
<section class="ecology-card">
<img src="assets/sprites/tarinai_07_sleep.png" alt="">
<div><strong>眠る</strong><p>疲れるとその場や寝床で眠ります。休めると元気を取り戻します。</p></div>
</section>
<section class="ecology-card">
<img src="assets/sprites/tarinai_21_normal_happy.png" alt="">
<div><strong>なつく</strong><p>気分がよい個体はカーソルや仲間に寄ってきます。やさしく構うと落ち着きやすくなります。</p></div>
</section>
<section class="ecology-card">
<img src="assets/sprites/tarinai_24_stress_dizzy.png" alt="">
<div><strong>関係が変わる</strong><p>近くで過ごすうちに仲良しや苦手が生まれます。強いストレスは行動や表情に出ます。</p></div>
</section>
<section class="ecology-card">
<img src="assets/sprites/tarinai_27_birth_ritual.png" alt="">
<div><strong>増える</strong><p>落ち着いた相手どうしから新しい個体が生まれ、家系図に親子関係が残ります。</p></div>
</section>
<section class="ecology-card">
<img src="assets/sprites/tarinai_18_fear.png" alt="">
<div><strong>環境の影響</strong><p>雨や食べ物不足、つつき、喧嘩などで暮らしぶりが変わります。庭の配置も大切です。</p></div>
</section>
</div>
<div class="field-dialog-actions">
<button id="ecologyCloseBtn" class="btn" type="button">閉じる</button>
</div>
</div>
</div>
<script src="js/data.js"></script>
<script src="js/math.js"></script>
<script src="js/assets.js"></script>

View file

@ -8,7 +8,7 @@ const SPRITES = [
{ id: "cry", label: "\u6ce3\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_06_cry.png" },
{ id: "sleep", label: "\u7720\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_07_sleep.png" },
{ id: "pokan", label: "\u307d\u304b\u3093\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_08_pokan.png" },
{ id: "stretch", label: "\u4f38\u3073\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_09_stretch.png" },
{ id: "stretch", label: "\u4f38\u3073\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_09_stretch.png", actionOnly: true },
{ id: "hurt", label: "\u75db\u304c\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_10_hurt.png", actionOnly: true },
{ id: "weak", label: "\u5f31\u308a\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_11_weak.png", actionOnly: true },
{ id: "back", label: "\u5965\u5411\u304d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_12_back.png", actionOnly: true },
@ -27,6 +27,7 @@ const SPRITES = [
{ id: "stress_sweat", label: "\u9ad8\u30b9\u30c8\u30ec\u30b9\u305f\u308a\u306a\u3044\u30fb\u6c57", path: "assets/sprites/tarinai_25_stress_sweat.png", actionOnly: true },
{ id: "intimidate", label: "\u5a01\u5687\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_26_intimidate.png", actionOnly: true },
{ id: "birth_ritual", label: "\u7e41\u6b96\u524d\u305f\u308a\u306a\u3044", path: "assets/sprites/tarinai_27_birth_ritual.png", actionOnly: true },
{ id: "zunchi_slave", label: "\u305a\u3093\u3061\u3069\u308c\u3044", path: "assets/sprites/tarinai_28_zunchi_slave.png", displayOnly: true },
];
const DECOR_ASSETS = [
{ id: "zunchi", path: "assets/objects/zunchi.png" },
@ -59,6 +60,7 @@ const ALPHA_BOUNDS = {
stress_dizzy: { x: 16, y: 21, w: 480, h: 433, imageW: 512, imageH: 468 },
stress_sweat: { x: 16, y: 26, w: 480, h: 428, imageW: 512, imageH: 468 },
intimidate: { x: 98, y: 16, w: 316, h: 438, imageW: 512, imageH: 468 },
zunchi_slave: { x: 17, y: 22, w: 478, h: 431, imageW: 512, imageH: 468 },
zunchi: { x: 0, y: 0, w: 236, h: 178, imageW: 236, imageH: 178 },
zunchi_02: { x: 8, y: 63, w: 220, h: 105, imageW: 236, imageH: 178 },
};

View file

@ -1,4 +1,4 @@
"use strict";
"use strict";
class Item {
constructor(type, x, y) {
@ -7,10 +7,10 @@ class Item {
this.x = x;
this.y = y;
this.r = {
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
this.amount = {
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80;
this.age = 0;
this.seed = Math.random() * 1000;
@ -25,11 +25,26 @@ class Item {
this.seedTimer = rand(18, 38);
this.eatenAmount = 0;
this.fertilityBoost = 0;
this.lifeSpan = rand(420, 860);
this.wither = 0;
}
if (type === "bed") {
this.comfort = rand(0.86, 1.14);
this.wear = 0;
}
if (type === "ball") {
this.vx = rand(-5, 5);
this.vy = rand(-5, 5);
this.prevX = x;
this.prevY = y;
this.spin = rand(0, Math.PI * 2);
this.spinVelocity = rand(-1.6, 1.6);
this.lastKickedAt = -999;
this.lastKickerId = "";
this.lastPokedAt = -999;
this.pokeCombo = 0;
this.lastPokeAngle = rand(0, Math.PI * 2);
}
if (type === "zunchi") {
this.stage = "fresh";
this.stageTimer = 0;
@ -53,7 +68,7 @@ class Item {
}
}
if (this.type === "water") this.amount -= dt * 0.9;
if (this.type === "sweet") this.amount -= dt * 0.12;
if (["sweet", "love_mochi", "fight_mochi"].includes(this.type)) this.amount -= dt * 0.12;
if (this.type === "trace") this.amount -= dt * 1.35;
if (this.type === "splat") this.amount -= dt * 1.05;
if (this.type === "firecracker") {
@ -63,6 +78,7 @@ class Item {
else this.amount = 0;
}
}
if (this.type === "ball") this.updateBall(dt, worldRef);
if (this.type === "grass") {
this.lifecycleTimer += dt;
const factor = worldRef.grassUpdateFactor ? worldRef.grassUpdateFactor() : 1;
@ -82,6 +98,147 @@ class Item {
}
}
}
updateBall(dt, worldRef) {
this.prevX = this.x;
this.prevY = this.y;
const maxSpeed = 1250;
const clampVelocity = () => {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > maxSpeed) {
this.vx = (this.vx || 0) / speed * maxSpeed;
this.vy = (this.vy || 0) / speed * maxSpeed;
}
return Math.hypot(this.vx || 0, this.vy || 0);
};
const moving = clampVelocity();
if (moving > 0.04) {
this.x += (this.vx || 0) * dt;
this.y += (this.vy || 0) * dt;
const p = Math.max(28, CONFIG.worldPadding || 30);
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.x > worldRef.w - p) { this.x = worldRef.w - p; this.vx = -Math.abs(this.vx || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
if (this.y > worldRef.h - p) { this.y = worldRef.h - p; this.vy = -Math.abs(this.vy || 0) * 0.72; this.spinVelocity *= -0.72; }
this.resolveBallObstacleCollisions(dt, worldRef);
clampVelocity();
const groundFriction = Math.pow(0.72, dt);
this.vx *= groundFriction;
this.vy *= groundFriction;
}
this.spin = (this.spin || 0) + (this.spinVelocity || 0) * dt + (this.vx || 0) * dt / Math.max(8, this.r || 18);
this.spinVelocity *= Math.pow(0.65, dt);
if (Math.hypot(this.vx || 0, this.vy || 0) < 0.18) { this.vx = 0; this.vy = 0; }
}
resolveBallObstacleCollisions(dt, worldRef) {
if (!worldRef?.nearbyItems) return;
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const searchRadius = Math.max(130, (this.r || 18) + speed * Math.max(dt || 0.016, 0.016) + 96);
const items = worldRef.nearbyItems(this.x, this.y, searchRadius) || [];
for (const it of items) {
if (!it || it === this || it.dead) continue;
if (it.type === "bed") {
this.applyBallHayDrag(it, dt, worldRef);
} else if (it.type === "stone") {
this.resolveBallCircleBounce(it, (it.r || 20) * 1.08 + (this.r || 18), 0.82, worldRef);
} else if (it.type === "fence_v" || it.type === "fence_h") {
this.resolveBallFenceBounce(it, worldRef);
}
}
}
applyBallHayDrag(bed, dt, worldRef) {
const rx = Math.max(26, (bed.r || 37) * 1.72 + (this.r || 18) * 0.40);
const ry = Math.max(18, (bed.r || 37) * 0.92 + (this.r || 18) * 0.34);
const dx = this.x - bed.x;
const dy = this.y - bed.y;
const inside = (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1;
if (!inside) return;
const slow = Math.pow(0.16, Math.max(0.016, dt || 0.016));
this.vx *= slow;
this.vy *= slow;
this.spinVelocity *= Math.pow(0.24, Math.max(0.016, dt || 0.016));
if ((this.lastHaySlowLogAt || -999) + 3.5 < (worldRef.time || 0) && Math.hypot(this.vx || 0, this.vy || 0) > 120) {
this.lastHaySlowLogAt = worldRef.time || 0;
worldRef.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(14, this.r * 0.95), life: 0.20, color: "rgba(214,184,96,0.42)" }));
}
}
resolveBallCircleBounce(obstacle, hitRadius, restitution, worldRef) {
let dx = this.x - obstacle.x;
let dy = this.y - obstacle.y;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) return;
if (d < 0.001) {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
if (speed > 0.001) { dx = -(this.vx || 0) / speed; dy = -(this.vy || 0) / speed; d = 1; }
else { dx = Math.cos(this.seed || 0); dy = Math.sin(this.seed || 0); d = 1; }
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = obstacle.x + nx * (hitRadius + 0.5);
this.y = obstacle.y + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - (1 + restitution) * toward * nx;
this.vy = (this.vy || 0) - (1 + restitution) * toward * ny;
} else {
this.vx = (this.vx || 0) + nx * 24;
this.vy = (this.vy || 0) + ny * 24;
}
this.vx *= 0.96;
this.vy *= 0.96;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 5.5, -38, 38);
this.emitBallBounce(worldRef, obstacle);
}
resolveBallFenceBounce(fence, worldRef) {
const vertical = fence.type === "fence_v";
const len = Math.max(112, (fence.r || 42) * 3.55);
const thick = Math.max(10, (fence.r || 42) * 0.31);
const halfLen = len / 2;
const sx1 = vertical ? fence.x : fence.x - halfLen;
const sy1 = vertical ? fence.y - halfLen : fence.y;
const sx2 = vertical ? fence.x : fence.x + halfLen;
const sy2 = vertical ? fence.y + halfLen : fence.y;
const vx = sx2 - sx1;
const vy = sy2 - sy1;
const segLenSq = vx * vx + vy * vy || 1;
const u = clamp(((this.x - sx1) * vx + (this.y - sy1) * vy) / segLenSq, 0, 1);
const cx = sx1 + vx * u;
const cy = sy1 + vy * u;
const hitRadius = (this.r || 18) + thick / 2 + 2;
let dx = this.x - cx;
let dy = this.y - cy;
let d = Math.hypot(dx, dy);
if (d >= hitRadius) return;
if (d < 0.001) {
if (vertical) { dx = (this.vx || 0) >= 0 ? -1 : 1; dy = 0; }
else { dx = 0; dy = (this.vy || 0) >= 0 ? -1 : 1; }
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const toward = (this.vx || 0) * nx + (this.vy || 0) * ny;
this.x = cx + nx * (hitRadius + 0.5);
this.y = cy + ny * (hitRadius + 0.5);
if (toward < 0) {
this.vx = (this.vx || 0) - 1.78 * toward * nx;
this.vy = (this.vy || 0) - 1.78 * toward * ny;
}
this.vx *= 0.94;
this.vy *= 0.94;
this.spinVelocity = clamp((this.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 6.2, -38, 38);
this.emitBallBounce(worldRef, fence);
}
emitBallBounce(worldRef, obstacle) {
const now = worldRef?.time || 0;
if ((this.lastObstacleBounceAt || -999) + 0.08 > now) return;
this.lastObstacleBounceAt = now;
worldRef?.effects?.push(new Effect("ring", this.x, this.y, { size: Math.max(13, (this.r || 18) * 0.82), life: 0.18, color: obstacle?.type === "stone" ? "rgba(165,165,150,0.45)" : "rgba(160,116,64,0.42)" }));
}
updateGrass(dt, worldRef) {
const light = worldRef.lightLevel ? worldRef.lightLevel() : 0.7;
const weather = worldRef.weather || "sunny";
@ -124,13 +281,22 @@ class Item {
const growthRate = (0.006 + light * 0.004 + weatherBoost * 0.01 + waterNear * 0.014 + this.fertilityBoost * 0.014) * dt * 0.5;
this.growth = clamp((this.growth ?? 0.25) + growthRate - dryStress * dt, 0, 0.88);
this.health = clamp((this.health ?? 1) + dt * (waterNear > 0.05 || weather === "light_rain" ? 0.017 : -0.0015 - dryStress * 0.28), 0.18, 1);
const grassLimit = CONFIG.grassLimit ?? 96;
const grassLimit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96);
if (this.growth >= 0.86 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
const spawned = this.seedAround(worldRef, fertileSource, this.fertilityBoost > 0.25, 2);
if (spawned > 0) this.growth = rand(0.52, 0.68);
this.seedTimer = rand(18, 34);
}
this.amount = clamp(this.growth * 120 * this.health, 3, 130);
if (this.age > (this.lifeSpan || 640) * 0.82) {
const fade = clamp((this.age - (this.lifeSpan || 640) * 0.82) / Math.max((this.lifeSpan || 640) * 0.18, 1), 0, 1);
this.wither = fade;
this.health = clamp((this.health ?? 1) - dt * (0.004 + fade * 0.020), 0, 1);
this.growth = clamp((this.growth ?? 0.25) - dt * fade * 0.005, 0, 0.88);
} else {
this.wither = Math.max(0, (this.wither || 0) - dt * 0.08);
}
this.amount = clamp(this.growth * 120 * this.health, 0, 130);
if (this.age > (this.lifeSpan || 640) * 1.05) this.amount -= dt * (1.2 + (this.wither || 0) * 24);
this.seedTimer -= dt * (this.growth > 0.72 ? 1 : 0.25) * 0.5;
if (this.seedTimer <= 0 && this.growth > 0.64 && (worldRef.itemCounts?.grass || 0) < grassLimit) {
this.seedTimer = rand(24, 54);
@ -139,7 +305,7 @@ class Item {
}
seedAround(worldRef, fertileSource = null, fertile = false, maxNew = 1) {
let made = 0;
const limit = CONFIG.grassLimit ?? 96;
const limit = worldRef.grassLimit ? worldRef.grassLimit() : (CONFIG.grassLimit ?? 96);
for (let n = 0; n < maxNew && (worldRef.itemCounts?.grass || 0) < limit; n++) {
const source = fertile && fertileSource ? fertileSource : this;
const spot = worldRef.findGrassSproutSpot
@ -157,7 +323,9 @@ class Item {
}
updateZunchi(dt, worldRef) {
const rainBoost = worldRef.weather === "light_rain" ? 1.35 : 1;
const rainy = worldRef.weather === "light_rain";
const rainBoost = rainy ? 1.95 : 1;
const decayBoost = rainy ? 2.15 : 1;
let waterBoost = 0;
const nearby = worldRef.nearbyItems ? worldRef.nearbyItems(this.x, this.y, 84) : (worldRef.items || []);
for (const it of nearby) {
@ -167,17 +335,19 @@ class Item {
this.stageTimer += dt * 2.0 * (rainBoost + waterBoost * 1.3);
if (this.stage === "fresh") {
this.freshness = clamp(1 - this.stageTimer / 110, 0, 1);
this.amount -= dt * 0.05 * decayBoost;
if (this.stageTimer > 110) { this.stage = "dry"; this.stageTimer = 0; }
} else if (this.stage === "dry") {
this.freshness = clamp(0.55 - this.stageTimer / 220, 0.20, 0.55);
this.amount -= dt * 0.09 * decayBoost;
if (this.stageTimer > 150) { this.stage = "decomposing"; this.stageTimer = 0; this.fertility = 0.35; }
} else if (this.stage === "decomposing") {
this.fertility = clamp(this.fertility + dt * 0.015, 0, 1);
this.amount -= dt * 0.12;
this.amount -= dt * 0.12 * decayBoost;
if (this.stageTimer > 190) { this.stage = "fertile_soil"; this.stageTimer = 0; this.amount = Math.min(this.amount, 140); }
} else if (this.stage === "fertile_soil") {
this.fertility = clamp(1 - this.stageTimer / 360, 0, 1);
this.amount -= dt * 0.26;
this.amount -= dt * 0.26 * decayBoost;
}
}
get dead() {
@ -187,8 +357,11 @@ class Item {
return {
type: this.type, x: this.x, y: this.y, amount: this.amount, age: this.age, seed: this.seed,
stage: this.stage, stageTimer: this.stageTimer, fertility: this.fertility, freshness: this.freshness,
growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost,
comfort: this.comfort, wear: this.wear, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax
growth: this.growth, health: this.health, seedTimer: this.seedTimer, eatenAmount: this.eatenAmount, fertilityBoost: this.fertilityBoost, lifeSpan: this.lifeSpan, wither: this.wither,
comfort: this.comfort, wear: this.wear, crawlOpen: this.crawlOpen, zunchiVariant: this.zunchiVariant, toolSize: this.toolSize, blastScale: this.blastScale, fuseTimer: this.fuseTimer, fuseMax: this.fuseMax, dropTimer: this.dropTimer, dropMax: this.dropMax,
vx: this.vx, vy: this.vy, prevX: this.prevX, prevY: this.prevY,
spin: this.spin, spinVelocity: this.spinVelocity, lastKickedAt: this.lastKickedAt, lastKickerId: this.lastKickerId,
lastPokedAt: this.lastPokedAt, pokeCombo: this.pokeCombo, lastPokeAngle: this.lastPokeAngle
};
}
static from(o) {
@ -200,10 +373,10 @@ class Item {
it.x = o.x;
it.y = o.y;
it.r = {
food: 14, sweet: 13, water: 12, grass: 17, stone: 20, bed: 37, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
food: 14, sweet: 13, love_mochi: 13, fight_mochi: 13, water: 12, grass: 17, stone: 20, bed: 37, ball: 18, firecracker: 15, fence_v: 42, fence_h: 42, trace: 16, splat: 26, zunchi: 14
}[type] || 12;
it.amount = o.amount ?? ({
food: 85, sweet: 62, water: 64, grass: 120, stone: 999, bed: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
food: 85, sweet: 62, love_mochi: 62, fight_mochi: 62, water: 64, grass: 120, stone: 999, bed: 999, ball: 999, firecracker: 999, fence_v: 999, fence_h: 999, trace: 220, splat: 260, zunchi: 240
}[type] || 80);
it.age = o.age || 0;
it.seed = o.seed || Math.random() * 1000;
@ -223,6 +396,19 @@ class Item {
it.comfort = 1;
it.wear = 0;
}
if (type === "ball") {
it.vx = o.vx || 0;
it.vy = o.vy || 0;
it.prevX = o.prevX ?? o.x;
it.prevY = o.prevY ?? o.y;
it.spin = o.spin || 0;
it.spinVelocity = o.spinVelocity || 0;
it.lastKickedAt = o.lastKickedAt ?? -999;
it.lastKickerId = o.lastKickerId || "";
it.lastPokedAt = o.lastPokedAt ?? -999;
it.pokeCombo = o.pokeCombo || 0;
it.lastPokeAngle = o.lastPokeAngle || rand(0, Math.PI * 2);
}
if (type === "zunchi") {
it.stage = "fresh";
it.stageTimer = 0;
@ -243,8 +429,22 @@ class Item {
it.seedTimer = o.seedTimer ?? it.seedTimer;
it.eatenAmount = o.eatenAmount || 0;
it.fertilityBoost = o.fertilityBoost || 0;
it.lifeSpan = o.lifeSpan ?? it.lifeSpan;
it.wither = o.wither ?? it.wither ?? 0;
it.comfort = o.comfort ?? it.comfort;
it.wear = o.wear ?? it.wear;
it.crawlOpen = o.crawlOpen ?? it.crawlOpen;
it.vx = o.vx ?? it.vx ?? 0;
it.vy = o.vy ?? it.vy ?? 0;
it.prevX = o.prevX ?? it.prevX ?? it.x;
it.prevY = o.prevY ?? it.prevY ?? it.y;
it.spin = o.spin ?? it.spin ?? 0;
it.spinVelocity = o.spinVelocity ?? it.spinVelocity ?? 0;
it.lastKickedAt = o.lastKickedAt ?? it.lastKickedAt ?? -999;
it.lastKickerId = o.lastKickerId || it.lastKickerId || "";
it.lastPokedAt = o.lastPokedAt ?? it.lastPokedAt ?? -999;
it.pokeCombo = o.pokeCombo ?? it.pokeCombo ?? 0;
it.lastPokeAngle = o.lastPokeAngle ?? it.lastPokeAngle ?? rand(0, Math.PI * 2);
it.zunchiVariant = o.zunchiVariant || it.zunchiVariant || "zunchi";
it.toolSize = o.toolSize || it.toolSize || "medium";
it.blastScale = o.blastScale || it.blastScale || 1;
@ -303,10 +503,12 @@ class Item {
ctx.save();
ctx.translate(this.x, this.y + dropY);
ctx.globalAlpha = visibleAlpha;
if (this.type === "food" || this.type === "sweet") {
if (this.type === "food" || this.type === "sweet" || this.type === "love_mochi" || this.type === "fight_mochi") {
const sweet = this.type === "sweet";
ctx.fillStyle = sweet ? "#fffaf1" : "#f1dfb7";
ctx.strokeStyle = sweet ? "#9a8a72" : "#8d6f48";
const loveMochi = this.type === "love_mochi";
const fightMochi = this.type === "fight_mochi";
ctx.fillStyle = loveMochi ? "#fff1f6" : (fightMochi ? "#fff3ec" : (sweet ? "#fffaf1" : "#f1dfb7"));
ctx.strokeStyle = loveMochi ? "#bf6f8b" : (fightMochi ? "#bc6d42" : (sweet ? "#9a8a72" : "#8d6f48"));
ctx.lineWidth = 2;
ctx.beginPath();
const bob = 0;
@ -321,6 +523,30 @@ class Item {
ctx.beginPath();
ctx.ellipse(-this.r * 0.32, -this.r * 0.16, this.r * 0.24, this.r * 0.10, -0.35, 0, Math.PI * 2);
ctx.fill();
} else if (loveMochi) {
ctx.fillStyle = "#ff6a9c";
ctx.font = `${Math.round((this.r || 13) * 1.15)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("❤", 0, 1);
ctx.fillStyle = "rgba(255,255,255,0.55)";
ctx.beginPath();
ctx.ellipse(-this.r * 0.28, -this.r * 0.18, this.r * 0.23, this.r * 0.10, -0.35, 0, Math.PI * 2);
ctx.fill();
} else if (fightMochi) {
ctx.fillStyle = "#d55632";
ctx.font = `${Math.round((this.r || 13) * 1.02)}px sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("✦", 0, 1);
ctx.strokeStyle = "rgba(168,76,42,0.72)";
ctx.lineWidth = 1.4;
ctx.beginPath();
ctx.moveTo(-this.r * 0.44, -this.r * 0.42);
ctx.lineTo(this.r * 0.44, this.r * 0.42);
ctx.moveTo(-this.r * 0.44, this.r * 0.42);
ctx.lineTo(this.r * 0.44, -this.r * 0.42);
ctx.stroke();
} else {
ctx.fillStyle = "#fff7dc";
for (let i = 0; i < 5; i++) {
@ -343,7 +569,8 @@ class Item {
const stage = growth < 0.22 ? "sprout" : growth < 0.48 ? "young" : "mature";
const eaten = this.eatenAmount > 14 || growth < 0.16;
const grassLoad = 1;
ctx.strokeStyle = eaten ? "#7b8755" : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438"));
const wither = clamp(this.wither || 0, 0, 1);
ctx.strokeStyle = eaten ? "#7b8755" : (wither > 0.08 ? `rgba(${Math.round(106 + wither * 40)}, ${Math.round(132 - wither * 32)}, ${Math.round(70 - wither * 18)}, 1)` : (styleNight ? "#294b40" : (styleWarm ? "#64ad3f" : "#4f8438")));
ctx.shadowColor = "transparent";
ctx.shadowBlur = 0;
ctx.lineCap = "round";
@ -399,6 +626,27 @@ class Item {
ctx.lineTo(x + Math.cos(a) * len * 0.5, y + Math.sin(a) * len * 0.5);
ctx.stroke();
}
} else if (this.type === "ball") {
const speed = Math.hypot(this.vx || 0, this.vy || 0);
const moving = clamp(speed / 260, 0, 1);
ctx.save();
ctx.rotate(this.spin || 0);
ctx.shadowColor = styleNight ? "rgba(0,0,0,0.34)" : "rgba(52,40,26,0.18)";
ctx.shadowBlur = 2 + moving * 3;
ctx.shadowOffsetY = 2;
ctx.font = `${Math.round((this.r || 18) * 2.0)}px "Apple Color Emoji", "Segoe UI Emoji", "Noto Color Emoji", sans-serif`;
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.fillText("⚽", 0, 1);
if (moving > 0.55) {
ctx.globalAlpha = 0.14 + moving * 0.10;
ctx.shadowColor = "transparent";
ctx.fillStyle = "#ffffff";
ctx.beginPath();
ctx.ellipse(-this.r * 0.35, -this.r * 0.42, this.r * 0.25, this.r * 0.12, -0.45, 0, Math.PI * 2);
ctx.fill();
}
ctx.restore();
} else if (this.type === "firecracker") {
const fuse = clamp((this.fuseTimer ?? 5) / Math.max(this.fuseMax || 5, 0.1), 0, 1);
const flash = fuse < 0.35 ? (0.5 + Math.sin(t * 20 + this.seed) * 0.5) : 0;

View file

@ -468,16 +468,58 @@ function drawAtmosphericLighting(ctx, w, h, lighting) {
function drawRain(ctx, w, h, t) {
if (world.weather !== "light_rain") return;
ctx.save();
ctx.globalAlpha = 0.28;
ctx.strokeStyle = "rgba(197, 220, 230, 0.70)";
ctx.lineWidth = 1;
for (let i = 0; i < 34; i++) {
const x = (randSeed(18000 + i, 0, w) + t * 26) % w;
const y = (randSeed(18400 + i, 0, h) + t * 72) % h;
const perf = world.performanceLevel ? world.performanceLevel() : 0;
const count = perf >= 2 ? 58 : 118;
ctx.lineCap = "round";
for (let i = 0; i < count; i++) {
const speed = randSeed(18100 + i, 132, 230);
const cycle = h + 320;
const len = randSeed(18500 + i, 13, 26);
const tilt = randSeed(18600 + i, 5, 12);
const fall = randSeed(18400 + i, -220, cycle) + t * speed;
const y = ((fall % cycle) + cycle) % cycle - 140;
// The droplet's movement vector matches the visible diagonal stroke.
const driftPerY = -tilt / Math.max(1, len);
const xCycle = w + 220;
const rawX = randSeed(18000 + i, -70, w + 120) + fall * driftPerY + Math.sin(t * 0.9 + i * 0.43) * 4;
const x = ((rawX % xCycle) + xCycle) % xCycle - 90;
const alpha = y < 110 ? clamp((y + 68) / 178, 0, 1) : 1;
ctx.globalAlpha = 0.34 + 0.30 * alpha;
ctx.strokeStyle = "rgba(198, 226, 240, 0.92)";
ctx.lineWidth = randSeed(18700 + i, 1.0, 1.8);
ctx.beginPath();
ctx.moveTo(x, y);
ctx.lineTo(x - 4, y + 10);
ctx.lineTo(x - tilt, y + len);
ctx.stroke();
if (perf < 3 && i % 2 === 0) {
ctx.globalAlpha = 0.18 + 0.18 * alpha;
ctx.fillStyle = "rgba(215, 237, 246, 0.92)";
ctx.beginPath();
ctx.ellipse(x - tilt * 0.9, y + len + 2, 1.8, 3.0, -0.28, 0, Math.PI * 2);
ctx.fill();
}
}
if (perf < 3) {
const dripCount = perf >= 2 ? 10 : 18;
for (let i = 0; i < dripCount; i++) {
const phase = (t * randSeed(18800 + i, 0.55, 1.15) + randSeed(18900 + i, 0, 100)) % 1;
const x = randSeed(19000 + i, 12, w - 12);
const topY = -22 + phase * (h + 58);
const bottomY = topY + randSeed(19100 + i, 14, 30);
const lean = randSeed(19200 + i, -6, -2);
ctx.globalAlpha = 0.44;
ctx.strokeStyle = "rgba(206, 233, 247, 0.98)";
ctx.lineWidth = randSeed(19300 + i, 1.2, 2.2);
ctx.beginPath();
ctx.moveTo(x, topY);
ctx.lineTo(x + lean, bottomY);
ctx.stroke();
ctx.globalAlpha = 0.26;
ctx.beginPath();
ctx.ellipse(x + lean, bottomY + 2, 2.1, 3.5, -0.18, 0, Math.PI * 2);
ctx.fillStyle = "rgba(220, 241, 248, 0.98)";
ctx.fill();
}
}
ctx.restore();
}
@ -486,6 +528,22 @@ function weatherLabel(weather) {
return { sunny: "\u6674\u308c", cloudy: "\u66c7\u308a", light_rain: "\u5c0f\u96e8" }[weather] || "\u6674\u308c";
}
function fillScreenFallback(ctx, w, h, lighting) {
const light = lighting?.light ?? 0.7;
ctx.save();
const bg = ctx.createLinearGradient(0, 0, 0, Math.max(1, h));
if (light > 0.42) {
bg.addColorStop(0, "#cfe3d3");
bg.addColorStop(1, "#d9d0ad");
} else {
bg.addColorStop(0, "#24355f");
bg.addColorStop(1, "#4f5d71");
}
ctx.fillStyle = bg;
ctx.fillRect(0, 0, w, h);
ctx.restore();
}
function render() {
activeCtx = ctx;
const sceneW = world.w, sceneH = world.h;
@ -495,9 +553,9 @@ function render() {
const cameraX = world.cameraX || 0;
const cameraY = world.cameraY || 0;
const viewScale = world.viewScale ? world.viewScale() : 1;
ctx.clearRect(0, 0, screenW, screenH);
const lighting = getLightingState(world);
fillScreenFallback(ctx, screenW, screenH, lighting);
const light = lighting.light;
const cachedBackground = ensureBackgroundCache(sceneW, sceneH, lighting);
@ -523,7 +581,13 @@ function render() {
beginFieldTransform();
for (const it of world.items) {
if (it.type !== "zunchi") it.draw(ctx, world.time, lighting);
if (it.type === "trace" || it.type === "splat") it.draw(ctx, world.time, lighting);
}
for (const ef of world.effects) {
if (ef.type === "bleed") ef.draw(ctx);
}
for (const it of world.items) {
if (it.type !== "zunchi" && it.type !== "trace" && it.type !== "splat") it.draw(ctx, world.time, lighting);
}
for (const it of world.items) {
if (it.type === "zunchi") it.draw(ctx, world.time, lighting);
@ -546,7 +610,9 @@ function render() {
const sorted = world.sortedDrawList ? world.sortedDrawList() : world.tarinai;
for (const t of sorted) t.draw(ctx, world.time, lighting);
for (const ef of world.effects) ef.draw(ctx);
for (const ef of world.effects) {
if (ef.type !== "bleed") ef.draw(ctx);
}
ctx.restore();
// Light affecting the whole visible scene, including characters and ground.
@ -649,4 +715,4 @@ function roundedRect(ctx, x, y, w, h, r) {
}
window.TarinaiRender = { randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, drawSelectedCard, render, roundedRect };
window.TarinaiRender = { randSeed, roundedBlob, getLightingState, projectedShadowParams, imageVisibleBottomFromTop, drawImageProjectedShadow, drawProjectedShadow, drawRadialGlow, drawItemGlow, drawCreatureGlow, drawDawnRimLight, drawGardenBackdrop, drawGardenBed, drawLightRays, drawAtmosphericLighting, drawRain, fillScreenFallback, drawSelectedCard, render, roundedRect };

View file

@ -1,4 +1,4 @@
"use strict";
"use strict";
function makeName(worldRef = null) {
const existing = new Set((worldRef?.tarinai || []).filter(t => !t.dead).map(t => t.name));
const build = () => {
@ -65,12 +65,13 @@ function stableUnit(seed, salt = "") {
}
const PERSONALITIES = {
timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00 },
glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42 },
sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92 },
lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00 },
irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94 },
calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94 },
timid: { label: "\u81c6\u75c5", desc: "\u9003\u3052\u3084\u3059\u304f\u3001\u55a7\u5629\u3092\u907f\u3051\u308b", fear: 1.42, fight: 0.48, social: 0.96, relation: 1.04, sleep: 1.00, zunda: 1.00, play: 0.78 },
glutton: { label: "\u98df\u3044\u3057\u3093\u574a", desc: "\u305a\u3093\u3060\u9905\u3092\u512a\u5148\u3057\u3084\u3059\u3044", fear: 0.92, fight: 0.82, social: 1.00, relation: 1.00, sleep: 0.95, zunda: 1.42, play: 0.84 },
sleepy: { label: "\u7720\u305f\u304c\u308a", desc: "\u65e9\u3081\u306b\u5bdd\u5e8a\u3078\u5411\u304b\u3046", fear: 0.96, fight: 0.72, social: 0.92, relation: 0.96, sleep: 1.34, zunda: 0.92, play: 0.68 },
lonely: { label: "\u5bc2\u3057\u304c\u308a", desc: "\u4ed6\u306e\u305f\u308a\u306a\u3044\u3078\u5bc4\u308a\u3084\u3059\u3044", fear: 1.02, fight: 0.70, social: 1.34, relation: 1.38, sleep: 0.96, zunda: 1.00, play: 1.08 },
irritable: { label: "\u6012\u308a\u3063\u307d\u3044", desc: "\u55a7\u5629\u304c\u8d77\u304d\u3084\u3059\u3044", fear: 0.82, fight: 1.82, social: 0.78, relation: 0.78, sleep: 0.90, zunda: 0.94, play: 0.82 },
playful: { label: "\u904a\u3073\u597d\u304d", desc: "\u30dc\u30fc\u30eb\u3084\u52d5\u304f\u3082\u306e\u306b\u5f15\u304d\u5bc4\u305b\u3089\u308c\u3084\u3059\u3044", fear: 0.92, fight: 0.74, social: 1.10, relation: 1.04, sleep: 0.94, zunda: 0.96, play: 1.72 },
calm: { label: "\u30de\u30a4\u30da\u30fc\u30b9", desc: "\u53cd\u5fdc\u304c\u7a4f\u3084\u304b\u3067\u5b89\u5b9a\u3057\u3084\u3059\u3044", fear: 0.82, fight: 0.58, social: 1.02, relation: 1.08, sleep: 1.02, zunda: 0.94, play: 0.86 },
};
const PERSONALITY_IDS = Object.keys(PERSONALITIES);
@ -199,6 +200,19 @@ class Effect {
ctx.arc(Math.cos(a) * r, Math.sin(a) * r, randSeed(this.seed + i + 12, 2.2, 5.2), 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "zunchi_miasma") {
const drift = 1 - alpha;
ctx.globalCompositeOperation = "source-over";
ctx.fillStyle = this.color || "rgba(16,78,28,0.62)";
for (let i = 0; i < 5; i++) {
const a = this.seed + i * 1.41 + drift * 1.8;
const px = Math.cos(a) * this.size * randSeed(this.seed + i, 0.08, 0.62);
const py = Math.sin(a * 0.8) * this.size * randSeed(this.seed + i + 7, 0.05, 0.42) - drift * this.size * 0.42;
ctx.globalAlpha = alpha * randSeed(this.seed + i + 14, 0.10, 0.28);
ctx.beginPath();
ctx.ellipse(px, py, this.size * randSeed(this.seed + i + 21, 0.22, 0.46), this.size * randSeed(this.seed + i + 35, 0.16, 0.38), a, 0, Math.PI * 2);
ctx.fill();
}
} else if (this.type === "bubble") {
const w = Math.max(30, 14 + this.text.length * 7);
const h = 20;

View file

@ -1,4 +1,4 @@
"use strict";
"use strict";
class Tarinai {
constructor(world, opts = {}) {
@ -33,8 +33,8 @@ class Tarinai {
this.affection = opts.affection ?? rand(0, 30);
this.need = opts.need || pick(NEEDS);
this.state = opts.state || "idle";
this.dead = false;
this.deathReason = "";
this.dead = !!opts.dead;
this.deathReason = opts.deathReason || "";
this.thought = "";
this.target = null;
this.targetKey = "";
@ -69,8 +69,17 @@ class Tarinai {
this.birthRitualRole = opts.birthRitualRole || 0;
this.birthRitualLeader = !!opts.birthRitualLeader;
this.pokeFlashTimer = opts.pokeFlashTimer ?? 0;
this.zunchiStain = 0;
this.zunchiStain = opts.zunchiStain ?? 0;
this.zunchiStainSeed = opts.zunchiStainSeed ?? stableUnit(this.id, "zunchi-stain");
this.zunchiDisease = Boolean(opts.zunchiDisease);
this.zunchiDiseaseSeverity = opts.zunchiDiseaseSeverity ?? (this.zunchiDisease ? 1 : 0);
this.zunchiDiseaseCooldown = opts.zunchiDiseaseCooldown ?? 0;
this.totalFightLosses = opts.totalFightLosses || 0;
this.isZunchiSlave = Boolean(opts.isZunchiSlave);
this.formerName = opts.formerName || null;
this.loveMochiTimer = opts.loveMochiTimer ?? 0;
this.fightMochiTimer = opts.fightMochiTimer ?? 0;
if (this.isZunchiSlave) this.name = "ずんちどれい";
this.tempComfort = opts.tempComfort ?? 0.62;
this.tempTimer = opts.tempTimer ?? stableUnit(this.id, "temp-start") * 0.55;
this.parents = opts.parents || [];
@ -105,16 +114,20 @@ class Tarinai {
this.nextBubbleAt = 0;
this.nextSleepBubbleAt = 0;
this.nextFearBubbleAt = 0;
this.nextCursorHeartAt = opts.nextCursorHeartAt ?? 0;
this.cursorPetting = opts.cursorPetting ?? 0;
this.aiTimer = opts.aiTimer ?? stableUnit(this.id, "ai-start") * 0.22;
this.envTimer = opts.envTimer ?? stableUnit(this.id, "env-start") * 0.55;
this.visibleSpriteId = opts.visibleSpriteId || null;
this.spriteLockUntil = opts.spriteLockUntil ?? 0;
this.entryTimer = opts.entryTimer ?? 0;
}
serialize() {
return {
id: this.id, name: this.name, type: this.type, x: this.x, y: this.y, vx: this.vx, vy: this.vy,
scale: this.scale, age: this.age, lifeSpan: this.lifeSpan, generation: this.generation, hasPaired: this.hasPaired,
dead: this.dead, deathReason: this.deathReason,
hunger: this.hunger, loneliness: this.loneliness, energy: this.energy, stress: this.stress, hpBarTimer: this.hpBarTimer,
affection: this.affection, need: this.need, state: this.state,
goodMode: this.goodMode, facing: this.facing, visualFacing: this.visualFacing, facingLockUntil: this.facingLockUntil, eatTimer: this.eatTimer,
@ -123,6 +136,8 @@ class Tarinai {
birthRitualTimer: this.birthRitualTimer, birthRitualMax: this.birthRitualMax, birthPartnerId: this.birthPartnerId,
birthRitualRole: this.birthRitualRole, birthRitualLeader: this.birthRitualLeader,
pokeFlashTimer: this.pokeFlashTimer, zunchiStain: this.zunchiStain, zunchiStainSeed: this.zunchiStainSeed,
totalFightLosses: this.totalFightLosses, isZunchiSlave: this.isZunchiSlave, formerName: this.formerName, loveMochiTimer: this.loveMochiTimer, fightMochiTimer: this.fightMochiTimer,
zunchiDisease: this.zunchiDisease, zunchiDiseaseSeverity: this.zunchiDiseaseSeverity, zunchiDiseaseCooldown: this.zunchiDiseaseCooldown,
tempComfort: this.tempComfort, tempTimer: this.tempTimer,
parents: this.parents, parentNames: this.parentNames, children: this.children, birthTime: this.birthTime,
fightTimer: this.fightTimer, fightCooldown: this.fightCooldown, fightTargetId: this.fightTargetId, fightTargetIds: this.fightTargetIds, targetGiveupKey: this.targetGiveupKey, targetGiveupUntil: this.targetGiveupUntil,
@ -132,7 +147,8 @@ class Tarinai {
defeatedTimer: this.defeatedTimer, defeatedById: this.defeatedById, fightWinnerId: this.fightWinnerId,
relationships: this.relationships, personality: this.personality, fallTimer: this.fallTimer, fallMax: this.fallMax, fallDir: this.fallDir,
blastSpinTimer: this.blastSpinTimer, blastSpinMax: this.blastSpinMax, postBirthPeaceTimer: this.postBirthPeaceTimer,
lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer,
lastMealColor: this.lastMealColor, aiTimer: this.aiTimer, envTimer: this.envTimer, nextCursorHeartAt: this.nextCursorHeartAt, cursorPetting: this.cursorPetting,
entryTimer: this.entryTimer || 0,
};
}
@ -283,6 +299,21 @@ class Tarinai {
return Boolean(this.hasPaired || (this.parents || []).length || (this.children || []).length);
}
nearestCoParent(maxRange = 220) {
let best = null;
let bestD = maxRange;
for (const o of this.world.tarinai || []) {
if (!o || o === this || o.dead) continue;
if (!(this.world.areCoParents?.(this, o))) continue;
const d = dist(this, o);
if (d < bestD) {
best = o;
bestD = d;
}
}
return best;
}
displayGeneration() {
return this.familyJoined() ? this.generation : null;
}
@ -378,13 +409,26 @@ class Tarinai {
}
lowHealthSprite() {
const critical = this.lack > 96 || this.hunger > 112 || this.energy < 7 || this.stress > 118;
// This is physical weakness only. Stress is handled by the stress sprite branch
// below so high-stress individuals do not incorrectly render as low-health.
const critical = this.hunger > 112 || this.energy < 7;
if (critical) return this.stretchTimer > 0 ? "stretch" : "weak";
const weak = this.lack > 88 || this.hunger > 102 || this.energy < 18 || this.stress > 104;
const weak = this.hunger > 102 || this.energy < 18;
if (weak) return "weak";
return null;
}
briefDeathReason(reason = this.deathReason) {
const text = String(reason || "");
if (!text) return "";
if (text.includes("食べ物がたりない")) return "飢え";
if (text.includes("寿命")) return "寿命";
if (text.includes("ストレス")) return "過ストレス";
if (text.includes("疲れ")) return "衰弱";
if (text.includes("つつかれ")) return "つつき";
return text.length > 10 ? `${text.slice(0, 10)}` : text;
}
variantSprite(category) {
const pools = {
hurt: ["hurt", "hurt2"],
@ -400,27 +444,120 @@ class Tarinai {
return stableChoice(this.id, `variant-${category}`, options) || options[0] || this.goodMode || "smile";
}
infectZunchiDisease(source = null, force = false) {
if (this.dead || this.zunchiDisease) return false;
if (!force && (this.zunchiDiseaseCooldown || 0) > 0) return false;
this.zunchiDisease = true;
this.zunchiDiseaseSeverity = Math.max(this.zunchiDiseaseSeverity || 0, source ? 0.72 : 0.86);
this.zunchiDiseaseCooldown = 12;
this.fearTimer = Math.max(this.fearTimer || 0, 0.35);
this.surpriseTimer = Math.max(this.surpriseTimer || 0, 0.45);
this.thought = "ずんち病にかかった";
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.20, "うぅ…", "rgba(38,82,38,0.82)");
return true;
}
recoverZunchiDisease(reason = "") {
if (!this.zunchiDisease) return false;
this.zunchiDisease = false;
this.zunchiDiseaseSeverity = 0;
this.zunchiDiseaseCooldown = 18;
this.zunchiStain = clamp((this.zunchiStain || 0) - 35, 0, 100);
this.stress = clamp(this.stress - 8, 0, 130);
this.thought = reason || "ずんち病が治まった";
this.world?.spawnBubble?.(this.x, this.y - this.radius * 1.18, "はう", "rgba(62,104,62,0.78)");
return true;
}
emitZunchiDiseaseEffect(dt) {
if (!this.zunchiDisease || !this.world?.effects) return;
if (Math.random() < dt * (1.2 + (this.zunchiDiseaseSeverity || 0) * 1.4)) {
this.world.effects.push(new Effect("zunchi_miasma", this.x + rand(-this.radius * 0.45, this.radius * 0.45), this.y - this.radius * rand(0.05, 0.72), {
vx: rand(-5, 5),
vy: rand(-10, -3),
life: rand(1.4, 2.4),
size: rand(8, 18) * (0.8 + (this.zunchiDiseaseSeverity || 0) * 0.45),
color: "rgba(16,78,28,0.70)",
}));
}
}
updateZunchiDisease(dt) {
this.zunchiStain = clamp(this.zunchiStain || 0, 0, 100);
this.zunchiDiseaseCooldown = Math.max(0, (this.zunchiDiseaseCooldown || 0) - dt);
if (this.world?.weather === "light_rain") {
this.zunchiStain = clamp(this.zunchiStain - dt * 2.4, 0, 100);
if (this.zunchiDisease) this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * 0.006);
}
if (!this.zunchiDisease && this.zunchiStain > 82 && Math.random() < dt * clamp((this.zunchiStain - 82) / 18, 0, 1) * 0.045) {
this.infectZunchiDisease(null, true);
this.world?.log?.(`${this.name}はずんち病を発症した。`, "accident");
}
if (this.zunchiDisease) {
this.zunchiDiseaseSeverity = clamp((this.zunchiDiseaseSeverity || 0.7) + dt * 0.002, 0, 1.25);
this.stress = clamp(this.stress + dt * 0.22 * this.zunchiDiseaseSeverity, 0, 130);
this.energy = clamp(this.energy - dt * 0.035 * this.zunchiDiseaseSeverity, 0, 100);
if (this.energy > 82) this.zunchiDiseaseSeverity = Math.max(0, this.zunchiDiseaseSeverity - dt * (this.energy > 94 ? 0.020 : 0.010));
this.emitZunchiDiseaseEffect(dt);
if (this.zunchiDiseaseSeverity <= 0.04) {
if (this.recoverZunchiDisease("体力が戻り、ずんち病が治まった")) this.world?.log?.(`${this.name}のずんち病が治まった。`, "accident");
}
}
}
canEatItemType(type) {
return this.isZunchiSlave ? type === "zunchi" : true;
}
hasLoveMochiEffect() {
return (this.loveMochiTimer || 0) > 0.04;
}
hasFightMochiEffect() {
return (this.fightMochiTimer || 0) > 0.04;
}
becomeZunchiSlave() {
if (this.isZunchiSlave) return false;
this.formerName = this.formerName || this.name;
this.isZunchiSlave = true;
this.name = "ずんちどれい";
this.birthRitualTimer = 0;
this.birthPartnerId = null;
this.birthRitualLeader = false;
this.birthRitualRole = 0;
this.target = null;
this.reproductionTimer = Math.max(this.reproductionTimer || 0, CONFIG.reproductionCooldown + rand(8, 18));
this.loveMochiTimer = 0;
this.stress = clamp(this.stress + 14, 0, 130);
this.hunger = Math.max(this.hunger, 40);
this.visibleSpriteId = "zunchi_slave";
this.spriteLockUntil = 0;
return true;
}
rawSpriteId() {
if (this.dead) return this.variantSprite("fear");
if (this.type === "cry") return "cry";
if (this.type === "stretch") return "stretch";
if (this.isZunchiSlave) return "zunchi_slave";
if (this.type === "cry" && (this.mood < 26 || this.stress > 78 || this.hunger > 96 || this.energy < 12)) return "cry";
if ((this.state === "eat" || this.eatTimer > 0.04) && this.target?.type === "sweet") return "zunda_eat";
if ((this.state === "seek_food" || this.foodReactTimer > 0.04) && this.target?.type === "sweet" && dist(this, this.target) < 80) return "zunda_eat";
if (this.hurtTimer > 0.08 || this.pokeFlashTimer > 0.08) return this.variantSprite("hurt");
if (this.birthRitualTimer > 0.04 || this.state === "birth_ritual") return "birth_ritual";
const low = this.lowHealthSprite();
if (low) return low;
if (low === "stretch") return low;
if (this.defeatedTimer > 0.04) return this.variantSprite("flee");
if (this.state === "intimidate" || this.intimidateTimer > 0.04) return this.variantSprite("intimidate");
if (this.state === "panic" || this.state === "cursor_enemy" || this.fearTimer > 0.08 || this.intimidatedTimer > 0.08) return this.variantSprite("fear");
if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep");
if (this.state === "fight" || this.fightTimer > 0.04) return "angry";
if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress");
if (low) return low;
if (this.state === "sleep" || this.energy < 18) return this.variantSprite("sleep");
if (this.surpriseTimer > 0.08) return "pokan";
if (this.state === "cursor_friend") return "smile";
if (this.state === "eat" || this.eatTimer > 0.04) return this.goodMode;
if ((this.state === "seek_food" || this.state === "idle" || this.state === "follow_parent") && (this.vy < -18 || (this.target && this.target.y < this.y - 36 && Math.abs(this.target.y - this.y) > Math.abs(this.target.x - this.x) * 0.55))) return "back";
if (this.state === "seek_food") return "drool";
if (this.stress > 62 || (this.stress > 50 && this.mood < 46)) return this.variantSprite("stress");
if (this.mood >= 66 && this.stress < 24 && this.hunger < 58 && this.loneliness < 62) return this.variantSprite("low_stress");
if (this.mood >= 63 && this.stress < 44 && this.hunger < 58) return this.goodMode;
if (this.mood < 38 || this.hunger > 72 || this.loneliness > 74) return "angry";
@ -460,6 +597,9 @@ class Tarinai {
this.blastSpinTimer = Math.max(0, (this.blastSpinTimer || 0) - dt);
this.postBirthPeaceTimer = Math.max(0, (this.postBirthPeaceTimer || 0) - dt);
this.hpBarTimer = Math.max(0, (this.hpBarTimer || 0) - dt);
this.updateZunchiDisease(dt);
this.loveMochiTimer = Math.max(0, (this.loveMochiTimer || 0) - dt);
this.fightMochiTimer = Math.max(0, (this.fightMochiTimer || 0) - dt);
if (this.intimidateTimer <= 0.04) this.intimidateTargetId = null;
if (this.birthRitualTimer > 0) {
const beforeBirthTimer = this.birthRitualTimer;
@ -471,6 +611,7 @@ class Tarinai {
}
}
this.pokeFlashTimer = Math.max(0, this.pokeFlashTimer - dt);
this.cursorPetting = Math.max(0, this.cursorPetting - dt * 1.2);
const wasFighting = this.fightTimer > 0.04;
this.fightTimer = Math.max(0, this.fightTimer - dt);
this.fightCooldown = Math.max(0, this.fightCooldown - dt);
@ -492,7 +633,6 @@ class Tarinai {
this.grassEatTimer = Math.max(0, this.grassEatTimer - dt);
this.hurtTimer = Math.max(0, this.hurtTimer - dt);
this.fallTimer = Math.max(0, this.fallTimer - dt);
this.zunchiStain = 0;
if (this.stress > 92 && this.state !== "sleep" && this.state !== "fight" && this.state !== "eat" && Math.random() < dt * 0.050) {
this.bubble("\u306f\u3063...\u306f\u3063...", 3.4, "rgba(58,48,63,0.80)");
}
@ -612,6 +752,14 @@ class Tarinai {
};
}
isSleepFurniture(target) {
return Boolean(target && target.type === "bed");
}
sleepFurnitureLabel(target) {
return "干草寝床";
}
sleepSpotFor(bed) {
if (!bed) return { x: this.x, y: this.y };
const angle = stableUnit(this.id, `bed-angle-${bed.id || bed.seed || "bed"}`) * Math.PI * 2;
@ -650,6 +798,7 @@ class Tarinai {
}
reactToFreshZunchi(dt) {
if (this.isZunchiSlave) return;
if (this.state === "panic" || this.fearTimer > 0.2) return;
const z = this.world.nearest(this, ["zunchi"], 90);
if (!z || z.stage !== "fresh") return;
@ -738,6 +887,41 @@ class Tarinai {
}
}
if (this.zunchiDisease && this.fightTimer <= 0.04 && this.birthRitualTimer <= 0.04 && this.defeatedTimer <= 0.04) {
const water = this.world.nearest(this, ["water"], 360);
if (water && !water.dead) {
this.state = "seek_water";
this.target = water;
this.thought = "ずんち病で水を探している";
return;
}
this.state = "zunchi_sick";
this.target = null;
this.thought = "ずんち病で当てもなく彷徨っている";
if (Math.random() < dt * 0.35) this.wanderAngle += rand(-2.4, 2.4);
return;
}
if (!this.isZunchiSlave) {
const specialMochi = this.world.nearest(this, ["love_mochi", "fight_mochi"], wasSleeping ? 520 : 390);
if (specialMochi && specialMochi.amount > 1.2 && this.hunger > 10 && !this.shouldAvoidTarget(specialMochi)) {
this.state = "seek_food";
this.target = specialMochi;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.34);
this.thought = specialMochi.type === "love_mochi" ? "媚薬餅を探している" : "喧嘩餅を探している";
return;
}
} else if (this.hunger > 20) {
const zOnly = this.world.nearest(this, ["zunchi"], 520);
if (zOnly && zOnly.amount > 1.2) {
this.state = "seek_food";
this.target = zOnly;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.30);
this.thought = "ずんちを探している";
return;
}
}
const zundaRange = wasSleeping ? 540 : 430 + 70 * this.personalityProfile().zunda;
const zunda = this.world.nearest(this, ["sweet"], zundaRange);
const zundaHungry = this.hunger > Math.max(7, 18 - 10 * (this.personalityProfile().zunda - 1));
@ -760,15 +944,15 @@ class Tarinai {
const sleepBias = this.personalityProfile().sleep;
const keepSleeping = wasSleeping && (personalSleep || this.energy < 66 + 8 * sleepBias);
if (keepSleeping || (personalSleep && this.energy < 90 + 8 * sleepBias) || this.energy < 28 + 8 * sleepBias) {
let bed = previousTarget && previousTarget.type === "bed" ? previousTarget : null;
if (!bed && friendNearby && friendNearby.target?.type === "bed" && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target;
let bed = this.isSleepFurniture(previousTarget) ? previousTarget : null;
if (!bed && friendNearby && this.isSleepFurniture(friendNearby.target) && (friendNearby.state === "sleep" || friendNearby.state === "seek_bed")) bed = friendNearby.target;
if (!bed && !wasSleeping) bed = this.world.bestBedFor(this, 360);
if (bed && !wasSleeping && this.energy > 18) {
const spot = this.sleepSpotFor(bed);
if (distXY(this.x, this.y, spot.x, spot.y) > this.radius + 10) {
this.state = "seek_bed";
this.target = bed;
this.thought = "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3063\u3066\u3044\u308b";
this.thought = `${this.sleepFurnitureLabel(bed)}へ向かっている`;
return;
}
}
@ -779,7 +963,7 @@ class Tarinai {
this.nextSleepBubbleAt = this.world.time + 4.5;
this.world.spawnBubble(this.x, this.y - this.radius * 1.18, "Zzz...", "rgba(65,70,92,0.72)");
}
this.thought = bed ? "\u30d9\u30c3\u30c9\u306e\u8fd1\u304f\u3067\u7720\u3063\u3066\u3044\u308b" : "\u7720\u3063\u3066\u3044\u308b";
this.thought = bed ? "ベッドの近くで眠っている" : "眠っている";
return;
}
@ -810,6 +994,24 @@ class Tarinai {
}
}
const playTrait = this.personalityProfile().play ?? 1;
const ballRange = this.personality === "playful" ? 540 : 240 + 60 * playTrait;
const ball = this.world.nearest(this, ["ball"], ballRange);
if (ball && this.energy > 26 && this.hunger < 98 && !this.shouldAvoidTarget(ball)) {
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const chasers = this.world.countBallChasers ? this.world.countBallChasers(ball, this) : 0;
const baseChance = this.personality === "playful" ? 0.74 : 0.08;
const movingBonus = clamp(ballSpeed / 120, 0, 0.42);
const socialBonus = this.loneliness > 48 ? 0.10 : 0;
if (chasers < 5 && (this.personality === "playful" || ballSpeed > 7 || Math.random() < baseChance * playTrait + movingBonus + socialBonus)) {
this.state = "play_ball";
this.target = ball;
this.goodMode = "smile";
this.thought = this.personality === "playful" ? "ボールに引き寄せられている" : "転がるものが気になっている";
return;
}
}
const parent = this.parentToFollow();
if (parent && !this.shouldAvoidTarget(parent)) {
const d = dist(this, parent);
@ -822,7 +1024,7 @@ class Tarinai {
}
}
if (friendNearby && this.hunger > 44 && friendNearby.target && ["sweet", "grass"].includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
if (friendNearby && this.hunger > 44 && friendNearby.target && (this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "grass"]).includes(friendNearby.target.type) && !friendNearby.target.dead && !this.shouldAvoidTarget(friendNearby.target)) {
this.state = "seek_food";
this.target = friendNearby.target;
this.foodReactTimer = Math.max(this.foodReactTimer, 0.24);
@ -831,7 +1033,7 @@ class Tarinai {
}
if (this.hunger > 62) {
const itemTypes = ["sweet", "grass"];
const itemTypes = this.isZunchiSlave ? ["zunchi"] : ["sweet", "love_mochi", "fight_mochi", "grass"];
const item = this.world.nearest(this, itemTypes, 420);
if (item) {
this.state = "seek_food";
@ -877,8 +1079,9 @@ class Tarinai {
}
interactWithItems(dt) {
const canBite = this.eatCooldown <= 0 && this.hunger > 18;
const canSweetBite = this.eatCooldown <= 0 && this.hunger > 7;
const canBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 18;
const canSweetBite = !this.isZunchiSlave && this.eatCooldown <= 0 && this.hunger > 7;
const canZunchiBite = this.eatCooldown <= 0 && this.hunger > (this.isZunchiSlave ? 16 : 104);
for (const it of this.world.nearbyItems(this.x, this.y, this.radius + 42)) {
const d = dist(this, it);
if (d > this.radius + it.r + 12) continue;
@ -917,6 +1120,42 @@ class Tarinai {
}
}
if (canSweetBite && (it.type === "love_mochi" || it.type === "fight_mochi") && it.amount > 1.2) {
const eating = Math.min(it.amount, 6.8);
this.state = "eat";
this.target = it;
this.vx *= Math.pow(0.25, dt * 60);
this.vy *= Math.pow(0.25, dt * 60);
it.amount -= eating;
this.hunger -= eating * 0.82;
this.recoverHealth(eating * 0.24);
this.affection += eating * 0.04;
this.stress = clamp(this.stress - eating * 0.25, 0, 130);
if (it.type === "love_mochi") {
this.loveMochiTimer = Math.max(this.loveMochiTimer || 0, 42 + rand(4, 12));
this.bubble("♡", 2.4, "rgba(190,82,132,0.78)");
this.lastMealColor = "#ff7bab";
} else {
this.fightMochiTimer = Math.max(this.fightMochiTimer || 0, 36 + rand(4, 10));
this.bubble("!", 2.0, "rgba(180,86,52,0.80)");
this.lastMealColor = "#e07c43";
}
this.eatTimer = Math.max(this.eatTimer, 0.30);
this.eatCooldown = rand(0.38, 0.68);
this.foodReactTimer = Math.max(this.foodReactTimer, 0.16);
if (Math.random() < dt * 2.2) {
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, it.type === "love_mochi" ? "#ff7bab" : "#e07c43");
}
this.makePoop(eating * 0.20);
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.42; }
if (this.world.time - this.lastLog > 9 && Math.random() < 0.02) {
this.world.log(`${this.name}${it.type === "love_mochi" ? "媚薬餅" : "喧嘩餅"}をしばらく味わった。`, "food");
this.lastLog = this.world.time;
}
break;
}
if (canBite && it.type === "grass") {
const bite = Math.min(it.amount, rand(4.2, 7.2));
this.state = "eat";
@ -945,21 +1184,23 @@ class Tarinai {
if (it.type === "zunchi") {
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1);
this.stress = clamp(this.stress + touch * dt * 3.4, 0, 130);
this.zunchiStain = 0;
if (canBite && this.hunger > 104) {
const bite = Math.min(it.amount, rand(3.4, 5.8));
if (!this.isZunchiSlave) this.stress = clamp(this.stress + touch * dt * 3.4, 0, 130);
this.zunchiStain = clamp((this.zunchiStain || 0) + touch * dt * (this.isZunchiSlave ? 5.2 : 9.5), 0, 100);
if (canZunchiBite) {
const bite = Math.min(it.amount, this.isZunchiSlave ? rand(4.8, 7.4) : rand(3.4, 5.8));
this.state = "eat";
this.target = it;
this.vx *= Math.pow(0.18, dt * 60);
this.vy *= Math.pow(0.18, dt * 60);
it.amount -= bite;
this.hunger -= bite * 0.18;
this.stress = clamp(this.stress + bite * 0.52, 0, 130);
this.zunchiStain = 0;
this.hunger -= bite * (this.isZunchiSlave ? 0.66 : 0.18);
this.recoverHealth(bite * (this.isZunchiSlave ? 0.10 : 0.02));
this.stress = clamp(this.stress + bite * (this.isZunchiSlave ? 0.06 : 0.52), 0, 130);
this.zunchiStain = clamp((this.zunchiStain || 0) + bite * (this.isZunchiSlave ? 0.75 : 1.75), 0, 100);
this.lastMealColor = "#6b8d3f";
this.eatTimer = Math.max(this.eatTimer, 0.24);
this.eatCooldown = rand(0.9, 1.4);
this.bubble("...", 2.2, "rgba(74,91,50,0.78)");
this.bubble(this.isZunchiSlave ? "もぐ" : "...", 2.2, "rgba(74,91,50,0.78)");
const mouth = this.mouthPosition(it);
this.world.spawnEatEffect(mouth.x, mouth.y, "#6b8d3f");
if (this.world.time > this.nextEatSound) { audio.eat(); this.nextEatSound = this.world.time + 0.7; }
@ -971,6 +1212,13 @@ class Tarinai {
this.stress -= dt * 2.4;
this.energy += dt * 0.5;
this.hunger -= dt * 0.2;
this.zunchiStain = clamp((this.zunchiStain || 0) - dt * 10.5, 0, 100);
if (this.zunchiDisease) {
this.zunchiDiseaseSeverity = Math.max(0, (this.zunchiDiseaseSeverity || 1) - dt * (this.energy > 70 ? 0.050 : 0.028));
if (this.zunchiDiseaseSeverity <= 0.04 && this.recoverZunchiDisease("水でずんち病が治まった")) {
this.world.log(`${this.name}は水でずんち病が治まった。`, "accident");
}
}
it.amount -= dt * 2.4;
if (this.type === "teary" || this.type === "cry") this.affection += dt * 0.18;
}
@ -981,15 +1229,17 @@ class Tarinai {
if (this.need === "\u3042\u305f\u305f\u304b\u3055") this.affection += dt * 0.25;
}
if (it.type === "bed") {
if (this.isSleepFurniture(it)) {
if (this.energy < 74 + 6 * this.personalityProfile().sleep) {
const occ = this.world.bedOccupancy(it);
const comfort = this.world.bedComfort(it);
this.state = "sleep";
this.target = it;
this.sleeping = true;
if (this.world.time > this.nextSleepBubbleAt) {
this.nextSleepBubbleAt = this.world.time + 4.5;
this.world.spawnBubble(this.x, this.y - this.radius * 1.18, occ > 5 ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)");
const crowded = occ > 5;
this.world.spawnBubble(this.x, this.y - this.radius * 1.18, crowded ? "mm..." : "Zzz...", "rgba(65,70,92,0.72)");
}
this.energy += dt * (1.9 + comfort * 1.4);
this.stress += dt * (occ > 5 ? 0.55 : -1.3 * comfort);
@ -997,6 +1247,16 @@ class Tarinai {
}
}
if (it.type === "ball") {
const touch = clamp(1 - d / (this.radius + it.r + 12), 0, 1);
if (this.state === "play_ball" || this.target === it || this.personality === "playful") {
this.goodMode = "smile";
this.stress = clamp(this.stress - dt * (0.9 + touch * 1.8), 0, 130);
this.loneliness = clamp(this.loneliness - dt * (0.4 + touch), 0, 110);
if (this.state === "play_ball") this.foodReactTimer = Math.max(this.foodReactTimer, 0.10);
}
}
if (it.type === "trace") {
this.loneliness -= dt * 0.25;
this.stress += dt * 0.06;
@ -1017,12 +1277,24 @@ class Tarinai {
if (d < 0.01 || d > 74) continue;
closeCount += 1;
if (this.zunchiDisease && !o.zunchiDisease && (o.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) {
if (o.infectZunchiDisease(this)) this.world.log(`${this.name}のずんち病が${o.name}にうつった。`, "accident");
}
if (o.zunchiDisease && !this.zunchiDisease && (this.zunchiDiseaseCooldown || 0) <= 0 && d < 54 && Math.random() < dt * 0.065 * clamp(1 - d / 58, 0.12, 1)) {
if (this.infectZunchiDisease(o)) this.world.log(`${o.name}のずんち病が${this.name}にうつった。`, "accident");
}
const pairBond = this.world.areCoParents?.(this, o);
const overlap = this.radius + o.radius - d;
if (overlap > -2) {
const push = (overlap + 6) * 0.17;
const push = (overlap + 6) * (pairBond ? 0.08 : 0.17);
this.vx -= (dx / d) * push;
this.vy -= (dy / d) * push;
if (Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
if (!pairBond && Math.random() < dt * 1.2) this.surpriseTimer = Math.max(this.surpriseTimer, 0.12);
}
if (pairBond && d > 26 && d < 120) {
this.vx += (dx / d) * dt * 4.8;
this.vy += (dy / d) * dt * 4.8;
}
if (d < 60) {
@ -1077,19 +1349,24 @@ class Tarinai {
const relA = this.relationTo(o.id);
const relB = o.relationTo(this.id);
const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || this.personality === "irritable" || o.personality === "irritable");
const fearBrake = clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1);
const friendBrake = clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1);
const battleDrug = this.hasFightMochiEffect() || o.hasFightMochiEffect();
const loveDrug = this.hasLoveMochiEffect() || o.hasLoveMochiEffect();
const fightRisk = (this.mood < 55 || o.mood < 55 || this.stress > 46 || o.stress > 46 || this.type === "angry" || o.type === "angry" || closeCount > 3 || this.personality === "irritable" || o.personality === "irritable" || battleDrug || o.isZunchiSlave || this.isZunchiSlave);
const fearBrake = battleDrug ? clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 220, 0.74, 1.15) : clamp(1 - ((relA.fear || 0) + (relB.fear || 0)) / 120, 0.34, 1);
const friendBrake = battleDrug ? clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 180, 0.72, 1.12) : clamp(1 - Math.max(relA.affinity || 0, relB.affinity || 0) / 90, 0.48, 1);
const personalityRisk = this.personalityProfile().fight * o.personalityProfile().fight;
const conflictBias = (battleDrug ? 2.9 : 1) * (o.isZunchiSlave ? 2.2 : 1) * (this.isZunchiSlave ? 1.15 : 1);
const birthPeace = (this.postBirthPeaceTimer || 0) > 0 || (o.postBirthPeaceTimer || 0) > 0;
if (d < 56 && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake) {
const familyFightBlocked = this.world.areParentChild ? this.world.areParentChild(this, o) : false;
const pairFightBlocked = this.world.areCoParents ? this.world.areCoParents(this, o) : false;
if (d < 56 && !familyFightBlocked && !pairFightBlocked && !birthPeace && fightRisk && this.fightCooldown <= 0 && o.fightCooldown <= 0 && Math.random() < dt * 0.23 * personalityRisk * fearBrake * friendBrake * conflictBias) {
this.world.startConflict(this, o);
}
if (d < 36 && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) {
const enough = this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44;
const lonelyBond = this.loneliness > 24 || o.loneliness > 24;
if (enough && lonelyBond && Math.random() < dt * 0.25) {
if (!this.isZunchiSlave && !o.isZunchiSlave && d < (loveDrug ? 44 : 36) && this.reproductionTimer <= 0 && o.reproductionTimer <= 0 && this.fightTimer <= 0 && o.fightTimer <= 0) {
const enough = loveDrug ? (this.hunger < 90 && o.hunger < 92 && this.energy > 18 && o.energy > 18) : (this.hunger < 55 && o.hunger < 60 && this.energy > 44 && o.energy > 44);
const lonelyBond = loveDrug ? (this.loneliness > 4 || o.loneliness > 4 || this.affection > 0 || o.affection > 0) : (this.loneliness > 24 || o.loneliness > 24);
if (enough && lonelyBond && Math.random() < dt * (loveDrug ? 0.72 : 0.25)) {
this.world.startBirthRitual(this, o);
}
}
@ -1108,23 +1385,27 @@ class Tarinai {
move(dt) {
let ax = 0, ay = 0;
const baseSpeed = 31 * this.trait.speed * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1);
const baseSpeed = 46.5 * 1.5 * this.trait.speed * (this.energy < 20 ? 0.42 : 1) * (this.fallTimer > 0 ? 0.35 : 1);
if (this.state === "sleep") {
const bedComfort = this.target?.type === "bed" ? this.world.bedComfort(this.target) : 0.72;
const bedCrowd = this.target?.type === "bed" ? this.world.bedOccupancy(this.target) : 0;
const bedComfort = this.isSleepFurniture(this.target) ? this.world.bedComfort(this.target) : 0.72;
const bedCrowd = this.isSleepFurniture(this.target) ? this.world.bedOccupancy(this.target) : 0;
const crowdedSleep = bedCrowd > 5;
this.energy = clamp(this.energy + dt * (this.target ? 1.8 + bedComfort * 1.35 : 2.1), 0, 100);
this.stress = clamp(this.stress + dt * (bedCrowd > 5 ? 0.18 * (bedCrowd - 5) : -0.12 * bedComfort), 0, 130);
this.stress = clamp(this.stress + dt * (crowdedSleep ? 0.18 * Math.max(1, bedCrowd - 5) : -0.12 * bedComfort), 0, 130);
this.hunger = clamp(this.hunger + dt * 0.018, 0, 115);
if (this.target && !this.target.dead && this.target.type === "bed") {
if (this.target && !this.target.dead && this.isSleepFurniture(this.target)) {
const spot = this.sleepSpotFor(this.target);
const dx = spot.x - this.x;
const dy = spot.y - this.y;
const d = Math.hypot(dx, dy);
if (d > 1.6) {
if (d > 2.2) {
const step = Math.min(d, dt * (12 + bedComfort * 14));
this.x += dx / d * step;
this.y += dy / d * step;
} else {
this.x = spot.x;
this.y = spot.y;
}
}
this.vx *= Math.pow(0.55, dt * 60);
@ -1137,15 +1418,21 @@ class Tarinai {
if (partner) {
const midX = (this.x + partner.x) / 2;
const midY = (this.y + partner.y) / 2;
const offset = (this.birthRitualRole || 1) * this.radius * 0.44;
const dx = midX - this.x;
const dy = (midY + offset) - this.y;
this.vx += dx * dt * 2.8;
this.vy += dy * dt * 2.8;
const pdx = partner.x - this.x;
const pdy = partner.y - this.y;
const plen = Math.max(0.001, Math.hypot(pdx, pdy));
const nx = -pdy / plen;
const ny = pdx / plen;
const desiredGap = this.radius * 0.36;
const side = this.birthRitualRole || 1;
const tx = midX + nx * desiredGap * side;
const ty = midY + ny * desiredGap * side;
this.vx += (tx - this.x) * dt * 3.8;
this.vy += (ty - this.y) * dt * 3.8;
}
this.vx *= Math.pow(0.56, dt * 60);
this.vy *= Math.pow(0.56, dt * 60);
this.x += Math.sin(this.world.time * 26 + this.age) * 0.32;
this.vx *= Math.pow(0.48, dt * 60);
this.vy *= Math.pow(0.48, dt * 60);
this.x += Math.sin(this.world.time * 26 + this.age) * 0.20;
return;
}
@ -1171,9 +1458,20 @@ class Tarinai {
}
if (this.target && !this.target.dead) {
const dx = this.target.x - this.x;
const dy = this.target.y - this.y;
const moveTarget = this.state === "seek_bed" && this.isSleepFurniture(this.target) ? this.sleepSpotFor(this.target) : this.target;
const dx = moveTarget.x - this.x;
const dy = moveTarget.y - this.y;
const d = Math.hypot(dx, dy) || 1;
if (this.state === "seek_bed" && this.isSleepFurniture(this.target)) {
const bedDx = this.target.x - this.x;
const bedDy = this.target.y - this.y;
const bedDist = Math.hypot(bedDx, bedDy);
if (d <= Math.max(this.radius + 8, 26) || bedDist <= Math.max(this.target.r * 0.58, 20)) {
this.state = "sleep";
this.sleeping = true;
return;
}
}
let sign = (this.state === "panic" || this.state === "cursor_enemy") ? -1 : 1;
if (this.target?.detour) sign = 1;
let strength = 1;
@ -1186,6 +1484,9 @@ class Tarinai {
} else if (this.state === "cursor_enemy") {
strength = d < 150 ? 1.35 : 0.35;
sign = -1;
} else if (this.state === "play_ball") {
strength = d < this.radius + 18 ? 1.75 : 1.28;
sign = 1;
} else if (this.state === "fight") {
const headbutt = Math.sin(this.world.time * 18 + this.age * 0.37);
if (d < 18) {
@ -1202,6 +1503,10 @@ class Tarinai {
this.wanderAngle += rand(-3.8, 3.8) * dt;
ax += Math.cos(this.wanderAngle) * baseSpeed * 1.5;
ay += Math.sin(this.wanderAngle) * baseSpeed * 1.5;
} else if (this.state === "zunchi_sick") {
this.wanderAngle += rand(-2.4, 2.4) * dt + Math.sin(this.world.time * 1.7 + this.age) * dt * 0.8;
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.72;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.72;
} else {
ax += Math.cos(this.wanderAngle) * baseSpeed * 0.34;
ay += Math.sin(this.wanderAngle) * baseSpeed * 0.34;
@ -1212,7 +1517,7 @@ class Tarinai {
this.vx *= Math.pow(0.84, dt * 8);
this.vy *= Math.pow(0.84, dt * 8);
const maxV = this.state === "panic" ? 116 : 76;
const maxV = this.state === "panic" ? 116 : (this.state === "zunchi_sick" ? 54 : 76);
const v = Math.hypot(this.vx, this.vy);
if (v > maxV) {
this.vx = this.vx / v * maxV;
@ -1222,11 +1527,19 @@ class Tarinai {
this.x += this.vx * dt;
this.y += this.vy * dt;
const p = CONFIG.worldPadding;
if (this.entryTimer > 0) {
this.entryTimer = Math.max(0, this.entryTimer - dt);
if (this.x >= CONFIG.worldPadding && this.y >= CONFIG.worldPadding && this.x <= this.world.w - CONFIG.worldPadding && this.y <= this.world.h - CONFIG.worldPadding) {
this.entryTimer = 0;
}
}
const p = this.entryTimer > 0 ? -this.radius * 2.4 : CONFIG.worldPadding;
const maxX = this.entryTimer > 0 ? this.world.w + this.radius * 2.4 : this.world.w - CONFIG.worldPadding;
const maxY = this.entryTimer > 0 ? this.world.h + this.radius * 2.4 : this.world.h - CONFIG.worldPadding;
if (this.x < p) { this.x = p; this.vx = Math.abs(this.vx) * 0.5; this.wanderAngle = rand(-0.8, 0.8); this.surpriseTimer = 0.15; }
if (this.y < p) { this.y = p; this.vy = Math.abs(this.vy) * 0.5; this.wanderAngle = rand(0.3, 1.8); this.surpriseTimer = 0.15; }
if (this.x > this.world.w - p) { this.x = this.world.w - p; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; }
if (this.y > this.world.h - p) { this.y = this.world.h - p; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; }
if (this.x > maxX) { this.x = maxX; this.vx = -Math.abs(this.vx) * 0.5; this.wanderAngle = rand(2.5, 3.8); this.surpriseTimer = 0.15; }
if (this.y > maxY) { this.y = maxY; this.vy = -Math.abs(this.vy) * 0.5; this.wanderAngle = rand(-2.2, -0.7); this.surpriseTimer = 0.15; }
}
checkLife() {
@ -1277,8 +1590,13 @@ class Tarinai {
const blastSpin = this.blastSpinTimer > 0.04 ? this.fallDir * ((this.blastSpinMax || 1) - this.blastSpinTimer) * Math.PI * 70.0 : 0;
const fallSpin = this.blastSpinTimer > 0.04 ? blastSpin : (fallPose > 0 ? this.fallDir * (1 - fallPose) * Math.PI * 2 : 0);
const motionTilt = clamp(this.vx / 240, -0.22, 0.22) + (this.state === "fight" ? headbuttPulse * 0.05 : 0) + eatingShake * 0.105 + this.fallDir * fallPose * 0.18 + fallSpin;
const insideCardboard = false;
let drawW = w * this.scale * 0.30;
let drawH = h * this.scale * 0.30;
if (insideCardboard) {
drawW *= 0.72;
drawH *= 0.72;
}
if (sprite === "stretch") {
drawW *= 1.16;
drawH *= 1.24;
@ -1324,6 +1642,7 @@ class Tarinai {
ctx.save();
ctx.translate(this.x + intimidateShake, this.y + bob + fearShakeY);
ctx.rotate(angle);
if (insideCardboard) ctx.globalAlpha *= 0.58;
if (this.world.selected === this) {
ctx.save();
@ -1375,13 +1694,16 @@ class Tarinai {
if ((this.world.performanceLevel ? this.world.performanceLevel() : 0) < 2 && (this.goodMode === "smile" || this.state === "cursor_friend") && this.mood > 58 && this.stress < 44 && this.state !== "sleep") {
ctx.save();
ctx.globalAlpha = this.state === "cursor_friend" ? 0.82 : 0.34;
const cursorLove = this.state === "cursor_friend" ? Math.max(0, this.cursorPetting || 0) : 0;
ctx.globalAlpha = this.state === "cursor_friend" ? clamp(0.82 + cursorLove * 0.16, 0.82, 1) : 0.34;
ctx.fillStyle = this.state === "cursor_friend" ? "#f05b87" : (lightState.nightStrength > 0.20 ? "#eef5ff" : (lightState.goldenStrength > 0.35 ? "#ffe398" : "#d6f5a9"));
for (let i = 0; i < 2; i++) {
const px = Math.cos(t * 2.2 + this.age + i * 2.4) * drawW * 0.34;
const py = -drawH * 0.24 + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * 0.11;
const count = this.state === "cursor_friend" ? (cursorLove > 0.18 ? 5 : 3) : 2;
for (let i = 0; i < count; i++) {
const orbit = 0.24 + (i % 3) * 0.08 + cursorLove * 0.05;
const px = Math.cos(t * 2.2 + this.age + i * 1.42) * drawW * orbit;
const py = -drawH * (0.22 + (i % 2) * 0.06) + Math.sin(t * 2.9 + this.age * 0.2 + i) * drawH * (0.08 + cursorLove * 0.02);
if (this.state === "cursor_friend") {
const s = Math.max(5, drawW * 0.075);
const s = Math.max(5, drawW * (0.070 + (i % 3) * 0.012 + cursorLove * 0.01));
drawHeartShape(ctx, px, py, s, { fill: "rgba(240,91,135,0.90)", stroke: "rgba(255,252,246,0.92)", alpha: 1, rotation: Math.sin(t * 3 + i) * 0.18 });
} else {
ctx.beginPath();
@ -1402,7 +1724,24 @@ class Tarinai {
const cacheH = Math.max(1, Math.round((dh * renderScale) / 8) * 8);
const spriteCanvas = typeof getCachedSpriteCanvas === "function" ? getCachedSpriteCanvas(sprite, img, cacheW, cacheH) : img;
ctx.drawImage(spriteCanvas, -dw / 2, -dh / 2, dw, dh);
this.zunchiStain = 0;
if ((this.zunchiStain || 0) > 3 && typeof getCachedStainOverlayCanvas === "function") {
const stainCanvas = getCachedStainOverlayCanvas(sprite, img, cacheW, cacheH, clamp((this.zunchiStain || 0) / 100, 0, 1), this.zunchiStainSeed || 0.5);
if (stainCanvas) {
ctx.save();
ctx.globalAlpha = clamp((this.zunchiStain || 0) / 100, 0.10, 0.88);
ctx.drawImage(stainCanvas, -dw / 2, -dh / 2, dw, dh);
ctx.restore();
}
}
if (this.zunchiDisease) {
ctx.save();
ctx.globalAlpha = 0.12 + clamp(this.zunchiDiseaseSeverity || 0, 0, 1) * 0.10;
ctx.fillStyle = "rgba(9, 74, 23, 0.75)";
ctx.beginPath();
ctx.ellipse(0, drawH * 0.05, drawW * 0.56, drawH * 0.38, Math.sin(t + this.age) * 0.12, 0, Math.PI * 2);
ctx.fill();
ctx.restore();
}
drawDawnRimLight(ctx, dw, dh, lightState);
if (faceRight) ctx.scale(-1, 1);

286
js/ui.js
View file

@ -5,10 +5,12 @@ const ctx = canvas.getContext("2d");
const ui = {
pauseBtn: document.getElementById("pauseBtn"),
speedBtn: document.getElementById("speedBtn"),
ecologyBtn: document.getElementById("ecologyBtn"),
soundBtn: document.getElementById("soundBtn"),
saveBtn: document.getElementById("saveBtn"),
loadBtn: document.getElementById("loadBtn"),
creditsBtn: document.getElementById("creditsBtn"),
resetBtn: document.getElementById("resetBtn"),
creditsDialog: document.getElementById("creditsDialog"),
creditsCloseBtn: document.getElementById("creditsCloseBtn"),
toolPalette: document.getElementById("toolPalette"),
toolCard: document.getElementById("toolCard"),
colonyChartTabs: document.getElementById("colonyChartTabs"),
@ -20,8 +22,14 @@ const ui = {
colonyChart: document.getElementById("colonyChart"),
colonyChartLegend: document.getElementById("colonyChartLegend"),
archiveContent: document.getElementById("archiveContent"),
archiveResetBtn: document.getElementById("archiveResetBtn"),
archiveResetDialog: document.getElementById("archiveResetDialog"),
archiveResetCancelBtn: document.getElementById("archiveResetCancelBtn"),
archiveResetConfirmBtn: document.getElementById("archiveResetConfirmBtn"),
fieldDialog: document.getElementById("fieldDialog"),
fieldCancelBtn: document.getElementById("fieldCancelBtn"),
ecologyDialog: document.getElementById("ecologyDialog"),
ecologyCloseBtn: document.getElementById("ecologyCloseBtn"),
};
const uiCache = {
@ -65,7 +73,7 @@ function renderLog(logs) {
time.textContent = formatLogTime(entry.time || 0);
div.appendChild(tag);
div.appendChild(time);
div.append(document.createTextNode(entry.text || ""));
div.append(document.createTextNode(formatLogDisplayText(entry.text || "")));
frag.appendChild(div);
}
ui.log.appendChild(frag);
@ -120,14 +128,21 @@ function renderSelected() {
fmt(t.age), fmt(t.lifeSpan), fmt(t.hunger), fmt(t.loneliness),
fmt(t.energy), fmt(t.stress), fmt(t.mood),
t.parentNames?.join("+") || "", t.children?.length || 0, fmt(t.lack),
rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord
rel.friend?.id || "", rel.friend?.score || 0, rel.friend?.id ? tarinaiSpritePathForId(rel.friend.id) : "", rel.fear?.id || "", rel.fear?.score || 0, rel.fear?.id ? tarinaiSpritePathForId(rel.fear.id) : "", fightRecord,
t.isZunchiSlave ? 1 : 0, fmt(t.totalFightLosses || 0), fmt(t.loveMochiTimer || 0), fmt(t.fightMochiTimer || 0),
t.zunchiDisease ? 1 : 0, fmt(t.zunchiDiseaseSeverity || 0), fmt(t.zunchiStain || 0)
].join("|");
if (uiCache.selectedSnapshot === snapshot) return;
uiCache.selectedSnapshot = snapshot;
ui.selectedInfo.className = "selected-info";
const effectBadges = [];
if (t.isZunchiSlave) effectBadges.push(`<span class="badge">ずんちどれい</span>`);
if (t.zunchiDisease) effectBadges.push(`<span class="badge">ずんち病 ${fmt((t.zunchiDiseaseSeverity || 0) * 100)}</span>`);
if ((t.loveMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">媚薬餅 ${fmt(t.loveMochiTimer)}</span>`);
if ((t.fightMochiTimer || 0) > 0.1) effectBadges.push(`<span class="badge">喧嘩餅 ${fmt(t.fightMochiTimer)}</span>`);
ui.selectedInfo.innerHTML = `
<div><span class="badge">${escapeHtml(t.label())}</span><span class="badge">${escapeHtml(t.name)}</span>${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `<span class="badge">\u4e16\u4ee3 ${t.generation}</span>` : ""}</div>
<div><span class="badge">${escapeHtml(t.label())}</span><span class="badge">${escapeHtml(t.name)}</span>${(t.displayGeneration ? t.displayGeneration() : ((t.hasPaired || t.parents?.length || t.children?.length) ? t.generation : null)) ? `<span class="badge">\u4e16\u4ee3 ${t.generation}</span>` : ""}${effectBadges.join("")}</div>
<div class="info-row"><span>\u6027\u683c</span><strong>${escapeHtml(t.personalityLabel ? t.personalityLabel() : t.personality || "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u72b6\u614b</span><strong>${escapeHtml(stateLabel(t.state))}</strong></div>
<div class="info-row"><span>\u7406\u7531</span><strong>${escapeHtml(reasonLabel(t))}</strong></div>
@ -137,10 +152,12 @@ function renderSelected() {
<div class="info-row"><span>\u5bc2\u3057\u3055</span><strong>${fmt(t.loneliness)}</strong></div>
<div class="info-row"><span>\u4f53\u529b</span><strong>${fmt(t.energy)}</strong></div>
<div class="info-row"><span>\u30b9\u30c8\u30ec\u30b9</span><strong>${fmt(t.stress)}</strong></div>
<div class="info-row"><span>ずんち汚れ</span><strong>${fmt(t.zunchiStain || 0)}</strong></div>
<div class="info-row"><span>\u6c17\u5206</span><strong>${fmt(t.mood)}</strong></div>
<div class="info-row relation-row"><span>\u4ef2\u826f\u3057</span><strong>${rel.friend ? `${relationIconHtml(rel.friend.id)}${escapeHtml(`${rel.friend.name} +${fmt(rel.friend.score)}`)}` : "\u306a\u3057"}</strong></div>
<div class="info-row relation-row"><span>\u907f\u3051\u308b\u76f8\u624b</span><strong>${rel.fear ? `${relationIconHtml(rel.fear.id)}${escapeHtml(`${rel.fear.name} ${fmt(rel.fear.score)}`)}` : "\u306a\u3057"}</strong></div>
<div class="info-row"><span>\u55a7\u5629\u8a18\u61b6</span><strong>${escapeHtml(fightRecord)}</strong></div>
<div class="info-row"><span>\u7d2f\u8a08\u6557\u5317</span><strong>${t.totalFightLosses || 0}</strong></div>
<div class="info-row"><span>\u89aa</span><strong>${escapeHtml(t.parentNames?.length ? t.parentNames.join(" + ") : "\u4e0d\u660e")}</strong></div>
<div class="info-row"><span>\u5b50</span><strong>${t.children?.length || 0}</strong></div>
<div class="info-row"><span>\u305f\u308a\u306a\u3044</span><strong>${fmt(t.lack)}</strong></div>
@ -151,7 +168,8 @@ function stateLabel(s) {
return {
idle: "\u5f85\u6a5f",
seek_food: "\u98df\u3079\u7269\u3078\u5411\u304b\u3046",
seek_bed: "\u5e72\u8349\u5bdd\u5e8a\u3078\u5411\u304b\u3046",
seek_bed: "寝床へ向かう",
play_ball: "ボール遊び",
seek_friend: "\u4ef2\u9593\u3092\u63a2\u3059",
follow_parent: "\u89aa\u306b\u3064\u3044\u3066\u3044\u304f",
cursor_friend: "\u30ab\u30fc\u30bd\u30eb\u306b\u3064\u3044\u3066\u3044\u304f",
@ -180,7 +198,7 @@ function reasonLabel(t) {
function targetLabel(target) {
if (!target) return "\u306a\u3057";
if (target.name) return target.name;
if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
if (target.type) return { sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u5a9a\u85ac\u9905", fight_mochi: "\u55a7\u5629\u9905", food: "\u98df\u4e8b", grass: "\u8349", water: "\u6c34", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", stone: "\u77f3", zunchi: "\u305a\u3093\u3061", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[target.type] || target.type;
if (Number.isFinite(target.x) && Number.isFinite(target.y)) return "\u30ab\u30fc\u30bd\u30eb";
return "\u4e0d\u660e";
}
@ -218,20 +236,41 @@ function escapeHtml(value) {
"'": "&#39;"
}[c]));
}
function escapeRegExp(value) {
return String(value ?? "").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
function formatLogDisplayText(text) {
let out = String(text ?? "");
const names = new Set();
for (const t of world?.tarinai || []) if (t?.name) names.add(t.name);
for (const n of Object.values(world?.family || {})) if (n?.name) names.add(n.name);
const sorted = [...names].filter(Boolean).sort((a, b) => b.length - a.length);
for (const name of sorted) {
const re = new RegExp(`(^|[^「])(${escapeRegExp(name)})(?=[とはがをにのもへからで、。]|$)`, "g");
out = out.replace(re, (m, prefix, found) => `${prefix}${found}`);
}
return out;
}
function resizeCanvas() {
const rect = canvas.getBoundingClientRect();
const perf = world?.performanceLevel ? world.performanceLevel() : 0;
const maxDpr = perf >= 2 ? 1 : (perf === 1 ? 1.5 : 2);
const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, maxDpr));
const nextW = Math.floor(rect.width * dpr);
const nextH = Math.floor(rect.height * dpr);
const nextW = Math.max(1, Math.floor(rect.width * dpr));
const nextH = Math.max(1, Math.floor(rect.height * dpr));
if (canvas.width === nextW && canvas.height === nextH && uiCache.canvasDpr === dpr) return;
uiCache.canvasDpr = dpr;
canvas.width = nextW;
canvas.height = nextH;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
world.setViewportSize(rect.width, rect.height);
ctx.fillStyle = "#cfe3d3";
ctx.fillRect(0, 0, Math.max(1, rect.width), Math.max(1, rect.height));
world.setViewportSize(Math.max(1, rect.width), Math.max(1, rect.height));
uiCache.lastChartDraw = "";
if (typeof render === "function") render();
}
function applyFieldLayout(fieldType = world.fieldType || "garden") {
@ -255,6 +294,22 @@ function closeFieldDialog() {
ui.fieldDialog?.classList.add("hidden");
}
function openEcologyDialog() {
ui.ecologyDialog?.classList.remove("hidden");
}
function closeEcologyDialog() {
ui.ecologyDialog?.classList.add("hidden");
}
function openArchiveResetDialog() {
ui.archiveResetDialog?.classList.remove("hidden");
}
function closeArchiveResetDialog() {
ui.archiveResetDialog?.classList.add("hidden");
}
function resetWithField(fieldType) {
const field = FIELD_TYPES?.[fieldType] || FIELD_TYPES.garden;
closeFieldDialog();
@ -282,7 +337,7 @@ function resizeFieldFromWheel(e) {
const sx = e.clientX - rect.left;
const sy = e.clientY - rect.top;
const focus = world.screenToWorld ? world.screenToWorld(sx, sy) : { x: sx, y: sy };
const factor = e.deltaY > 0 ? 1.08 : 1 / 1.08;
const factor = e.deltaY > 0 ? 1 / 1.08 : 1.08;
if (!world.setFieldZoom((world.fieldZoom || 1) * factor)) return;
const off = world.fieldScreenOffset ? world.fieldScreenOffset() : { x: 0, y: 0 };
const scale = world.viewScale ? world.viewScale() : 1;
@ -298,7 +353,7 @@ function selectTool(tool) {
btn.classList.toggle("selected", btn.dataset.tool === tool);
}
syncToolSizeBadges();
canvas.style.cursor = tool === "observe" ? "default" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair");
canvas.style.cursor = tool === "observe" ? "default" : (tool === "pinch" ? "grab" : (tool === "poke" || tool === "delete" ? "pointer" : "crosshair"));
}
function showToast(text) {
@ -308,33 +363,12 @@ function showToast(text) {
showToast._timer = setTimeout(() => ui.toast.classList.add("hidden"), 1400);
}
function saveGame() {
localStorage.setItem(CONFIG.saveKey, JSON.stringify(world.serialize()));
showToast("\u4fdd\u5b58\u3057\u307e\u3057\u305f\u3002");
function openCreditsDialog() {
ui.creditsDialog?.classList.remove("hidden");
}
function loadGame() {
const raw = localStorage.getItem(CONFIG.saveKey);
if (!raw) { showToast("\u30bb\u30fc\u30d6\u30c7\u30fc\u30bf\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return; }
try {
const t0 = performance.now();
world.load(JSON.parse(raw));
applyFieldLayout(world.fieldType || "garden");
uiCache.archiveVersion = "";
uiCache.deferArchiveUntil = performance.now() + 80;
if (ui.archiveContent) ui.archiveContent.innerHTML = `<div class="selected-info empty">\u5bb6\u7cfb\u56f3\u3092\u8aad\u307f\u8fbc\u3093\u3067\u3044\u307e\u3059\u3002</div>`;
renderSelected();
renderStats();
requestAnimationFrame(() => {
uiCache.deferArchiveUntil = 0;
renderArchive();
});
if (typeof console !== "undefined") console.debug?.(`loaded save in ${Math.round(performance.now() - t0)}ms`);
showToast("\u8aad\u307f\u8fbc\u307f\u307e\u3057\u305f\u3002");
} catch (e) {
console.error(e);
showToast("\u8aad\u307f\u8fbc\u307f\u306b\u5931\u6557\u3057\u307e\u3057\u305f\u3002");
}
function closeCreditsDialog() {
ui.creditsDialog?.classList.add("hidden");
}
function syncTopButtons() {
@ -343,7 +377,7 @@ function syncTopButtons() {
if (ui.soundBtn) ui.soundBtn.textContent = audio.enabled ? "\u97f3 ON" : "\u97f3 OFF";
}
const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "grass", "stone", "firecracker", "fence_v", "fence_h"]);
const SCALABLE_TOOLS = new Set(["zunchi", "sweet", "love_mochi", "fight_mochi", "grass", "stone", "firecracker", "fence_v", "fence_h"]);
const SIZE_ORDER = ["small", "medium", "large"];
const SIZE_LABELS = { small: "\u5c0f", medium: "\u4e2d", large: "\u5927" };
@ -400,6 +434,20 @@ function bindUI() {
});
syncToolSizeBadges();
ui.archiveContent?.addEventListener("scroll", scheduleArchiveWindowRender, { passive: true });
const bindArchiveHorizontal = (root) => {
if (!root) return;
root.addEventListener("wheel", (e) => {
const scroller = e.target.closest(".lineage-family-tree, .lineage-main-content");
if (!scroller) return;
const canX = scroller.scrollWidth > scroller.clientWidth + 4;
if (!canX) return;
const dominant = Math.abs(e.deltaX) > Math.abs(e.deltaY) ? e.deltaX : (e.shiftKey ? e.deltaY : 0);
if (!dominant) return;
e.preventDefault();
scroller.scrollLeft += dominant;
}, { passive: false });
};
bindArchiveHorizontal(ui.archiveContent);
ui.archiveContent?.addEventListener("click", (e) => {
const node = e.target.closest("[data-family-tarinai-id]");
if (!node) return;
@ -422,13 +470,17 @@ function bindUI() {
world.speed = speeds[(i + 1) % speeds.length];
syncTopButtons();
});
ui.ecologyBtn?.addEventListener("click", openEcologyDialog);
ui.soundBtn?.addEventListener("click", () => {
const on = audio.toggle();
ui.soundBtn.textContent = on ? "\u97f3 ON" : "\u97f3 OFF";
showToast(on ? "\u97f3\u3092\u6709\u52b9\u306b\u3057\u307e\u3057\u305f\u3002" : "\u97f3\u3092\u7121\u52b9\u306b\u3057\u307e\u3057\u305f\u3002");
});
ui.saveBtn.addEventListener("click", saveGame);
ui.loadBtn.addEventListener("click", loadGame);
ui.creditsBtn?.addEventListener("click", openCreditsDialog);
ui.creditsCloseBtn?.addEventListener("click", closeCreditsDialog);
ui.creditsDialog?.addEventListener("click", (e) => {
if (e.target === ui.creditsDialog) closeCreditsDialog();
});
ui.resetBtn.addEventListener("click", () => {
openFieldDialog();
});
@ -439,6 +491,23 @@ function bindUI() {
if (!btn) return;
resetWithField(btn.dataset.fieldType || "garden");
});
ui.ecologyCloseBtn?.addEventListener("click", closeEcologyDialog);
ui.ecologyDialog?.addEventListener("click", (e) => {
if (e.target === ui.ecologyDialog) closeEcologyDialog();
});
ui.archiveResetBtn?.addEventListener("click", openArchiveResetDialog);
ui.archiveResetCancelBtn?.addEventListener("click", closeArchiveResetDialog);
ui.archiveResetDialog?.addEventListener("click", (e) => {
if (e.target === ui.archiveResetDialog) closeArchiveResetDialog();
});
ui.archiveResetConfirmBtn?.addEventListener("click", () => {
world.resetFamilyTree?.();
uiCache.archiveVersion = "";
closeArchiveResetDialog();
renderArchive();
renderSelected();
showToast("家系図をリセットしました。");
});
ui.toolPalette.addEventListener("click", (e) => {
const toggle = e.target.closest(".tool-category-toggle");
@ -468,34 +537,139 @@ function bindUI() {
}
}, { passive: true });
};
const containOwnVerticalScroll = (el) => {
if (!el) return;
el.addEventListener("wheel", (e) => {
if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return;
const canScroll = el.scrollHeight > el.clientHeight + 1;
if (!canScroll) return;
const atTop = el.scrollTop <= 0;
const atBottom = el.scrollTop + el.clientHeight >= el.scrollHeight - 1;
const movingInside = (e.deltaY < 0 && !atTop) || (e.deltaY > 0 && !atBottom);
if (!movingInside) return;
e.preventDefault();
e.stopPropagation();
el.scrollTop += e.deltaY;
}, { passive: false });
};
chainScroll(document.querySelector(".panel"));
chainScroll(ui.archiveContent);
chainScroll(ui.log);
containOwnVerticalScroll(ui.log);
canvas.addEventListener("contextmenu", (e) => e.preventDefault());
canvas.addEventListener("mousedown", (e) => {
if (e.button === 0 && world.tool === "pinch") {
const p = screenToWorld(e);
if (!p.inside) return;
const target = world.findGrabTargetAt ? world.findGrabTargetAt(p.x, p.y) : null;
if (!target) {
showToast("つまめる対象がありません。");
return;
}
e.preventDefault();
uiCache.grabbing = true;
uiCache.grabMoved = false;
uiCache.grabTarget = target;
uiCache.grabKind = target instanceof Tarinai ? "tarinai" : "item";
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
uiCache.grabStartX = e.clientX;
uiCache.grabStartY = e.clientY;
if (uiCache.grabKind === "tarinai") {
world.selected = target;
target.sleeping = false;
if (target.state === "sleep" || target.state === "seek_bed") target.state = "idle";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.18);
} else {
target.dropTimer = 0;
target.dropMax = 0;
target.dropImpactDone = true;
}
canvas.style.cursor = "grabbing";
renderStats();
return;
}
if (e.button !== 2) return;
e.preventDefault();
uiCache.panning = true;
uiCache.panMoved = false;
uiCache.panButton = e.button;
uiCache.panStartX = e.clientX;
uiCache.panStartY = e.clientY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
canvas.style.cursor = "grabbing";
});
window.addEventListener("mouseup", (e) => {
if (e.button !== 2 && !uiCache.panning) return;
if (uiCache.panning) {
uiCache.panning = false;
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair");
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (target) {
if (uiCache.grabKind === "tarinai") {
target.target = null;
target.targetKey = "";
target.surpriseTimer = Math.max(target.surpriseTimer || 0, 0.15);
} else if (target.type === "ball") {
target.prevX = target.x;
target.prevY = target.y;
}
}
uiCache.grabbing = false;
uiCache.grabTarget = null;
uiCache.grabKind = "";
world.rebuildSpatial?.();
canvas.style.cursor = world.tool === "pinch" ? "grab" : (world.tool === "observe" ? "default" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
renderStats();
return;
}
if (!uiCache.panning) return;
if (uiCache.panButton !== null && e.button !== uiCache.panButton) return;
uiCache.panning = false;
uiCache.panButton = null;
canvas.style.cursor = world.tool === "observe" ? "default" : (world.tool === "pinch" ? "grab" : (world.tool === "poke" || world.tool === "delete" ? "pointer" : "crosshair"));
});
window.addEventListener("mousemove", (e) => {
if (uiCache.grabbing) {
const target = uiCache.grabTarget;
if (!target || target.dead) {
uiCache.grabbing = false;
uiCache.grabTarget = null;
canvas.style.cursor = world.tool === "pinch" ? "grab" : "default";
return;
}
const p = screenToWorld(e);
const dx = p.x - (uiCache.grabLastWorldX ?? p.x);
const dy = p.y - (uiCache.grabLastWorldY ?? p.y);
uiCache.grabLastWorldX = p.x;
uiCache.grabLastWorldY = p.y;
const totalMove = Math.hypot(e.clientX - (uiCache.grabStartX || e.clientX), e.clientY - (uiCache.grabStartY || e.clientY));
if (totalMove > 3) uiCache.grabMoved = true;
const pad = target.radius || target.r || 16;
target.x = clamp(p.x, CONFIG.worldPadding + pad * 0.15, world.w - CONFIG.worldPadding - pad * 0.15);
target.y = clamp(p.y, CONFIG.worldPadding + pad * 0.15, world.h - CONFIG.worldPadding - pad * 0.15);
target.vx = clamp(dx * 16, -160, 160);
target.vy = clamp(dy * 16, -160, 160);
if (uiCache.grabKind === "tarinai") {
target.state = "idle";
target.target = null;
target.sleeping = false;
} else if (target.type === "ball") {
target.prevX = target.x - dx;
target.prevY = target.y - dy;
target.spinVelocity = clamp((target.spinVelocity || 0) + dx * 0.06, -38, 38);
}
world.drawListDirty = true;
world.rebuildSpatial?.();
canvas.style.cursor = "grabbing";
render();
return;
}
if (!uiCache.panning) return;
const dx = e.clientX - uiCache.panLastX;
const dy = e.clientY - uiCache.panLastY;
uiCache.panLastX = e.clientX;
uiCache.panLastY = e.clientY;
if (Math.abs(dx) + Math.abs(dy) > 0.5) {
const totalMove = Math.hypot(e.clientX - uiCache.panStartX, e.clientY - uiCache.panStartY);
if (totalMove > 8) {
canvas.style.cursor = "grabbing";
world.panCamera?.(-dx, -dy);
uiCache.panMoved = true;
render();
@ -503,6 +677,10 @@ function bindUI() {
});
canvas.addEventListener("click", (e) => {
if (uiCache.grabMoved) {
uiCache.grabMoved = false;
return;
}
if (uiCache.panMoved) {
uiCache.panMoved = false;
return;
@ -515,13 +693,27 @@ function bindUI() {
canvas.addEventListener("mousemove", (e) => {
const p = screenToWorld(e);
const prevX = world.pointer.x;
const prevY = world.pointer.y;
world.pointer.x = p.x;
world.pointer.y = p.y;
world.pointer.inside = !!p.inside;
const delta = Math.hypot((p.x || 0) - (prevX || 0), (p.y || 0) - (prevY || 0));
world.pointer.motion = clamp(delta / 18, 0, 2);
world.pointer.movedAt = performance.now();
});
canvas.addEventListener("wheel", resizeFieldFromWheel, { passive: false });
canvas.addEventListener("mouseleave", () => {
world.pointer.inside = false;
world.pointer.motion = 0;
});
window.addEventListener("keydown", (e) => {
if (e.key !== "Escape") return;
closeFieldDialog();
closeEcologyDialog();
closeArchiveResetDialog();
closeCreditsDialog();
});
window.addEventListener("resize", resizeCanvas);

File diff suppressed because it is too large Load diff

View file

@ -29,7 +29,7 @@ class World {
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
this.pointer = { x: 0, y: 0, inside: false };
this.pointer = { x: 0, y: 0, inside: false, motion: 0, movedAt: 0 };
this.lastPhase = "";
this.weather = "sunny";
this.fieldType = "garden";
@ -85,8 +85,49 @@ class World {
return Math.max(0.22, field.worldScale || 1);
}
grassLimit() {
const field = this.fieldDefinition();
if (Number.isFinite(field?.grassLimit)) return Math.max(0, Math.round(field.grassLimit));
const base = CONFIG.grassLimit ?? 96;
const mediumScale = Math.max(0.22, FIELD_TYPES?.garden?.worldScale || 1);
const ratio = this.fieldWorldScale() / mediumScale;
// 中サイズを CONFIG.grassLimit の基準にし、フィールドの論理サイズに応じて増減する。
return Math.max(12, Math.round(base * ratio));
}
baseZoomMin() {
return CONFIG.fieldZoomMin || 0.42;
}
baseZoomMax() {
return CONFIG.fieldZoomMax || 2.45;
}
fitFieldZoom() {
const vw = Math.max(1, this.viewportW || this.w || 1000);
const vh = Math.max(1, this.viewportH || this.h || 720);
const ww = Math.max(1, this.w || vw);
const wh = Math.max(1, this.h || vh);
// The minimum visible zoom is field-size dependent. Small fields must not
// be allowed to shrink below the viewport, otherwise empty margins appear.
return Math.max(vw / ww, vh / wh);
}
zoomLimits() {
const min = Math.max(this.baseZoomMin(), this.fitFieldZoom());
// Keep the old upper bound for normal/large fields, but give small fields
// proportional zoom-in headroom because their no-margin minimum is higher.
const max = Math.max(this.baseZoomMax(), min * this.baseZoomMax());
return { min, max };
}
normalizedFieldZoom(value = this.fieldZoom) {
const limits = this.zoomLimits();
return clamp(Number(value) || limits.min, limits.min, limits.max);
}
viewScale() {
return clamp(Number(this.fieldZoom) || 1, 0.42, 2.45);
return this.normalizedFieldZoom(this.fieldZoom);
}
visibleWorldW() {
@ -161,6 +202,7 @@ class World {
const scale = Math.max(requestedScale, 220 / this.viewportW, 180 / this.viewportH);
this.w = this.viewportW * scale;
this.h = this.viewportH * scale;
this.fieldZoom = this.normalizedFieldZoom(this.fieldZoom);
this.clampCamera();
if (opts.scaleContents && oldW > 0 && oldH > 0) {
const sx = this.w / oldW;
@ -183,15 +225,20 @@ class World {
}
setFieldZoom(nextZoom) {
const next = clamp(Number(nextZoom) || 1, 0.42, 2.45);
if (Math.abs(next - (this.fieldZoom || 1)) < 0.001) return false;
const current = this.normalizedFieldZoom(this.fieldZoom);
const next = this.normalizedFieldZoom(nextZoom);
if (Math.abs(next - current) < 0.001) {
this.fieldZoom = current;
this.clampCamera();
return false;
}
this.fieldZoom = next;
this.clampCamera();
this.drawListDirty = true;
return true;
}
edgeSpawnPoint(targetX = null, targetY = null, margin = 34) {
edgeSpawnPoint(targetX = null, targetY = null, margin = 34, outside = false) {
const w = Math.max(this.w || 1000, margin * 2 + 1);
const h = Math.max(this.h || 720, margin * 2 + 1);
let side;
@ -201,19 +248,20 @@ class World {
} else {
side = Math.floor(Math.random() * 4);
}
if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: margin, vx: rand(-10, 10), vy: rand(18, 34), angle: Math.PI / 2 };
if (side === 1) return { x: w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-34, -18), vy: rand(-10, 10), angle: Math.PI };
if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: h - margin, vx: rand(-10, 10), vy: rand(-34, -18), angle: -Math.PI / 2 };
return { x: margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(18, 34), vy: rand(-10, 10), angle: 0 };
const p = outside ? -(margin + 24) : margin;
if (side === 0) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: p, vx: rand(-10, 10), vy: rand(28, 48), angle: Math.PI / 2 };
if (side === 1) return { x: outside ? w - p : w - margin, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(-48, -28), vy: rand(-10, 10), angle: Math.PI };
if (side === 2) return { x: clamp(targetX ?? rand(margin, w - margin), margin, w - margin), y: outside ? h - p : h - margin, vx: rand(-10, 10), vy: rand(-48, -28), angle: -Math.PI / 2 };
return { x: p, y: clamp(targetY ?? rand(margin, h - margin), margin, h - margin), vx: rand(28, 48), vy: rand(-10, 10), angle: 0 };
}
toolSizeFor(type = "") {
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return "medium";
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return "medium";
return (this.toolSizes && this.toolSizes[type]) || this.toolSize || "medium";
}
toolSizeScale(type = "") {
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "zunchi"].includes(type)) return 1;
if (!["firecracker", "fence_v", "fence_h", "stone", "grass", "sweet", "love_mochi", "fight_mochi", "zunchi"].includes(type)) return 1;
return { small: 0.68, medium: 1.0, large: 1.48 }[this.toolSizeFor(type)] || 1;
}
@ -224,7 +272,7 @@ class World {
item.toolSize = size;
this.toolSize = size;
item.r *= scale;
if (["sweet", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
if (["sweet", "love_mochi", "fight_mochi", "grass", "zunchi"].includes(item.type)) item.amount *= scale * scale;
if (item.type === "firecracker") item.blastScale = scale;
return item;
}
@ -256,7 +304,7 @@ class World {
this.familyVersion = 0;
this.relationNotices = {};
this.resolvedFightIds = {};
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false };
this.pointer = { x: this.w / 2, y: this.h / 2, inside: false, motion: 0, movedAt: 0 };
this.cameraX = Math.max(0, (this.w - (this.viewportW || this.w)) / 2);
this.cameraY = Math.max(0, (this.h - (this.viewportH || this.h)) / 2);
this.clampCamera();
@ -425,6 +473,20 @@ class World {
return changed;
}
resetFamilyTree() {
this.family = {};
this.familyCleanVersion = 0;
this.familyVersion = (this.familyVersion || 0) + 1;
this.maxGeneration = 1;
for (const t of this.tarinai || []) {
t.parents = [];
t.parentNames = [];
t.children = [];
t.generation = 1;
t.hasPaired = false;
}
}
validateFamily() {
this.normalizeFamily();
const family = this.family || {};
@ -516,16 +578,16 @@ class World {
startBirthRitual(a, b) {
if (!a || !b || a.dead || b.dead) return false;
if (a.isZunchiSlave || b.isZunchiSlave) return false;
if (a.birthRitualTimer > 0.04 || b.birthRitualTimer > 0.04) return false;
const aJoined = a.familyJoined ? a.familyJoined() : Boolean((a.parents || []).length || (a.children || []).length);
const bJoined = b.familyJoined ? b.familyJoined() : Boolean((b.parents || []).length || (b.children || []).length);
if (aJoined && !bJoined) b.generation = a.generation;
else if (bJoined && !aJoined) a.generation = b.generation;
else if (!aJoined && !bJoined) { a.generation = 1; b.generation = 1; }
a.hasPaired = true;
b.hasPaired = true;
this.recordFamily(a);
this.recordFamily(b);
// Do not mark either parent as part of a family during the ritual.
// The archive should only gain nodes after a child has actually been created;
// otherwise childless ritual participants appear as isolated family trees.
const duration = 10.0;
a.birthRitualTimer = b.birthRitualTimer = duration;
a.birthRitualMax = b.birthRitualMax = duration;
@ -568,6 +630,29 @@ class World {
return children[0] || null;
}
areParentChild(a, b) {
if (!a || !b) return false;
const aId = a.id || a;
const bId = b.id || b;
if (!aId || !bId) return false;
return Boolean((a.parents || []).includes(bId) || (a.children || []).includes(bId) || (b.parents || []).includes(aId) || (b.children || []).includes(aId));
}
areCoParents(a, b) {
if (!a || !b) return false;
const aId = a.id || a;
const bId = b.id || b;
if (!aId || !bId || aId === bId) return false;
if (a.birthPartnerId && a.birthPartnerId === bId) return true;
if (b.birthPartnerId && b.birthPartnerId === aId) return true;
const aChildren = new Set((a.children || []).filter(Boolean));
if (!aChildren.size) return false;
for (const id of (b.children || [])) {
if (aChildren.has(id)) return true;
}
return false;
}
spawnChild(a, b, index = 0, total = 1) {
const inherited = Math.random() < 0.56 ? a.type : b.type;
const mutation = Math.random() < 0.18 ? baseSpriteId() : inherited;
@ -590,8 +675,14 @@ class World {
birthTime: this.time,
});
this.maxGeneration = Math.max(this.maxGeneration, child.generation);
a.hasPaired = true;
b.hasPaired = true;
a.affection = 0; b.affection = 0;
a.loneliness *= 0.65; b.loneliness *= 0.65;
a.adjustRelation?.(b, 0.65, -0.20, "co_parent");
b.adjustRelation?.(a, 0.65, -0.20, "co_parent");
a.postBirthPeaceTimer = Math.max(a.postBirthPeaceTimer || 0, 28);
b.postBirthPeaceTimer = Math.max(b.postBirthPeaceTimer || 0, 28);
a.children.push(child.id);
b.children.push(child.id);
this.recordFamily(a);
@ -607,16 +698,18 @@ class World {
startConflict(a, b) {
if (!a || !b || a.dead || b.dead) return;
if (this.areParentChild(a, b) || this.areCoParents?.(a, b)) return;
if ((a.postBirthPeaceTimer || 0) > 0 || (b.postBirthPeaceTimer || 0) > 0) return;
if (a.fightTimer > 0.04 || b.fightTimer > 0.04 || a.intimidateTimer > 0.04 || b.intimidateTimer > 0.04) return;
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + rand(-5, 5);
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + rand(-5, 5);
const scoreA = a.energy * 0.38 + a.mood * 0.18 + a.stress * 0.12 + (a.type === "angry" ? 12 : 0) + (a.personality === "irritable" ? 8 : 0) + ((b.relationTo(a.id).fear || 0) * 0.65) + (a.isZunchiSlave ? -10 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const scoreB = b.energy * 0.38 + b.mood * 0.18 + b.stress * 0.12 + (b.type === "angry" ? 12 : 0) + (b.personality === "irritable" ? 8 : 0) + ((a.relationTo(b.id).fear || 0) * 0.65) + (b.isZunchiSlave ? -10 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 9 : 0) + rand(-5, 5);
const actor = scoreA >= scoreB ? a : b;
const target = actor === a ? b : a;
const actorScore = actor === a ? scoreA : scoreB;
const targetScore = actor === a ? scoreB : scoreA;
const canIntimidate = !actor.lowHealthSprite || !actor.lowHealthSprite();
const intimidationChance = 0.40;
const battleDrug = (actor.fightMochiTimer || 0) > 0.04 || (target.fightMochiTimer || 0) > 0.04;
const intimidationChance = clamp(0.40 + (battleDrug ? 0.24 : 0) + (target.isZunchiSlave ? 0.18 : 0), 0.24, 0.90);
if (canIntimidate && Math.random() < intimidationChance) {
this.startIntimidation(actor, target, actorScore, targetScore);
return;
@ -686,8 +779,8 @@ class World {
b.fightTargetIds = b.fightTargetIds.slice(-4);
a.fightTargetId = a.fightTargetIds[0];
b.fightTargetId = b.fightTargetIds[0];
const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + rand(-7, 7);
const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + rand(-7, 7);
const aScore = a.energy * 0.52 + a.mood * 0.30 + (a.type === "angry" ? 10 : 0) + (a.personality === "irritable" ? 5 : 0) + ((a.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (a.isZunchiSlave ? -14 : 0) + rand(-7, 7);
const bScore = b.energy * 0.52 + b.mood * 0.30 + (b.type === "angry" ? 10 : 0) + (b.personality === "irritable" ? 5 : 0) + ((b.fightMochiTimer || 0) > 0.04 ? 12 : 0) + (b.isZunchiSlave ? -14 : 0) + rand(-7, 7);
const loser = aScore <= bScore ? a : b;
const winner = loser === a ? b : a;
loser.defeatedById = winner.id;
@ -739,6 +832,15 @@ class World {
}
for (const t of this.tarinai) {
if (t.dead) continue;
const wasSleeping = t.state === "sleep" || t.sleeping || t.state === "seek_bed";
t.sleeping = false;
if (wasSleeping) {
t.state = "panic";
t.target = { x, y, dead: false };
t.surpriseTimer = Math.max(t.surpriseTimer, 0.95);
t.fearTimer = Math.max(t.fearTimer, 1.2);
t.thought = "爆竹でたたき起こされた";
}
t.stress = clamp(t.stress + rand(8, 15), 0, 130);
t.fearTimer = Math.max(t.fearTimer, 0.58);
const dx = t.x - x;
@ -764,8 +866,35 @@ class World {
this.spawnFallEffect(t.x, t.y + t.radius * 0.45, 1.1 + p);
if (!t.dead && Math.random() < 0.45) this.spawnBubble(t.x, t.y - t.radius * 1.35, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
}
let blastedBalls = 0;
for (const ball of this.items) {
if (!ball || ball.dead || ball.type !== "ball") continue;
let dx = ball.x - x;
let dy = ball.y - y;
let d = Math.hypot(dx, dy) || 1;
if (d > blastRadius * 1.18) continue;
const p = clamp(1 - d / (blastRadius * 1.18), 0, 1);
if (d < 0.001) {
const a = rand(0, Math.PI * 2);
dx = Math.cos(a); dy = Math.sin(a); d = 1;
}
const blast = 430 + p * 960;
ball.vx = (ball.vx || 0) + dx / d * blast + rand(-80, 80);
ball.vy = (ball.vy || 0) + dy / d * blast + rand(-80, 80);
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
const cap = 1250;
if (speed > cap) {
ball.vx = ball.vx / speed * cap;
ball.vy = ball.vy / speed * cap;
}
ball.spinVelocity = clamp((ball.spinVelocity || 0) + rand(-24, 24), -38, 38);
ball.lastPokedAt = this.time || 0;
ball.pokeCombo = Math.max(ball.pokeCombo || 0, 5);
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(18, ball.r * 1.2), life: 0.24, color: "rgba(255,230,116,0.58)" }));
blastedBalls += 1;
}
audio.explode();
this.log("\u7206\u7af9\u304c\u6d3e\u624b\u306b\u306f\u3058\u3051\u3001\u5ead\u304c\u3056\u308f\u3064\u3044\u305f\u3002", "accident");
this.log(blastedBalls ? "爆竹が派手にはじけ、ボールが急加速した。" : "爆竹が派手にはじけ、庭がざわついた。", "accident");
}
spawnZunchi(x, y) {
@ -845,8 +974,12 @@ class World {
}));
}
isSleepFurniture(it) {
return Boolean(it && it.type === "bed");
}
bedOccupancy(bed) {
if (!bed || bed.type !== "bed") return 0;
if (!this.isSleepFurniture(bed)) return 0;
let n = 0;
const radius = Math.max(86, bed.r * 2.45);
for (const t of this.nearbyTarinai(bed.x, bed.y, radius)) {
@ -857,7 +990,7 @@ class World {
}
bedComfort(bed) {
if (!bed || bed.type !== "bed") return 0.7;
if (!this.isSleepFurniture(bed)) return 0.7;
const occ = this.bedOccupancy(bed);
return clamp((bed.comfort ?? 1) - Math.max(0, occ - 3) * 0.075 - (bed.wear || 0) * 0.18, 0.42, 1.18);
}
@ -866,12 +999,13 @@ class World {
let best = null;
let bestScore = Infinity;
for (const bed of this.nearbyItems(t.x, t.y, maxDist)) {
if (bed.dead || bed.type !== "bed") continue;
if (bed.dead || !this.isSleepFurniture(bed)) continue;
if (t?.shouldAvoidTarget && t.shouldAvoidTarget(bed)) continue;
const d = dist(t, bed);
const occ = this.bedOccupancy(bed);
const comfort = this.bedComfort(bed);
const score = d - comfort * 46 + Math.max(0, occ - 3) * 34;
const crowdPenalty = Math.max(0, occ - 3) * 34;
const score = d - comfort * 46 + crowdPenalty;
if (score < bestScore) { best = bed; bestScore = score; }
}
return best;
@ -901,6 +1035,12 @@ class World {
loser.adjustRelation(winner, -1.2, 7.2 * loser.personalityProfile().fear, "\u55a7\u5629\u306b\u8ca0\u3051\u305f");
winner.relationTo(loser.id).fightsWon = (winner.relationTo(loser.id).fightsWon || 0) + 1;
loser.relationTo(winner.id).fightsLost = (loser.relationTo(winner.id).fightsLost || 0) + 1;
loser.totalFightLosses = (loser.totalFightLosses || 0) + 1;
if ((loser.totalFightLosses || 0) >= 5 && loser.becomeZunchiSlave?.(winner)) {
const oldName = loser.formerName || loser.name;
this.spawnBubble(loser.x, loser.y - loser.radius * 1.20, "ぶりゅ…", "rgba(74,91,50,0.78)");
this.log(`${oldName}は5回喧嘩に負け、ずんちどれいになった。`, "fight");
}
loser.fearTimer = Math.max(loser.fearTimer, 1.2 * loser.personalityProfile().fear);
this.spawnBubble(loser.x, loser.y - loser.radius * 1.28, "\u306f\u3041\u3041\u3041\uff01", "rgba(84,66,86,0.80)");
winner.affection = clamp(winner.affection + 0.8, 0, 80);
@ -939,6 +1079,183 @@ class World {
if (hit > 0) this.log(`${isStone ? "\u77f3" : "\u9053\u5177"}\u304c\u843d\u3061\u3001${hit}\u5339\u306e\u305f\u308a\u306a\u3044\u304c\u5dfb\u304d\u8fbc\u307e\u308c\u305f\u3002`, "accident");
}
countBallChasers(ball, except = null) {
if (!ball) return 0;
let count = 0;
for (const t of this.tarinai || []) {
if (!t || t === except || t.dead) continue;
if (t.state === "play_ball" && t.target === ball) count += 1;
}
return count;
}
limitBallChasers() {
if (!this.itemCounts?.ball) return;
const groups = new Map();
for (const t of this.tarinai || []) {
if (!t || t.dead || t.state !== "play_ball" || !t.target || t.target.dead || t.target.type !== "ball") continue;
const arr = groups.get(t.target) || [];
arr.push(t);
groups.set(t.target, arr);
}
for (const [ball, arr] of groups) {
if (arr.length <= 5) continue;
arr.sort((a, b) => {
const ap = a.personality === "playful" ? -120 : 0;
const bp = b.personality === "playful" ? -120 : 0;
return dist(a, ball) + ap - (dist(b, ball) + bp);
});
for (const t of arr.slice(5)) {
t.state = "idle";
t.target = null;
t.thought = "ボールが混み合っているので眺めている";
t.wanderAngle = Math.atan2(t.y - ball.y, t.x - ball.x) + rand(-0.45, 0.45);
}
}
}
pokeBall(ball, x, y) {
if (!ball || ball.dead || ball.type !== "ball") return false;
const now = (typeof performance !== "undefined" && performance.now) ? performance.now() / 1000 : (this.time || 0);
let dx = ball.x - x;
let dy = ball.y - y;
let d = Math.hypot(dx, dy);
if (d < 0.001) {
const a = (ball.lastPokeAngle ?? rand(0, Math.PI * 2)) + rand(-0.55, 0.55);
dx = Math.cos(a);
dy = Math.sin(a);
d = 1;
}
const nx = dx / d;
const ny = dy / d;
const recent = now - (ball.lastPokedAt || -999) < 0.82;
ball.pokeCombo = recent ? Math.min(10, (ball.pokeCombo || 0) + 1) : 1;
ball.lastPokedAt = now;
ball.lastPokeAngle = Math.atan2(ny, nx);
const currentSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const impulse = 145 + ball.pokeCombo * 64 + Math.min(260, currentSpeed * 0.24);
ball.vx = (ball.vx || 0) + nx * impulse;
ball.vy = (ball.vy || 0) + ny * impulse;
const speed = Math.hypot(ball.vx || 0, ball.vy || 0);
const cap = 1250;
if (speed > cap) {
ball.vx = ball.vx / speed * cap;
ball.vy = ball.vy / speed * cap;
}
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? 1 : -1) * (5.6 + ball.pokeCombo * 1.15), -38, 38);
ball.amount = Math.max(ball.amount || 999, 999);
audio.poke();
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(16, ball.r * (0.95 + ball.pokeCombo * 0.05)), life: 0.22, color: "rgba(255,255,255,0.58)" }));
if (ball.pokeCombo >= 4 || speed > 430) this.log(`ボールを連続でつつき、勢いが増した。`, "observe");
else this.log(`ボールをつついて弾いた。`, "observe");
return true;
}
resolveBallInteractions(dt) {
if (!this.itemCounts?.ball) return;
for (const ball of this.items) {
if (!ball || ball.dead || ball.type !== "ball") continue;
const ballSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const sweep = Math.min(420, ballSpeed * Math.max(0.016, dt || 0.016) + 84);
const range = (ball.r || 18) + 72 + sweep;
const px = Number.isFinite(ball.prevX) ? ball.prevX : ball.x;
const py = Number.isFinite(ball.prevY) ? ball.prevY : ball.y;
const sx = ball.x - px;
const sy = ball.y - py;
const segLenSq = sx * sx + sy * sy;
for (const t of this.nearbyTarinai(ball.x, ball.y, range)) {
if (!t || t.dead || t.state === "sleep") continue;
let hitX = ball.x;
let hitY = ball.y;
if (segLenSq > 1) {
const u = clamp(((t.x - px) * sx + (t.y - py) * sy) / segLenSq, 0, 1);
hitX = px + sx * u;
hitY = py + sy * u;
}
let d = Math.max(0.001, distXY(hitX, hitY, t.x, t.y));
const hitDistance = (ball.r || 18) + t.radius * 0.74;
if (d > hitDistance) continue;
const now = this.time || 0;
const repeat = ball.lastKickerId === t.id && now - (ball.lastKickedAt || -999) < 0.18;
if (repeat && ballSpeed < 300) continue;
let nx = (hitX - t.x) / d;
let ny = (hitY - t.y) / d;
if (!Number.isFinite(nx) || !Number.isFinite(ny) || Math.abs(nx) + Math.abs(ny) < 0.001) {
if (ballSpeed > 0.01) {
nx = (ball.vx || 0) / ballSpeed;
ny = (ball.vy || 0) / ballSpeed;
} else {
nx = t.facingDir ? t.facingDir() : 1;
ny = rand(-0.22, 0.22);
}
}
const isDangerous = ballSpeed >= 320;
if (isDangerous && now - (t.lastBallDamageAt || -999) > 0.46) {
const bvx = ballSpeed > 0.01 ? (ball.vx || 0) / ballSpeed : nx;
const bvy = ballSpeed > 0.01 ? (ball.vy || 0) / ballSpeed : ny;
const damage = clamp((ballSpeed - 285) / 18, 3.5, 38);
const push = clamp(ballSpeed * 0.34, 95, 310);
t.lastBallDamageAt = now;
t.vx += bvx * push;
t.vy += bvy * push;
t.damage(damage, "超高速のボールに当たった");
const timidStress = t.personality === "timid" ? 1.55 : 1.0;
t.stress = clamp(t.stress + clamp(damage * 0.55 * timidStress, 3, t.personality === "timid" ? 26 : 18), 0, 130);
t.hurtTimer = Math.max(t.hurtTimer, damage > 16 ? 2.4 : 1.35);
t.fallTimer = Math.max(t.fallTimer, damage > 14 ? 0.82 : 0.38);
t.fallMax = Math.max(t.fallMax || 0.82, t.fallTimer);
t.fallDir = bvx >= 0 ? 1 : -1;
t.fearTimer = Math.max(t.fearTimer, 0.8);
this.spawnFallEffect(t.x, t.y + t.radius * 0.55, clamp(damage / 18, 0.55, 1.6));
this.effects.push(new Effect("ring", hitX, hitY, { size: Math.max(20, ball.r * 1.25), life: 0.22, color: "rgba(210,75,65,0.50)" }));
if (this.relationNotice(t.id, ball.id || "ball", "fast-ball-hit", 2.8)) this.log(`${t.name}は超高速のボールに当たってダメージを受けた。`, "accident");
ball.vx = (ball.vx || 0) * 0.58 - nx * Math.min(110, ballSpeed * 0.10);
ball.vy = (ball.vy || 0) * 0.58 - ny * Math.min(110, ballSpeed * 0.10);
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx >= 0 ? -1 : 1) * 8.5, -38, 38);
if (t.dead) continue;
}
const relVx = (t.vx || 0) - (ball.vx || 0);
const relVy = (t.vy || 0) - (ball.vy || 0);
const relativeSpeed = Math.hypot(relVx, relVy);
const playIntent = t.state === "play_ball" || t.target === ball;
const playful = t.personality === "playful";
const impulse = clamp(48 + relativeSpeed * 0.72 + (playIntent ? 52 : 0) + (playful ? 24 : 0), 48, isDangerous ? 360 : 260);
ball.vx = (ball.vx || 0) * (isDangerous ? 0.78 : 0.92) + nx * impulse + (t.vx || 0) * 0.52;
ball.vy = (ball.vy || 0) * (isDangerous ? 0.78 : 0.92) + ny * impulse + (t.vy || 0) * 0.52;
const kickedSpeed = Math.hypot(ball.vx || 0, ball.vy || 0);
const kickCap = 1250;
if (kickedSpeed > kickCap) {
ball.vx = ball.vx / kickedSpeed * kickCap;
ball.vy = ball.vy / kickedSpeed * kickCap;
}
ball.spinVelocity = clamp((ball.spinVelocity || 0) + (nx * (t.vy || 0) - ny * (t.vx || 0)) / 16 + impulse / Math.max(12, ball.r || 18) * (nx >= 0 ? 1 : -1), -38, 38);
ball.x = clamp(t.x + nx * (hitDistance + 2), CONFIG.worldPadding, this.w - CONFIG.worldPadding);
ball.y = clamp(t.y + ny * (hitDistance + 2), CONFIG.worldPadding, this.h - CONFIG.worldPadding);
ball.lastKickedAt = now;
ball.lastKickerId = t.id;
t.vx -= nx * Math.min(22, impulse * 0.10);
t.vy -= ny * Math.min(22, impulse * 0.10);
t.energy = clamp(t.energy - (playIntent ? 0.20 : 0.08), 0, 100);
const playRelief = playful && playIntent ? 2.8 : playful ? 1.5 : playIntent ? 0.75 : 0.20;
const timidBump = t.personality === "timid" && !playIntent ? clamp(ballSpeed / 190, 0.9, 3.8) : 0;
t.stress = clamp(t.stress - playRelief + timidBump, 0, 130);
if (playful && playIntent) {
t.loneliness = clamp(t.loneliness - 0.9, 0, 100);
t.affection = clamp(t.affection + 0.18, 0, 100);
}
t.goodMode = playIntent || playful ? "smile" : t.goodMode;
if (playIntent && now > (t.nextPlayBubbleAt || 0)) {
t.nextPlayBubbleAt = now + rand(2.0, 3.8);
this.spawnBubble(t.x, t.y - t.radius * 1.15, pick(["!", "はう", "?" ]), "rgba(70,96,50,0.76)");
}
if (playful && this.relationNotice(t.id, ball.id || "ball", "ball-kick", 16)) this.log(`${t.name}はボールを追いかけて弾いた。`, "observe");
this.effects.push(new Effect("ring", ball.x, ball.y, { size: Math.max(12, ball.r * 0.80), life: 0.20, color: "rgba(170,210,95,0.50)" }));
}
}
}
nearest(entity, types, maxDist = Infinity) {
let best = null, bestD = maxDist;
const candidates = Number.isFinite(maxDist) ? this.nearbyItems(entity.x, entity.y, maxDist) : this.items;
@ -1205,7 +1522,7 @@ class World {
pruneOldest("zunchi", CONFIG.zunchiLimit ?? 56);
pruneOldest("trace", CONFIG.traceLimit ?? 64);
pruneOldest("splat", CONFIG.splatLimit ?? 48);
pruneOldest("grass", CONFIG.grassLimit ?? 96);
pruneOldest("grass", this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96));
const limit = CONFIG.itemLimit ?? Infinity;
if (!Number.isFinite(limit) || this.items.length <= limit) return;
@ -1253,18 +1570,22 @@ class World {
this.time += dt;
this.day = Math.floor(this.time / CONFIG.dayLength) + 1;
if (this.pointer) this.pointer.motion = Math.max(0, (this.pointer.motion || 0) - dt * 2.4);
this.updateWeather(dt);
this.rebuildSpatial();
const perf = this.performanceLevel();
const itemCountBefore = this.items.length;
for (const it of this.items) it.update(dt, this);
if (this.itemCounts?.ball) this.rebuildSpatial();
for (const ef of this.effects) ef.update(dt);
for (const t of this.tarinai) {
t.update(dt);
}
this.limitBallChasers();
this.resolveBallInteractions(dt);
this.compactTimer += dt;
const compactInterval = perf >= 2 ? 0.95 : 0.58;
@ -1297,16 +1618,17 @@ class World {
audio.phase();
}
if (this.tarinai.length > 0 && this.tarinai.length < 5 && Math.random() < dt * 0.03) {
const spot = this.edgeSpawnPoint();
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId() });
if (this.tarinai.length > 0 && this.tarinai.length <= 10 && Math.random() < dt * 0.022) {
const spot = this.edgeSpawnPoint(null, null, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), entryTimer: 2.0 });
t.wanderAngle = spot.angle;
this.log("\u3069\u3053\u304b\u304b\u3089\u305f\u308a\u306a\u3044\u304c\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002");
t.surpriseTimer = Math.max(t.surpriseTimer || 0, 0.35);
this.log(`${t.name}が画面外から迷い込んできた。`);
}
const grassCount = this.itemCounts.grass || 0;
if (grassCount < Math.min(24, CONFIG.grassLimit ?? 96) && Math.random() < dt * 0.036) {
if (grassCount < Math.min(24, this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96)) && Math.random() < dt * 0.036) {
const spot = this.findGrassPlantingSpot(rand(60, this.w - 60), rand(60, this.h - 60), {
allowOriginal: true,
minRadius: 28,
@ -1355,8 +1677,34 @@ class World {
this.log(`\u5929\u6c17: ${weatherLabel(this.weather)}`);
}
findGrabTargetAt(x, y) {
let foundT = null;
for (let i = this.tarinai.length - 1; i >= 0; i--) {
const t = this.tarinai[i];
if (!t || t.dead) continue;
if (t.contains ? t.contains(x, y) : distXY(x, y, t.x, t.y) <= (t.radius || 22) * 1.4) { foundT = t; break; }
}
let foundItem = null, foundItemD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type === "trace" || it.type === "splat") continue;
const hit = Math.max(24, (it.r || 12) * (it.type === "ball" ? 4.0 : 2.2));
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < foundItemD) { foundItem = it; foundItemD = d; }
}
if (foundT && foundItem) {
const td = distXY(x, y, foundT.x, foundT.y);
return td <= foundItemD + 8 ? foundT : foundItem;
}
return foundT || foundItem;
}
placeItem(item, dropped = true) {
if (!item) return null;
if (item.type === "grass" && (this.itemCounts?.grass || 0) >= (this.grassLimit ? this.grassLimit() : (CONFIG.grassLimit ?? 96))) {
showToast("このフィールドの草の上限に達しています。");
return null;
}
if (dropped) {
item.dropMax = item.type === "stone" ? 0.72 : 0.58;
item.dropTimer = item.dropMax;
@ -1381,19 +1729,30 @@ class World {
}
if (tool === "poke") {
let ballTarget = null;
let ballTargetD = Infinity;
for (let i = this.items.length - 1; i >= 0; i--) {
const it = this.items[i];
if (!it || it.dead || it.type !== "ball") continue;
const hit = Math.max(72, (it.r || 18) * 4.0);
const d = distXY(x, y, it.x, it.y);
if (d <= hit && d < ballTargetD) { ballTarget = it; ballTargetD = d; }
}
if (ballTarget && this.pokeBall(ballTarget, x, y)) return;
const target = [...this.tarinai].reverse().find(t => t.contains(x, y));
if (target) {
target.poke();
if (!target.dead) this.log(`${target.name}\u306f\u3064\u3064\u304b\u308c\u3001\u305f\u308a\u306a\u3044\u3053\u3068\u3092\u899a\u3048\u305f\u3002`);
if (!target.dead) this.log(`${target.name}はつつかれ、たりないことを覚えた。`);
} else {
this.log("\u7a7a\u6c17\u3092\u3064\u3064\u3044\u305f\u3002\u305d\u3053\u306b\u306f\u4f55\u3082\u305f\u308a\u306a\u304b\u3063\u305f\u3002");
this.log("空気をつついた。そこには何もたりなかった。");
}
return;
}
if (tool === "new") {
const spot = this.edgeSpawnPoint(x, y);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1 });
const spot = this.edgeSpawnPoint(x, y, 42, true);
const t = this.addTarinai({ x: spot.x, y: spot.y, vx: spot.vx, vy: spot.vy, type: baseSpriteId(), generation: 1, entryTimer: 2.0 });
t.wanderAngle = spot.angle;
audio.birth();
this.log(`${t.name}\u304c\u753b\u9762\u5916\u304b\u3089\u8ff7\u3044\u8fbc\u3093\u3067\u304d\u305f\u3002`);
@ -1415,14 +1774,14 @@ class World {
this.compactItems();
this.updateItemCounts();
this.rebuildSpatial();
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", love_mochi: "\u5a9a\u85ac\u9905", fight_mochi: "\u55a7\u5629\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", ball: "\u30dc\u30fc\u30eb", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5", trace: "\u8db3\u8de1", splat: "\u3057\u3076\u304d" }[best.type] || best.type;
this.log(`${label}\u3092\u524a\u9664\u3057\u305f\u3002`, "observe");
return;
}
const itemType = {
zunchi: "zunchi", sweet: "sweet", water: "water", grass: "grass",
stone: "stone", bed: "bed", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
zunchi: "zunchi", sweet: "sweet", love_mochi: "love_mochi", fight_mochi: "fight_mochi", water: "water", grass: "grass",
stone: "stone", bed: "bed", ball: "ball", firecracker: "firecracker", fence_v: "fence_v", fence_h: "fence_h"
}[tool];
if (itemType) {
let px = x, py = y;
@ -1436,9 +1795,10 @@ class World {
px = spot.x;
py = spot.y;
}
this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true);
const label = { zunchi: "\u305a\u3093\u3061", food: "\u98df\u3079\u7269", sweet: "\u305a\u3093\u3060\u9905", water: "\u6c34", grass: "\u8349", stone: "\u77f3", bed: "\u5e72\u8349\u5bdd\u5e8a", firecracker: "\u7206\u7af9", fence_v: "\u7e26\u306e\u67f5", fence_h: "\u6a2a\u306e\u67f5" }[itemType];
this.log(`${label}\u3092\u914d\u7f6e\u3057\u305f\u3002`);
const placed = this.placeItem(this.applyToolSize(new Item(itemType, px, py)), true);
if (!placed) return;
const label = { zunchi: "ずんち", food: "食べ物", sweet: "ずんだ餅", love_mochi: "媚薬餅", fight_mochi: "喧嘩餅", water: "水", grass: "草", stone: "石", bed: "干草寝床", ball: "ボール", firecracker: "爆竹", fence_v: "縦の柵", fence_h: "横の柵" }[itemType];
this.log(`${label}を配置した。`);
}
}
@ -1494,11 +1854,11 @@ class World {
this.toolSizes = data.toolSizes || this.toolSizes || {};
this.maxGeneration = data.maxGeneration || 1;
this.fieldType = FIELD_TYPES?.[data.fieldType]?.id || "garden";
this.fieldZoom = clamp(Number(data.fieldZoom) || 1, 0.42, 2.45);
this.fieldZoom = Number(data.fieldZoom) || 1;
this.cameraX = Number(data.cameraX) || 0;
this.cameraY = Number(data.cameraY) || 0;
this.tarinai = (data.tarinai || []).map(o => Tarinai.from(this, o));
this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food").map(o => Item.from(o));
this.items = (data.items || []).filter(o => o?.type !== "mirror" && o?.type !== "food" && o?.type !== "cardboard").map(o => Item.from(o));
this.effects = [];
this.compactItems();
this.compactEffects();