このシミュレーションは、町の住民1人ずつを「年齢・性別・住む家」の3つの数で持っている。1年が過ぎるたびに、亡くなるかどうか、子が生まれるかどうかを決めて年を1つ足し、そのあと町を出る人と町に来る世帯を決める。人口ピラミッドとグラフは、その結果を数えて描いているだけである。地図の点の流れは、同じ住民の1日の行き先を動きにしたものだ。
統計の表をそのまま持つ
出生・死亡・移動の確率は、公表されている統計の表から作る。死亡は、2024年の簡易生命表の死亡率qxを、男女別に0〜105歳まで並べた。qxは、x歳の人が1年のうちに亡くなる確率である。出生は、母の年齢5歳ごとの合計特殊出生率の内訳を各歳に均等に分け、合計が1になるように割ってある。これに合計特殊出生率のつまみの値を掛けると、その年齢の女性が1年に子を生む確率になる。
定数と統計(src/sims/town-population.ts の 13〜63 行)const START = 2025; // 最初の年const N0 = 6000; // 最初の人口const CAP = 24000, LOTS = 9000; // 住民と区画の配列の大きさconst AMAX = 105; // 生命表の最後の年齢(105 歳の人はその年のうちに亡くなる)const W_KM = 7.2, H_KM = 4.6; // 地図の広さ(km)const SP = 0.034; // 家の区画の間隔(km)const R_ST = 1.5; // 駅まで歩くか自転車で行ける距離(km)。この中に住む人は市の仕事に通えるconst FACTORY_JOBS = 400, DAYCARE_CAP = 90, DAYCARE_JOBS = 15, SCHOOL_JOBS = 20;const DAYCARE_USE = 0.55; // 1〜5 歳の子のうち、保育所に通う割合(この画面の近似)const CITY = 0.35; // 駅から 1.5 km の中に住む働く人のうち、市の仕事を選ぶ割合(この画面の近似)const ES_MIN = 40, JHS_MIN = 30, LOW_YEARS = 3; // 学校が閉じる子どもの数と年数(この画面の決まり)const WORK = 0.8, WORK_OLD = 0.35; // 働く人の割合(18〜64 歳・65〜74 歳。この画面の近似)const OUT_JOB = 3; // 仕事の足りない割合 u があると、50 歳未満の転出は (1 + 3u) 倍になるconst YOUTH = 1.5; // 町に大学や専門学校が無いので、18〜24 歳の転出は全国の割合の 1.5 倍const IN_BASE = 0.0105; // 転入する世帯の数の基準(1 年・住民 1 人あたり)const IN_JOB = 0.01; // 埋まっていない仕事 1 人分あたり、1 年に転入する世帯の数(工場の仕事は IN_FAC)const IN_FAC = 0.1; // 埋まっていない工場の仕事 1 人分あたり、1 年に転入する世帯の数const IN_ST = 0.06; // 駅から 1.5 km の中の空き家 1 戸あたり、1 年に転入する世帯の数(市へ通う人)const NO_ES = 1.6, NO_JHS = 1.25, DC_STAY = 0.85; // 町に小学校が無い・中学校が無い・近くに保育所がある家族の転出の倍率const DAY_S = 10; // 1 日の動きを見せる秒数 // 令和6年簡易生命表の死亡率 nqx(x 歳の人が x+1 歳になるまでに亡くなる確率)。0〜104 歳。105 歳は 1const Q_M = [0.00182, 0.00025, 0.00018, 0.00013, 0.0001, 0.00009, 0.00008, 0.00008, 0.00008, 0.00008, 0.00008, 0.00009, 0.0001, 0.00012, 0.00014, 0.00018, 0.00022, 0.00026, 0.00031, 0.00036, 0.00042, 0.00047, 0.00049, 0.00049, 0.00047, 0.00047, 0.00047, 0.00047, 0.00048, 0.0005, 0.00053, 0.00056, 0.00059, 0.00062, 0.00066, 0.00071, 0.00075, 0.0008, 0.00084, 0.0009, 0.00097, 0.00107, 0.00117, 0.00125, 0.00134, 0.00144, 0.00157, 0.00174, 0.00194, 0.00216, 0.00238, 0.00263, 0.00289, 0.00321, 0.00355, 0.00394, 0.00437, 0.00482, 0.00531, 0.00583, 0.00639, 0.007, 0.00771, 0.00854, 0.00948, 0.01051, 0.01161, 0.01277, 0.01407, 0.01555, 0.01724, 0.01917, 0.0213, 0.02369, 0.0263, 0.02894, 0.03171, 0.03498, 0.03893, 0.0435, 0.04861, 0.05417, 0.06002, 0.06687, 0.07513, 0.08467, 0.09544, 0.1074, 0.12076, 0.13561, 0.15175, 0.16524, 0.18235, 0.20147, 0.22276, 0.2464, 0.27252, 0.30126, 0.33271, 0.36691, 0.40384, 0.44341, 0.4854, 0.52952, 0.57531, 1];const Q_F = [0.00182, 0.00025, 0.00019, 0.00014, 0.00011, 0.00008, 0.00007, 0.00007, 0.00006, 0.00006, 0.00006, 0.00007, 0.00009, 0.00011, 0.00014, 0.00017, 0.0002, 0.00022, 0.00025, 0.00026, 0.00028, 0.00029, 0.0003, 0.0003, 0.00029, 0.00029, 0.0003, 0.0003, 0.0003, 0.0003, 0.0003, 0.00031, 0.00032, 0.00034, 0.00037, 0.00041, 0.00045, 0.00048, 0.0005, 0.00053, 0.00057, 0.00061, 0.00066, 0.00072, 0.00079, 0.00087, 0.00096, 0.00106, 0.00116, 0.00128, 0.00141, 0.00155, 0.00169, 0.00182, 0.00196, 0.00209, 0.00224, 0.0024, 0.00256, 0.00276, 0.00297, 0.00318, 0.00343, 0.00374, 0.00409, 0.00446, 0.00484, 0.00524, 0.00571, 0.00631, 0.00704, 0.00785, 0.00874, 0.00975, 0.01094, 0.01224, 0.01366, 0.01536, 0.01743, 0.01994, 0.02294, 0.02633, 0.03007, 0.03448, 0.03973, 0.04602, 0.0533, 0.06158, 0.0712, 0.08239, 0.0952, 0.10915, 0.12444, 0.14014, 0.15761, 0.18355, 0.20866, 0.23588, 0.26528, 0.29688, 0.33068, 0.36663, 0.40463, 0.44451, 0.48604, 1];// 母の年齢 5 歳ごとの合計特殊出生率の内訳(2024 年。15〜19 歳から 45〜49 歳)。足すと 1.1475const ASFR5 = [0.0082, 0.0764, 0.3064, 0.4369, 0.2566, 0.0608, 0.0022];const TFR24 = ASFR5.reduce((a, b) => a + b, 0);// 2024 年に生まれた子のうち男の子の割合(男 351,451 人・女 334,722 人。女 100 人に男 105.0 人)const BOY = 351451 / (351451 + 334722);// 市区町村の境を越えて移った人(2024 年。都道府県の間と、都道府県の中の合計)と、全国の人口(2024 年 10 月 1 日・千人)。5 歳ごと、最後は 85 歳以上const MOVERS5 = [250444, 114934, 66446, 254207, 1003802, 1103688, 685569, 412321, 277003, 224257, 208506, 157926, 108568, 75274, 65236, 58717, 54916, 85920];const POP5 = [3934, 4710, 5185, 5457, 6260, 6519, 6395, 6871, 7631, 8745, 9788, 8490, 7571, 7268, 8198, 7886, 6138, 6754];// 秋田県の人口(2024 年 10 月 1 日・千人)。5 歳ごと、最後は 85 歳以上const AKITA_M = [10, 14, 17, 18, 16, 15, 17, 21, 26, 31, 31, 29, 32, 35, 39, 31, 21, 22];const AKITA_F = [10, 13, 16, 17, 13, 13, 16, 20, 25, 29, 31, 30, 34, 38, 44, 38, 33, 53];// 全国の各歳の人口(2024 年 10 月 1 日・千人。最後は 100 歳以上)。5 歳の中の形に使う(58 歳の丙午、78・79 歳の終戦の前後のへこみが入る)const NAT_M = [367, 388, 409, 424, 427, 446, 469, 481, 502, 515, 515, 526, 528, 542, 546, 549, 559, 557, 558, 576, 611, 623, 651, 670, 676, 666, 676, 671, 669, 674, 670, 653, 656, 649, 660, 669, 688, 704, 713, 735, 758, 767, 766, 773, 806, 826, 855, 878, 918, 954, 1008, 1021, 1003, 971, 939, 921, 897, 891, 691, 851, 794, 771, 743, 723, 722, 731, 703, 678, 706, 721, 712, 743, 772, 804, 842, 905, 873, 808, 483, 496, 582, 541, 530, 487, 414, 333, 323, 300, 267, 220, 177, 148, 119, 91, 67, 50, 36, 22, 14, 8, 11];const NAT_F = [350, 369, 388, 404, 409, 426, 447, 459, 477, 490, 488, 502, 504, 515, 519, 522, 532, 528, 531, 545, 577, 584, 610, 627, 631, 629, 636, 633, 630, 635, 630, 614, 619, 616, 628, 639, 656, 674, 683, 710, 734, 745, 746, 751, 784, 803, 833, 854, 893, 930, 982, 1002, 981, 955, 925, 912, 890, 887, 695, 855, 800, 780, 754, 739, 745, 757, 736, 715, 750, 771, 770, 817, 861, 906, 971, 1056, 1038, 975, 605, 645, 778, 738, 743, 704, 621, 520, 533, 521, 491, 437, 381, 346, 300, 254, 206, 168, 135, 90, 66, 44, 77]; /** 1 年に子を生む確率の年齢の形(合計特殊出生率 1 あたり)。5 歳ごとの内訳を、その中の各歳に均等に分ける */const FERT = new Float32Array(AMAX + 1);for (let a = 15; a <= 49; a++) FERT[a] = ASFR5[Math.floor((a - 15) / 5)] / 5 / TFR24;/** 1 年に町の外へ移る確率の年齢の形 */const MOVE = new Float32Array(AMAX + 1);for (let a = 0; a <= AMAX; a++) { const g = Math.min(17, Math.floor(a / 5)); MOVE[a] = MOVERS5[g] / (POP5[g] * 1000); }// 15〜19 歳の移動は進学と就職の 18・19 歳に寄せる(15〜17 歳は、家族と動く 10〜14 歳と同じ割合)MOVE[15] = MOVE[16] = MOVE[17] = MOVE[10];MOVE[18] = MOVE[19] = (5 * MOVE[19] - 3 * MOVE[10]) / 2;/** 生まれた女の子が、母になる年齢まで生き残る割合(出生率の重みで平均)。人口置換水準 = 1 ÷ (女の子の割合 × この割合) */const RHO = (() => { let l = 1, s = 0; for (let a = 0; a < 50; a++) { s += FERT[a] * l * (1 - Q_F[a] / 2); l *= 1 - Q_F[a]; } return s; })();const REPL = 1 / ((1 - BOY) * RHO);
町の外へ移る確率の形MOVEは、2024年に市区町村の境を越えて移った人の数を、同じ年齢の全国の人口で割ったものである。20〜24歳では1年に16%が移り、70歳代では1%に届かない。15〜19歳の分は、進学と就職で動く18・19歳に寄せた。最後のREPLは人口置換水準で、本文の式のとおり、女の子の割合と母になる年まで生き残る割合から計算すると2.07になる。冒頭の定数のうち、働く人の割合や転入の重み、学校が閉じる人数は、統計ではなく、この画面で決めた値である。
最初の町をつくる
秋田県の2024年の人口を男女・5歳ごとに6,000人へ縮め、5歳の中は全国の各歳の人数の形で分ける。人口ピラミッドで58歳(1966年生まれ)と78・79歳(1945・46年生まれ)の段が短いのは、全国の人口にあるへこみをそのまま持ち込んでいるからだ。
町を作る(src/sims/town-population.ts の 109〜279 行)const roadY = (x: number) => 2.36 + 0.07 * Math.sin(x * 0.9 + 0.3); // 東西の国道const roadX = (y: number) => 3.48 + 0.05 * Math.sin(y * 1.7); // 南北の県道const railY = (x: number) => 3.98 - 0.16 * Math.sin((x + 0.6) * 0.42); // 線路(東の端の先に市がある)const riverY = (x: number) => 1.08 + 0.13 * Math.sin(1.25 * x + 0.4) + 0.025 * x;/** 山と丘の高さ(0〜1)。等高線と、家を建てられない所を決める */function hill(x: number, y: number) { const n = (0.82 - y + 0.12 * Math.sin(x * 1.7) + 0.07 * Math.sin(x * 4.3 + 1)) / 0.82; const se = 1 - Math.hypot((x - 7.4) / 1.2, (y - 4.7) / 0.8); const w = 1 - Math.hypot((x + 0.35) / 0.6, (y - 3.0) / 0.7); return Math.max(n, se * 0.85, w * 0.7);}/** 家を建てられる所か(山・川・線路・広い道の上には建てない) */function buildable(x: number, y: number) { return x > 0.06 && x < W_KM - 0.06 && y > 0.06 && y < H_KM - 0.06 && hill(x, y) < 0.06 && Math.abs(y - riverY(x)) > 0.09 && Math.abs(y - railY(x)) > 0.09 && Math.abs(y - roadY(x)) > 0.04 && Math.abs(x - roadX(y)) > 0.04;}const ZONES0: [string, number, number, number, number][] = [ ["本町", 2.75, 2.02, 1.55, 0.72], ["新町", 3.0, 2.9, 1.5, 0.6], ["東団地", 5.0, 1.62, 0.85, 0.62], ["西町", 0.95, 2.15, 1.2, 0.6], ["北町", 2.2, 1.38, 1.15, 0.42], ["東町", 4.6, 2.46, 1.7, 0.38], ["南西", 1.6, 3.15, 1.0, 0.45], ["北東", 6.0, 1.62, 0.9, 0.42], ["集落", 0.45, 1.5, 0.36, 0.2], ["集落", 1.2, 1.5, 0.3, 0.2], ["集落", 4.1, 1.42, 0.34, 0.2], ["集落", 6.7, 2.75, 0.3, 0.3], ["集落", 0.55, 3.55, 0.3, 0.2], ["集落", 2.8, 3.55, 0.3, 0.2], ["集落", 6.25, 3.2, 0.36, 0.2], ["集落", 4.65, 3.45, 0.3, 0.2], ["南町", 5.3, 2.95, 0.85, 0.45],];let CONTOURS: Float32Array | null = null, PADDY: Float32Array | null = null, FARMS: Float32Array | null = null;/** 等高線(マーチング・スクエア法: 格子の 4 つの角の高さから、同じ高さの点を結ぶ線分を作る) */function contours() { const st = 0.05, nx = Math.ceil(W_KM / st) + 1, ny = Math.ceil(H_KM / st) + 1, g = new Float32Array(nx * ny), out: number[] = []; for (let j = 0; j < ny; j++) for (let i = 0; i < nx; i++) g[j * nx + i] = hill(i * st, j * st); for (const lv of [0.12, 0.27, 0.42, 0.57, 0.72, 0.87]) for (let j = 0; j < ny - 1; j++) for (let i = 0; i < nx - 1; i++) { const a = g[j * nx + i], b = g[j * nx + i + 1], c = g[(j + 1) * nx + i + 1], d = g[(j + 1) * nx + i], x = i * st, y = j * st, p: number[] = []; const e = (v0: number, v1: number, x0: number, y0: number, x1: number, y1: number) => { if ((v0 > lv) !== (v1 > lv)) { const t = (lv - v0) / (v1 - v0); p.push(x0 + (x1 - x0) * t, y0 + (y1 - y0) * t); } }; e(a, b, x, y, x + st, y); e(b, c, x + st, y, x + st, y + st); e(c, d, x + st, y + st, x, y + st); e(d, a, x, y + st, x, y); if (p.length >= 4) for (let t = 0; t < Math.min(8, p.length); t++) out.push(p[t]); } return new Float32Array(out);}function terrain() { if (CONTOURS) return; CONTOURS = contours(); // 田の記号の位置(家の無い平らな所)と、田畑で働く人の行き先 const p: number[] = []; for (let j = 0; j * 0.15 < H_KM; j++) for (let i = 0; i * 0.17 < W_KM; i++) { const x = 0.08 + i * 0.17 + (j % 2) * 0.085, y = 0.08 + j * 0.15; if (x > W_KM - 0.05 || hill(x, y) > 0.02 || Math.abs(y - riverY(x)) < 0.12 || Math.abs(y - railY(x)) < 0.1 || Math.abs(y - roadY(x)) < 0.07 || Math.abs(x - roadX(y)) < 0.07) continue; if (ZONES0.some(([, zx, zy, zw, zh]) => x > zx - 0.06 && x < zx + zw + 0.06 && y > zy - 0.06 && y < zy + zh + 0.06)) continue; p.push(x, y); } PADDY = new Float32Array(p); const f: number[] = []; for (let k = 0; k < p.length; k += 2 * 13) f.push(p[k], p[k + 1]); FARMS = new Float32Array(f);}function addFac(s: S, kind: Kind, name: string, x: number, y: number, user: boolean, zone: Zone | null = null): Fac { const f: Fac = { id: s.nextId++, kind, name, x, y, open: true, low: 0, users: 0, filled: 0, user, zone }; s.facs.push(f); return f;}const nearFac = (s: S, x: number, y: number) => s.facs.some((f) => f.kind !== "zone" && f.kind !== "station" && Math.abs(f.x - x) < FOOT[f.kind] + 0.02 && Math.abs(f.y - y) < FOOT[f.kind] * 0.8 + 0.02);/** 住宅地: 長方形の中に区画を並べる。4 行目ごとと 12 列目ごとを道にする */function makeZone(s: S, name: string, x: number, y: number, w: number, h: number, user: boolean): Zone { const z: Zone = { name, x, y, w, h, user, streets: [], lots: [] }; const ni = Math.floor(w / SP), nj = Math.floor(h / SP); for (let j = 0; j < nj; j++) for (let i = 0; i < ni; i++) { if (j % 4 === 3 || i % 12 === 11 || s.nl >= LOTS) continue; const px = x + (i + 0.5) * SP, py = y + (j + 0.5) * SP; if (!buildable(px, py) || nearFac(s, px, py)) continue; if (user) { let hit = false; for (let l = 0; l < s.nl && !hit; l++) if (s.lst[l] !== 2 && Math.abs(s.lx[l] - px) < SP * 0.9 && Math.abs(s.ly[l] - py) < SP * 0.9) hit = true; if (hit) continue; } const l = s.nl++; s.lx[l] = px; s.ly[l] = py; s.lst[l] = 0; s.locc[l] = 0; s.lvac[l] = 0; z.lots.push(l); } for (let j = 3; j < nj; j += 4) z.streets.push(x, y + (j + 0.5) * SP, x + ni * SP, y + (j + 0.5) * SP); for (let i = 11; i < ni; i += 12) z.streets.push(x + (i + 0.5) * SP, y, x + (i + 0.5) * SP, y + nj * SP); s.zones.push(z); return z;}function addAgent(s: S, age: number, sex: number, house: number) { if (s.n >= CAP) return -1; const i = s.n++; s.age[i] = age; s.sex[i] = sex; s.house[i] = house; s.sd[i] = hash(Math.imul(s.nextSd++, 2654435761)); s.dk[i] = 0; s.gone[i] = 0; s.cw[i] = 0; return i;}/** 最初の住民と世帯: 子を母に、母に夫、残りの大人を夫婦かひとりに、若い人の一部は親の家に、高齢でひとりの人の一部は子の家族の家に */function populate(s: S, houses: number) { const r = s.rnd, k = N0 / (AKITA_M.reduce((a, b) => a + b, 0) + AKITA_F.reduce((a, b) => a + b, 0)); const ages: number[] = [], sexes: number[] = []; for (let sx = 0; sx < 2; sx++) { const AK = sx ? AKITA_F : AKITA_M, NAT = sx ? NAT_F : NAT_M; for (let g = 0; g < 18; g++) { const a0 = g * 5, a1 = g < 17 ? a0 + 4 : 100; // 5 歳ごとの人数を決めてから、各歳に全国の形で分ける(端数は大きい順に 1 人ずつ足す) let ws = 0; for (let a = a0; a <= a1; a++) ws += NAT[a]; const T = Math.round(AK[g] * k), es: number[] = [], cs: number[] = []; for (let a = a0; a <= a1; a++) { const e = (T * NAT[a]) / ws; es.push(e - Math.floor(e)); cs.push(Math.floor(e)); } let rest = T - cs.reduce((x, y) => x + y, 0); for (const t of [...es.keys()].sort((x, y) => es[y] - es[x])) { if (rest <= 0) break; cs[t]++; rest--; } cs.forEach((c, t) => { for (let m = 0; m < c; m++) { ages.push(a0 + t === 100 ? 100 + Math.floor(r() * 4) : a0 + t); sexes.push(sx); } }); } } const P = ages.length, hh = new Int32Array(P).fill(-1), groups: number[][] = [], kids = new Int32Array(P), isMom = new Uint8Array(P); const byAge: number[][][] = [[], []]; for (let sx = 0; sx < 2; sx++) for (let a = 0; a <= AMAX; a++) byAge[sx][a] = []; for (let p = 0; p < P; p++) byAge[sexes[p]][ages[p]].push(p); const join = (p: number, g: number) => { hh[p] = g; groups[g].push(p); }; const newG = (p: number) => { groups.push([p]); hh[p] = groups.length - 1; return groups.length - 1; }; const find = (sx: number, a: number, spread: number, ok: (p: number) => boolean) => { for (let d = 0; d <= spread; d++) for (const sg of d ? [1, -1] : [1]) { const aa = a + d * sg; if (aa < 0 || aa > AMAX) continue; const L = byAge[sx][aa]; if (!L.length) continue; const st = Math.floor(r() * L.length); for (let t = 0; t < L.length; t++) { const p = L[(st + t) % L.length]; if (ok(p)) return p; } } return -1; }; const free = (p: number) => hh[p] < 0; const idx = shuffle([...Array(P).keys()], r); for (const c of idx) { if (ages[c] >= 18) continue; const mo = find(1, ages[c] + birthAge(r), 3, (p) => ages[p] >= 18 && kids[p] < 4 && (hh[p] < 0 || isMom[p] === 1)); if (mo < 0) continue; if (hh[mo] < 0) { newG(mo); isMom[mo] = 1; } join(c, hh[mo]); kids[mo]++; } for (let g = 0; g < groups.length; g++) { const mo = groups[g][0]; if (r() < 0.88) { const f = find(0, ages[mo] + 2, 4, free); if (f >= 0) join(f, g); } } for (const p of idx) { if (!free(p) || sexes[p] !== 0 || ages[p] < 35) continue; if (r() < 0.78) { const w = find(1, ages[p] - 2, 4, free); if (w >= 0) { const g = newG(p); join(w, g); } } } for (const p of idx) { if (!free(p) || ages[p] < 18 || ages[p] > 34) continue; if (r() < (ages[p] < 25 ? 0.8 : 0.55)) { const m = find(1, ages[p] + 30, 8, (q) => hh[q] >= 0); if (m >= 0) { join(p, hh[m]); continue; } } if (ages[p] >= 25 && sexes[p] === 0 && r() < 0.5) { const w = find(1, ages[p] - 2, 3, free); if (w >= 0) { const g = newG(p); join(w, g); continue; } } } for (const p of idx) { if (!free(p) || ages[p] < 72) continue; if (r() < 0.4) { const m = find(1, ages[p] - 30, 6, (q) => hh[q] >= 0 && groups[hh[q]].length < 6); if (m >= 0) join(p, hh[m]); } } const fams = groups.map((_, g) => g).filter((g) => isMom[groups[g][0]] === 1); for (const p of idx) if (free(p)) { if (ages[p] < 18 && fams.length) join(p, fams[Math.floor(r() * fams.length)]); else newG(p); } // 家を建てる区画を選び、世帯を家に入れる(家が足りなければ、ほかの世帯と同じ家に住む) const all = shuffle([...Array(s.nl).keys()], r), built = all.slice(0, Math.min(houses, s.nl)); for (const l of built) { s.lst[l] = 1; s.lvac[l] = 1 + Math.floor(r() * 14); } const order = shuffle([...Array(groups.length).keys()], r); order.forEach((g, t) => { const l = built[t < built.length ? t : Math.floor(r() * built.length)]; for (const p of groups[g]) addAgent(s, ages[p], sexes[p], l); });}function initState(api: Api): S { terrain(); const s: S = { n: 0, age: new Uint8Array(CAP), sex: new Uint8Array(CAP), house: new Int32Array(CAP), sd: new Uint32Array(CAP), dk: new Uint8Array(CAP), dx: new Float32Array(CAP), dy: new Float32Array(CAP), gone: new Uint8Array(CAP), nl: 0, lx: new Float32Array(LOTS), ly: new Float32Array(LOTS), lst: new Uint8Array(LOTS), locc: new Uint16Array(LOTS), lvac: new Uint16Array(LOTS), near: new Uint8Array(LOTS), dcn: new Uint8Array(LOTS), hs: new Int32Array(LOTS + 1), hf: new Int32Array(LOTS), hl: new Int32Array(CAP), zones: [], facs: [], nextId: 1, nextSd: 1, year: START, rnd: api.rng(20251001), step: stepper(1, 4), hist: { pop: new Float32Array(401), old: new Float32Array(401), n: 0 }, pyr0: new Float32Array(2 * (AMAX + 1)), pyr: new Float32Array(2 * (AMAX + 1)), cur: { births: 0, deaths: 0, ins: 0, outs: 0, rejected: 0, done: false }, extraOut: 0, job: { LF: 0, J: 0, u: 0, open: 0, openF: 0, city: 0 }, st: { pop: 0, old: 0, young: 0, houses: 0, vac: 0 }, phase: 0.2, drag: null, sel: null, fired: {}, notes: [], log: [], noteT: -99, lay: null, cw: new Uint8Array(CAP), }; addFac(s, "es", "北小", 2.0, 1.92, false); addFac(s, "es", "南小", 2.78, 3.0, false); addFac(s, "es", "東小", 4.72, 1.98, false); addFac(s, "jhs", "中学校", 4.0, 1.8, false); addFac(s, "hall", "役場", 3.66, 2.17, false); addFac(s, "hospital", "病院", 4.08, 2.6, false); addFac(s, "daycare", "保育所", 3.08, 2.58, false); for (const [name, x, y, w, h] of ZONES0) makeZone(s, name, x, y, w, h, false); populate(s, api.num("houses")); countSchools(s); refresh(api, s); s.pyr0.set(s.pyr); record(s); return s;}
住民がそろったら世帯にまとめる。18歳未満の子は、2024年の出生率の形で選んだ年齢の母に付け、母には夫を、残りの大人は夫婦かひとりにする。若い人の一部は親の家に、ひとりで暮らす高齢の人の一部は子の家族の家に入れる。世帯の数は3,300戸の家より少なく、入る世帯の無かった家が最初の空き家(約12%)になる。
1年を進める
yearStepは住民を1人ずつ見て、乱数が生命表の死亡率より小さければ亡くなったことにし、女性なら同じやり方で出生を決め、残った人の年齢に1を足す。本文の式Pt+1=Pt+Bt−Dt+It−OtのBtとDtが、コードのbirthsとdeathsにあたり、ItとOtは、次の章のmigrateが返すinsとoutsにあたる。全員を1回ずつ見るだけなので、計算の量は住民の数に比例する。
1年の計算(src/sims/town-population.ts の 281〜389 行)/** 家ごとの住民の一覧(区画の番号で並べ替えた住民の番号) */function buildHH(s: S) { const hs = s.hs; hs.fill(0, 0, s.nl + 1); for (let i = 0; i < s.n; i++) hs[s.house[i] + 1]++; for (let l = 0; l < s.nl; l++) { hs[l + 1] += hs[l]; s.hf[l] = hs[l]; } for (let i = 0; i < s.n; i++) s.hl[s.hf[s.house[i]]++] = i; for (let l = 0; l < s.nl; l++) s.locc[l] = hs[l + 1] - hs[l];}function compact(s: S) { let j = 0; for (let i = 0; i < s.n; i++) { if (s.gone[i]) continue; if (i !== j) { s.age[j] = s.age[i]; s.sex[j] = s.sex[i]; s.house[j] = s.house[i]; s.sd[j] = s.sd[i]; s.dk[j] = s.dk[i]; s.dx[j] = s.dx[i]; s.dy[j] = s.dy[i]; s.cw[j] = s.cw[i]; } s.gone[j] = 0; j++; } s.n = j;}function yearStep(api: Api, s: S) { const r = s.rnd, tfr = api.num("tfr"); let births = 0, deaths = 0; const babies: number[] = []; // 死亡(生命表の死亡率)・出生(年齢ごとの出生率 × 合計特殊出生率)・年を 1 つ取る for (let i = 0; i < s.n; i++) { const a = s.age[i], q = (s.sex[i] ? Q_F : Q_M)[Math.min(a, AMAX)]; if (r() < q) { s.gone[i] = 1; deaths++; continue; } if (s.sex[i] && FERT[a] > 0 && r() < FERT[a] * tfr) { babies.push(s.house[i], r() < BOY ? 0 : 1); births++; } s.age[i] = Math.min(AMAX, a + 1); } compact(s); for (let k = 0; k < babies.length; k += 2) addAgent(s, 0, babies[k + 1], babies[k]); assign(api, s); const m = api.bool("move") ? migrate(api, s) : { ins: 0, outs: 0, rejected: 0 }; // 大人がいなくなった家の子は、親類の所へ移る buildHH(s); let orphans = 0; for (let l = 0; l < s.nl; l++) { const a = s.hs[l], b = s.hs[l + 1]; if (a === b) continue; let adult = false; for (let t = a; t < b; t++) if (s.age[s.hl[t]] >= 18) adult = true; if (!adult) for (let t = a; t < b; t++) { s.gone[s.hl[t]] = 1; orphans++; } } if (orphans) compact(s); s.year++; buildHH(s); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) s.lvac[l] = s.locc[l] ? 0 : Math.min(999, s.lvac[l] + 1); s.cur = { births, deaths, ins: m.ins, outs: m.outs + orphans + s.extraOut, rejected: m.rejected, done: true }; s.extraOut = 0; schools(api, s); refresh(api, s); record(s); events(api, s);}/** 町の子(6〜11 歳・12〜14 歳)を、家からいちばん近い開いている学校に数える */function countSchools(s: S) { for (const kind of ["es", "jhs"] as const) { const open = s.facs.filter((f) => f.kind === kind && f.open); for (const f of open) f.users = 0; const a0 = kind === "es" ? 6 : 12, a1 = kind === "es" ? 11 : 14; if (open.length) for (let i = 0; i < s.n; i++) { const a = s.age[i]; if (a < a0 || a > a1) continue; const f = nearest(open, s.lx[s.house[i]], s.ly[s.house[i]]); if (f) f.users++; } }}/** 学校を閉じる: 通う子が決まった数を 3 年続けて下回ると閉校(町に 1 つだけ残った学校は、その半分の数で) */function schools(api: Api, s: S) { countSchools(s); for (const kind of ["es", "jhs"] as const) { const open = s.facs.filter((f) => f.kind === kind && f.open), min = kind === "es" ? ES_MIN : JHS_MIN; let worst: Fac | null = null; for (const f of open) { f.low = f.users < (open.length > 1 ? min : Math.ceil(min / 2)) ? f.low + 1 : 0; if (f.low >= LOW_YEARS && (!worst || f.users < worst.users)) worst = f; } if (worst) { worst.open = false; const to = nearest(open.filter((f) => f !== worst), worst.x, worst.y); say(api, s, `close-${worst.id}`, `${worst.name}が閉校しました(通う子ども ${worst.users} 人)。${to ? `これからは${to.name}へ通います` : "子どもはバスで隣の市の学校へ通います"}`, true); } } countSchools(s);}function nearest(list: Fac[], x: number, y: number) { let best: Fac | null = null, bd = Infinity; for (const f of list) { const d = (f.x - x) ** 2 + (f.y - y) ** 2; if (d < bd) { bd = d; best = f; } } return best;}/** 数え直し: 家ごとの人数・行き先と仕事・人口ピラミッド・高齢化率・空き家 */function refresh(api: Api, s: S) { buildHH(s); geo(s); assign(api, s); const P = s.pyr; P.fill(0); let old = 0, young = 0; for (let i = 0; i < s.n; i++) { const a = s.age[i]; P[s.sex[i] * (AMAX + 1) + a]++; if (a >= 65) old++; else if (a < 15) young++; } let houses = 0, vac = 0; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) { houses++; if (!s.locc[l]) vac++; } s.st = { pop: s.n, old: s.n ? old / s.n : 0, young: s.n ? young / s.n : 0, houses, vac };}/** 区画ごとに、駅から 1.5 km の中か・保育所から 3 km の中か */function geo(s: S) { const st = s.facs.filter((f) => f.kind === "station"), dc = s.facs.filter((f) => f.kind === "daycare"); for (let l = 0; l < s.nl; l++) { const x = s.lx[l], y = s.ly[l]; s.near[l] = st.some((f) => (f.x - x) ** 2 + (f.y - y) ** 2 < R_ST * R_ST) ? 1 : 0; s.dcn[l] = dc.some((f) => (f.x - x) ** 2 + (f.y - y) ** 2 < 9) ? 1 : 0; }}function record(s: S) { const k = s.year - START; if (k < 0 || k > 400) return; s.hist.pop[k] = s.st.pop; s.hist.old[k] = s.st.old; s.hist.n = k + 1;}
大人のいなくなった家の子どもは、親類の家へ移ったことにして転出に数える。乱数の種は決めてあるので、同じ場面を同じ操作で動かせば、何度でも同じ町の歴史になる。学校は、通う子どもが3年続けて決まった数を下回ると閉じる。小学校は40人、中学校は30人で、町に1校だけ残ったときはその半分にした。この数は法令や基準ではなく、この画面の決まりである。
町を出る人、町に来る人
転出は2通りに分けた。親と住む18〜29歳は1人ずつ進学や就職で出ていき、それ以外は世帯ごとに引っ越す。確率はMOVEに倍率を掛けたもので、町に仕事の足りない割合をuとすると、50歳未満では1+3u倍、50〜64歳では1+u倍になる。駅から1.5 kmの中に住む人は市の仕事に通えるので、uの効き目を4分の1にしてある。24歳以下は、町に大学や専門学校が無いぶん、さらに1.5倍にした。
転入と転出(src/sims/town-population.ts の 391〜456 行)/** 仕事の足りない割合 u で、転出が何倍になるか。駅の近くの人は市の仕事に通えるので、u の 4 分の 1 だけ効く */const pushOf = (age: number, u: number, near: boolean) => { if (age >= 65) return 1; const uu = near ? u * 0.25 : u; return (age < 50 ? 1 + OUT_JOB * uu : 1 + uu) * (age <= 24 ? YOUTH : 1); };/** 転出: 親と住む 18〜29 歳は 1 人ずつ(進学・就職)、ほかは世帯ごと。転入: 世帯ごとに空き家へ */function migrate(api: Api, s: S) { const r = s.rnd, u = s.job.u, esOpen = s.facs.some((f) => f.kind === "es" && f.open), jhsOpen = s.facs.some((f) => f.kind === "jhs" && f.open); let outs = 0; buildHH(s); for (let l = 0; l < s.nl; l++) { const a = s.hs[l], b = s.hs[l + 1]; if (a === b) continue; let nA = 0, sumA = 0, nY = 0, sumY = 0, kid5 = false, kid14 = false; for (let t = a; t < b; t++) { const g = s.age[s.hl[t]]; if (g < 6) kid5 = true; if (g < 15) kid14 = true; if (g >= 30) { nA++; sumA += g; } else if (g >= 18) { nY++; sumY += g; } } const near = s.near[l] === 1; if (nA) for (let t = a; t < b; t++) { const i = s.hl[t], g = s.age[i]; if (g < 18 || g > 29 || (kid5 && g >= 20)) continue; if (r() < MOVE[g] * pushOf(g, u, near)) { s.gone[i] = 1; outs++; } } const ref = nA ? Math.round(sumA / nA) : nY ? Math.round(sumY / nY) : -1; if (ref < 0) continue; let p = MOVE[ref] * pushOf(ref, u, near); if (kid14 && !esOpen) p *= NO_ES; if (kid14 && !jhsOpen) p *= NO_JHS; if (kid5 && s.dcn[l]) p *= DC_STAY; if (r() < p) for (let t = a; t < b; t++) { const i = s.hl[t]; if (!s.gone[i]) { s.gone[i] = 1; outs++; } } } compact(s); buildHH(s); const vac: number[] = [], vnear: number[] = []; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l]) { vac.push(l); if (s.near[l]) vnear.push(l); } // 1 年に転入する世帯の数の平均: 基準(人口に比例)+ 埋まっていない仕事(工場の仕事は多めに)+ 駅の近くの空き家 const k = api.num("ease"), dc = s.facs.some((f) => f.kind === "daycare"); const lam0 = IN_BASE * s.n * k * (esOpen ? 1 : 0.6) * (dc ? 1.1 : 1) * (1 - Math.min(0.6, 1.5 * u)); const lamJ = (IN_JOB * (s.job.open - s.job.openF) + IN_FAC * s.job.openF) * k, lamS = IN_ST * vnear.length * k; let ins = 0, rejected = 0; const take = (pool: number[]) => { while (pool.length) { const t = Math.floor(r() * pool.length), l = pool[t]; pool[t] = pool[pool.length - 1]; pool.pop(); if (!s.locc[l]) return l; } return -1; }; const arrive = (W: Cum, pool: number[], city = false) => { const mem = newcomers(r, W), l = take(pool); if (l < 0 || s.n + mem.length / 2 > CAP) { rejected++; return; } for (let t = 0; t < mem.length; t += 2) { const i = addAgent(s, mem[t], mem[t + 1], l); if (city && i >= 0 && mem[t] >= 18) s.cw[i] = 1; } s.locc[l] = mem.length / 2; ins += mem.length / 2; }; for (let t = poisson(r, lamS); t > 0; t--) arrive(W_ST, vnear, true); for (let t = poisson(r, lamJ); t > 0; t--) arrive(W_JOB, vac); for (let t = poisson(r, lam0); t > 0; t--) arrive(W_BASE, vac); // 町の中の引っ越し: 親と住む 25〜39 歳の一部が、町の空き家に移って世帯を分ける buildHH(s); for (let l = 0; l < s.nl; l++) { const a = s.hs[l], b = s.hs[l + 1]; if (b - a < 2) continue; let mx = 0; for (let t = a; t < b; t++) mx = Math.max(mx, s.age[s.hl[t]]); for (let t = a; t < b; t++) { const i = s.hl[t], g = s.age[i]; if (g >= 25 && g <= 39 && mx - g >= 18 && r() < 0.03) { const v = take(vac); if (v >= 0) { s.house[i] = v; s.locc[v] = 1; } } } } return { ins, outs, rejected };}/** 転入する世帯: いちばん上の人の年齢を選び、夫婦か、ひとりか、子のいる家族かを決める。[年齢, 性別, …] */function newcomers(r: () => number, W: Cum) { const h = pickAge(r, W), sx = r() < 0.5 ? 0 : 1, out = [h, sx]; const couple = r() < (h < 27 ? 0.3 : h < 60 ? 0.8 : 0.6); if (couple) out.push(clamp(h + (sx ? 2 : -2) + Math.round((r() - 0.5) * 6), 18, 100), 1 - sx); if (couple && h >= 24 && h < 50) { const x = r(), nk = x < 0.3 ? 0 : x < 0.6 ? 1 : x < 0.9 ? 2 : 3; for (let t = 0; t < nk; t++) out.push(Math.floor(r() * Math.min(18, h - 22)), r() < BOY ? 0 : 1); } return out;}/** 町の仕事の数: つまみの値のうち 8 割は、人口に比べて増減する店・病院・農業などの分 */const jobsBase = (api: Api, s: S) => api.num("jobs") * (0.2 + 0.8 * Math.min(2, s.n / N0));
転入は世帯ごとに考える。1年に来る世帯の数の平均は、3つの分の和で決める。人口に比べた分、埋まっていない仕事の分(工場の仕事は多め)、駅の近くの空き家の分である。実際の数は、この平均を持つポアソン分布の乱数で引く。平均が3世帯なら、ある年は1世帯、次の年は5世帯というように、平均のまわりでばらつく。来た世帯は空き家に入り、空き家が無ければ転入できない。世帯のいちばん上の人の年齢は、全国の移動の年齢の形から選ぶ。最後に、親と住む25〜39歳の一部が町の空き家へ移り、世帯を分ける。これらの重みは統計から求めたものではなく、この画面で決めた値である。
朝と夕方の点
住民の行き先は、1年ごとと、地図を触ったときに決め直す。子どもは家からいちばん近い開いている学校へ、1〜5歳の子の一部は保育所へ行く。働く人は、空きのある仕事場のうち家にいちばん近い所へ行く。駅から1.5 kmの中に住む働く人は、一部(駅の近くへ越してきた人は全員)が駅から市へ通い、町に空いた仕事が無いときも市へ通う。
1日の動き(src/sims/town-population.ts の 458〜559 行)/** 住民の行き先と仕事: 0 家にいる・1 小学校・2 中学校・3 保育所・4 職場・5 田畑・6 店・7 市の職場(駅から)・8 町の外(バス)・9 市の高校(駅から) */function assign(api: Api, s: S) { const base = jobsBase(api, s), F = s.facs, FM = FARMS!; type Slot = { x: number; y: number; cap: number; jit: number; dk: number; fac: Fac | null }; const slots: Slot[] = []; for (const f of F) { f.filled = 0; if (f.kind === "daycare" || f.kind === "station") f.users = 0; } const hall = F.find((f) => f.kind === "hall"), hosp = F.find((f) => f.kind === "hospital"); // 町の仕事を、役場・病院・商店街・田畑・住宅地の中の小さな仕事場に分ける if (hall) slots.push({ x: hall.x, y: hall.y, cap: base * 0.12, jit: 0.05, dk: 4, fac: hall }); if (hosp) slots.push({ x: hosp.x, y: hosp.y, cap: base * 0.16, jit: 0.06, dk: 4, fac: hosp }); slots.push({ x: 3.4, y: roadY(3.4), cap: base * 0.22, jit: 0.4, dk: 6, fac: null }); const nfm = FM.length / 2; for (let t = 0; t < nfm; t++) slots.push({ x: FM[2 * t], y: FM[2 * t + 1], cap: (base * 0.3) / nfm, jit: 0.06, dk: 5, fac: null }); const zs = s.zones.filter((z) => !z.user), zl = zs.reduce((a, z) => a + z.lots.length, 0) || 1; for (const z of zs) slots.push({ x: z.x + z.w * 0.5, y: z.y + z.h * 0.5, cap: (base * 0.2 * z.lots.length) / zl, jit: 0.1, dk: 4, fac: null }); for (const f of F) { if (f.kind === "factory") slots.push({ x: f.x, y: f.y, cap: FACTORY_JOBS, jit: 0.12, dk: 4, fac: f }); else if (f.kind === "daycare") slots.push({ x: f.x, y: f.y, cap: DAYCARE_JOBS, jit: 0.03, dk: 4, fac: f }); else if ((f.kind === "es" || f.kind === "jhs") && f.open) slots.push({ x: f.x, y: f.y, cap: SCHOOL_JOBS, jit: 0.05, dk: 4, fac: f }); } const J = slots.reduce((a, b) => a + b.cap, 0); const es = F.filter((f) => f.kind === "es" && f.open), jhs = F.filter((f) => f.kind === "jhs" && f.open), dcs = F.filter((f) => f.kind === "daycare"), sts = F.filter((f) => f.kind === "station"); const ex = W_KM + 0.05, ey = roadY(W_KM); let LF = 0, emp = 0, city = 0; for (let i = 0; i < s.n; i++) { const a = s.age[i], l = s.house[i], hx = s.lx[l], hy = s.ly[l], sd = s.sd[i]; const jx = ((sd >>> 20) & 255) / 255 - 0.5, jy = ((sd >>> 12) & 255) / 255 - 0.5; let dk = 0, tx = hx, ty = hy, jit = 0; const st = nearest(sts, hx, hy), dst = st ? Math.hypot(st.x - hx, st.y - hy) : Infinity; if (st && dst < R_ST) st.users++; if (a <= 5) { let best: Fac | null = null, bd = 16; for (const f of dcs) { const d = (f.x - hx) ** 2 + (f.y - hy) ** 2; if (d < bd && f.users < DAYCARE_CAP) { bd = d; best = f; } } if (best && a >= 1 && ((sd >>> 8) & 255) / 256 < DAYCARE_USE) { best.users++; dk = 3; tx = best.x; ty = best.y; jit = 0.04; } } else if (a <= 14) { const f = nearest(a <= 11 ? es : jhs, hx, hy); if (f) { dk = a <= 11 ? 1 : 2; tx = f.x; ty = f.y; jit = 0.07; } else { dk = 8; tx = ex; ty = ey; } } else if (a <= 17) { if (st && dst < 3) { dk = 9; tx = st.x; ty = st.y; } else { dk = 8; tx = ex; ty = ey; } } else { const w = a <= 64 ? WORK : a <= 74 ? WORK_OLD : 0, hv = (sd & 0xffff) / 65536; if (hv < w) { // 働く人: 駅の近くに住む人の一部(駅の近くへ越してきた人は全員)は市へ通う。ほかは空きのある仕事場のうち家にいちばん近い所。町に無ければ、駅の近くの人は市へ LF++; if (st && dst < R_ST && (s.cw[i] === 1 || ((sd >>> 24) & 255) / 256 < CITY)) { dk = 7; city++; st.filled++; s.dk[i] = dk; s.dx[i] = st.x + jx * 0.02; s.dy[i] = st.y; continue; } let best: Slot | null = null, bd = Infinity; for (const sl of slots) { if (sl.cap < 1) continue; const d = (sl.x - hx) ** 2 + (sl.y - hy) ** 2; if (d < bd) { bd = d; best = sl; } } if (best) { best.cap--; emp++; dk = best.dk === 6 ? 4 : best.dk; tx = best.x; ty = best.y; jit = best.jit; if (best.fac) best.fac.filled++; } else if (st && dst < R_ST) { dk = 7; tx = st.x; ty = st.y; city++; st.filled++; } } else if (a >= 65 && ((sd >>> 16) & 255) < 56) { dk = 6; tx = 3.4 + jx * 0.8; ty = roadY(tx); jit = 0.02; } } s.dk[i] = dk; s.dx[i] = tx + jx * jit * 2; s.dy[i] = ty + jy * jit * 1.4; } const open = slots.reduce((a, b) => a + Math.max(0, Math.floor(b.cap)), 0), openF = slots.reduce((a, b) => a + (b.fac?.kind === "factory" ? Math.max(0, Math.floor(b.cap)) : 0), 0); s.job = { LF, J, u: LF ? Math.max(0, LF - emp - city) / LF : 0, open, openF, city };}/** 家から行き先までの道: 国道をまたぐか遠いときは国道を通り、近いときは東西に行ってから南北に行く */const TMP = new Float32Array(2);function path(hx: number, hy: number, tx: number, ty: number, f: number) { const r1 = roadY(hx), r2 = roadY(tx); if ((hy - r1) * (ty - r2) < 0 || Math.abs(hx - tx) > 1.2) { const a = Math.abs(r1 - hy), b = Math.abs(tx - hx), c = Math.abs(ty - r2), L = a + b + c || 1; let d = f * L; if (d < a) { TMP[0] = hx; TMP[1] = hy + ((r1 - hy) * d) / a; return; } d -= a; if (d < b) { const x = hx + ((tx - hx) * d) / b; TMP[0] = x; TMP[1] = roadY(x); return; } d -= b; TMP[0] = tx; TMP[1] = r2 + (ty - r2) * (c ? d / c : 1); return; } const a = Math.abs(tx - hx), c = Math.abs(ty - hy), L = a + c || 1, d = f * L; if (d < a) { TMP[0] = hx + ((tx - hx) * d) / a; TMP[1] = hy; } else { TMP[0] = tx; TMP[1] = hy + (ty - hy) * (c ? (d - a) / c : 1); }}/** 1 日の中の時刻(0〜1)を時計の時刻に。朝と夕方の通う時間をゆっくり見せる */const PH = [0, 0.08, 0.38, 0.52, 0.86, 1], HR = [5, 7, 9, 15.5, 19.5, 24];function clock(ph: number) { let k = 0; while (k < PH.length - 2 && ph > PH[k + 1]) k++; const h = HR[k] + ((HR[k + 1] - HR[k]) * (ph - PH[k])) / (PH[k + 1] - PH[k]), hh = Math.floor(h) % 24, mm = Math.floor((h - Math.floor(h)) * 6) * 10; return `${hh}:${String(mm).padStart(2, "0")}`;}/** 出かけている人を点で描く。出る時刻と帰る時刻は、1 人ずつの決まった乱数でずらす */function drawPeople(api: Api, s: S, L: Lay) { const ph = s.phase, c = api.ctx, stride = [3, 2, 1][api.quality], r = Math.max(0.55, api.u(0.8)); c.beginPath(); for (let i = 0; i < s.n; i += stride) { const dk = s.dk[i]; if (!dk) continue; const sd = s.sd[i], h1 = (sd & 1023) / 1023, h2 = ((sd >>> 10) & 1023) / 1023; let d0: number, d1: number; if (dk === 1 || dk === 2) { d0 = 0.14 + 0.06 * h1; d1 = 0.5 + 0.08 * h2; } else if (dk === 3) { d0 = 0.12 + 0.08 * h1; d1 = 0.6 + 0.12 * h2; } else if (dk === 6) { d0 = 0.36 + 0.06 * h1; d1 = 0.46 + 0.07 * h2; } else if (dk === 7 || dk === 9) { d0 = 0.07 + 0.1 * h1; d1 = 0.63 + 0.14 * h2; } else { d0 = 0.1 + 0.14 * h1; d1 = 0.58 + 0.16 * h2; } const l = s.house[i], hx = s.lx[l], hy = s.ly[l], tx = s.dx[i], ty = s.dy[i]; const T = 0.05 + 0.05 * Math.min(1, (Math.abs(tx - hx) + Math.abs(ty - hy)) / 3); if (ph < d0 || ph > d1 + T) continue; const f = ph < d0 + T ? (ph - d0) / T : ph < d1 ? 1 : 1 - (ph - d1) / T; if (f >= 1 && dk >= 7) continue; // 市や隣の市に出ている path(hx, hy, tx, ty, f); const X = L.X(TMP[0]), Y = L.Y(TMP[1]); c.rect(X - r, Y - r, 2 * r, 2 * r); } c.fillStyle = api.ink; c.fill();}
出かける時刻と帰る時刻は、住民ごとに決まった乱数でずらしてある。道は、国道をまたぐときや遠くへ行くときは国道を通る折れ線にした。1日を10秒で見せる絵なので、年の進み方とは別に動く。
地図を触る
何もない所をたたくと、選んだ施設を置く。工場と保育所は家と重ならないいちばん近い場所を探し、駅は線路の上に置く。住宅地を置くと、その区画にすべて家が建ち、最初は空き家になる。施設はドラッグで動かせ、指を動かさずに0.65秒押し続けると取り除く。
操作(src/sims/town-population.ts の 588〜737 行)/** 地図の点から、そこにある施設を探す(画面の上の距離) */function hit(api: Api, s: S, L: Lay, px: number, py: number): Fac | null { let best: Fac | null = null, bd = api.u(15); for (const f of s.facs) { if (f.kind === "zone") continue; const d = Math.hypot(L.X(f.x) - px, L.Y(f.y) - py); if (d < bd) { bd = d; best = f; } } if (best) return best; for (const f of s.facs) if (f.kind === "zone" && f.zone) { const z = f.zone; if (px > L.X(z.x) && px < L.X(z.x + z.w) && py > L.Y(z.y) && py < L.Y(z.y + z.h)) return f; } return null;}function spotOK(s: S, x: number, y: number, R: number, self: Fac | null) { if (x < R || x > W_KM - R || y < R || y > H_KM - R) return false; if (hill(x, y) > 0.05 || Math.abs(y - riverY(x)) < R * 0.7 + 0.06 || Math.abs(y - railY(x)) < R * 0.7 + 0.07) return false; for (const f of s.facs) if (f !== self && f.kind !== "zone" && f.kind !== "station" && Math.abs(f.x - x) < R + FOOT[f.kind] && Math.abs(f.y - y) < (R + FOOT[f.kind]) * 0.75) return false; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && Math.abs(s.lx[l] - x) < R && Math.abs(s.ly[l] - y) < R * 0.75) return false; return true;}/** 家や施設と重ならない、いちばん近い場所 */function freeSpot(s: S, kind: Kind, x: number, y: number, self: Fac | null = null): [number, number] | null { const R = FOOT[kind]; for (let rr = 0; rr <= 1.5; rr += 0.05) { const n = rr === 0 ? 1 : Math.ceil((TAU * rr) / 0.06); for (let t = 0; t < n; t++) { const a = (t / n) * TAU, px = x + rr * Math.cos(a), py = y + rr * Math.sin(a); if (spotOK(s, px, py, R, self)) return [px, py]; } } return null;}/** 家を取り除く: 住んでいた世帯は町の空き家へ移り、無ければ町を出る */function evict(s: S, lots: Set<number>) { buildHH(s); const vac: number[] = []; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l] && !lots.has(l)) vac.push(l); for (const l of lots) { const a = s.hs[l], b = s.hs[l + 1]; if (a < b) { const v = vac.length ? vac.splice(Math.floor(s.rnd() * vac.length), 1)[0] : -1; for (let t = a; t < b; t++) { const i = s.hl[t]; if (v >= 0) s.house[i] = v; else { s.gone[i] = 1; s.extraOut++; } } } s.lst[l] = 2; } compact(s);}function setHouses(api: Api, s: S, target: number) { buildHH(s); let have = 0; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) have++; const cx = 3.5, cy = 2.4, d = (l: number) => (s.lx[l] - cx) ** 2 + (s.ly[l] - cy) ** 2 - (s.near[l] ? 4 : 0); if (target > have) { // 町の中心と駅に近い空き区画から建てる const empty: number[] = []; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 0 && !nearFac(s, s.lx[l], s.ly[l])) empty.push(l); empty.sort((a, b) => d(a) - d(b)); for (const l of empty) { if (have >= target) break; s.lst[l] = 1; s.lvac[l] = 0; have++; } if (have < target) say(api, s, `lots-${s.year}`, "家を建てる区画が足りません。地図に住宅地を置くと、区画が増えます", true); } else if (target < have) { // 中心から遠い空き家から壊し、足りなければ人の住む家も壊す const vac: number[] = [], occ: number[] = []; for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1) (s.locc[l] ? occ : vac).push(l); vac.sort((a, b) => d(b) - d(a)); occ.sort((a, b) => d(b) - d(a)); const gone = new Set<number>(); for (const l of [...vac, ...occ]) { if (have <= target) break; gone.add(l); have--; } const moved = occ.filter((l) => gone.has(l)).length; evict(s, gone); for (const l of gone) s.lst[l] = 0; if (moved) say(api, s, `evict-${s.year}-${target}`, `人の住む家を ${moved} 戸取り壊しました。移る空き家が無い世帯は町を出ます`, true); } if (Math.round(api.num("houses")) !== have) api.set("houses", have); refresh(api, s);}const LIMIT: Partial<Record<Kind, number>> = { factory: 4, daycare: 4, station: 3, zone: 8 };function place(api: Api, s: S, tool: string, x: number, y: number) { if (tool === "remove") { api.note("施設をたたくと取り除けます(長押しでも取り除けます)"); return; } const kind = tool as Kind, n = s.facs.filter((f) => f.kind === kind && (kind !== "zone" || f.user)).length; if (n >= (LIMIT[kind] ?? 4)) { api.note(`${NAME[kind]}は ${LIMIT[kind]} つまで置けます`); return; } let f: Fac | null = null; if (kind === "station") { const sx = clamp(x, 0.3, W_KM - 0.3); if (s.facs.some((o) => o.kind === "station" && Math.abs(o.x - sx) < 1)) { api.note("駅と駅は 1 km 以上離して置けます"); return; } f = addFac(s, "station", "駅", sx, railY(sx), true); api.note("駅を置きました。駅から 1.5 km の中に住む人は、市の仕事に通えます", 2600); } else if (kind === "zone") { const z = makeZone(s, "住宅地", clamp(x - 0.3, 0.05, W_KM - 0.65), clamp(y - 0.21, 0.05, H_KM - 0.47), 0.6, 0.42, true); if (z.lots.length < 12) { for (const l of z.lots) s.lst[l] = 2; s.zones.pop(); api.note("ここには家を建てられません(山・川・線路・ほかの家の上)"); return; } for (const l of z.lots) { s.lst[l] = 1; s.lvac[l] = 0; } f = addFac(s, "zone", "住宅地", z.x + z.w / 2, z.y + z.h / 2, true, z); api.note(`住宅地に家を ${z.lots.length} 戸建てました。人が来るまでは空き家です`, 2600); } else { const p = freeSpot(s, kind, x, y); if (!p) { api.note("近くに、家と重ならない場所がありません"); return; } f = addFac(s, kind, NAME[kind], p[0], p[1], true); api.note(kind === "factory" ? `工場を置きました。${FACTORY_JOBS} 人分の仕事が増えます` : `保育所を置きました。1〜5 歳の子を ${DAYCARE_CAP} 人まで預かれます`, 2600); } s.sel = f; refresh(api, s); if (kind === "zone") api.set("houses", s.st.houses); api.emit("place", { kind });}const removable = (f: Fac) => f.kind === "factory" || f.kind === "daycare" || f.kind === "station" || (f.kind === "zone" && f.user);function removeFac(api: Api, s: S, f: Fac) { if (!removable(f)) { api.note(f.kind === "es" || f.kind === "jhs" ? "学校は、通う子どもの数で開くか閉じるかが決まります(動かすことはできます)" : `${f.name}は取り除けません`); return; } if (f.kind === "zone" && f.zone) { const z = f.zone; evict(s, new Set(z.lots)); s.zones.splice(s.zones.indexOf(z), 1); } s.facs.splice(s.facs.indexOf(f), 1); if (s.sel === f) s.sel = null; api.note(`${f.kind === "zone" ? "住宅地" : f.name}を取り除きました`); refresh(api, s); if (f.kind === "zone") api.set("houses", s.st.houses); api.emit("remove", { kind: f.kind });}function moveFac(api: Api, s: S, d: Drag, x: number, y: number, drop: boolean) { const f = d.fac!; if (f.kind === "hall" || f.kind === "hospital") return; if (f.kind === "station") { f.x = clamp(x, 0.3, W_KM - 0.3); f.y = railY(f.x); } else if (f.kind === "zone" && f.zone) { const z = f.zone, nx = clamp(x, z.w / 2 + 0.05, W_KM - z.w / 2 - 0.05), ny = clamp(y, z.h / 2 + 0.05, H_KM - z.h / 2 - 0.05), ddx = nx - f.x, ddy = ny - f.y; for (const l of z.lots) { s.lx[l] += ddx; s.ly[l] += ddy; } for (let t = 0; t < z.streets.length; t += 2) { z.streets[t] += ddx; z.streets[t + 1] += ddy; } z.x += ddx; z.y += ddy; f.x = nx; f.y = ny; } else { f.x = clamp(x, 0.1, W_KM - 0.1); f.y = clamp(y, 0.1, H_KM - 0.1); } if (!drop) return; if (f.kind !== "station" && f.kind !== "zone") { const p = freeSpot(s, f.kind, f.x, f.y, f); if (p) { f.x = p[0]; f.y = p[1]; } else { f.x = d.sx; f.y = d.sy; api.note("家と重ならない場所に置けます"); } } refresh(api, s);}/** 指の操作: 何もない所をたたくと置く・施設をドラッグすると動かす・長押しか「取り除く」でたたくと取り除く・たたくと選ぶ */function onPointer(type: "down" | "move" | "up" | "cancel" | "wheel", api: Api, s: S) { const L = s.lay; if (!L) return; const P = api.pointer, wx = (P.x - L.mx) / L.k, wy = (P.y - L.my) / L.k; if (type === "down") { if (P.x < L.mx || P.x > L.mx + L.mw || P.y < L.my || P.y > L.my + L.mh) { s.drag = null; return; } const f = hit(api, s, L, P.x, P.y); s.drag = { fac: f, x0: P.x, y0: P.y, t0: performance.now(), moved: false, ox: f ? f.x - wx : 0, oy: f ? f.y - wy : 0, sx: f ? f.x : 0, sy: f ? f.y : 0, done: false }; return; } const d = s.drag; if (!d) return; if (type === "move") { if (!d.moved && Math.hypot(P.x - d.x0, P.y - d.y0) > api.u(6)) d.moved = true; if (d.fac && d.moved && !d.done) moveFac(api, s, d, wx + d.ox, wy + d.oy, false); return; } s.drag = null; if (type !== "up" || d.done) return; if (d.fac && d.moved) { moveFac(api, s, d, wx + d.ox, wy + d.oy, true); s.sel = d.fac; return; } if (d.fac) { if (api.str("tool") === "remove" || performance.now() - d.t0 > 650) removeFac(api, s, d.fac); else s.sel = s.sel === d.fac ? null : d.fac; return; } if (!d.moved) { s.sel = null; place(api, s, api.str("tool"), wx, wy); }}
描く
色は白と黒の2色だけで、濃淡は線の太さと点の数で表す。人口ピラミッドの細い線は2025年の形で、棒の外では文字の色、棒の中では地の色で描き、どちらに重なっても見えるようにした。家の四角が小さくしか描けない画面では、空き家を小さな点にして、人の住む家との違いを大きさで見せる。駅があると、朝は東の市へ向かう電車、夕方は市から戻る電車が線路を走る。地図の学校の「文」と、田んぼに並ぶ「‖」は、地図記号の形を借りている。
描画(src/sims/town-population.ts の 739〜995 行)/** 配置: 横長なら地図を左に、2 つのグラフを右に縦に並べる。縦長なら地図を上に、グラフを下に。右下の角(170×50 px)は画面のボタンのために空ける */function layout(api: Api): Lay { const W = api.w, H = api.h, u = api.u, m = u(12), asp = H_KM / W_KM, BTN = 54; let mx: number, my: number, mw: number, mh: number, pyr: Box, chart: Box, log: Box | null = null; if (W >= H * 1.15) { const colW = clamp(W * 0.3, u(220), u(400)); mw = W - colW - m * 3; mh = mw * asp; if (mh > H - m * 2) { mh = H - m * 2; mw = mh / asp; } mx = m + (W - colW - m * 3 - mw) / 2; my = (H - mh) / 2; // 地図の下に余りがあれば「できごと」の欄を置く if (H - mh - 2 * m >= u(84)) { my = m + Math.min(u(8), (H - mh - 2 * m - u(84)) / 2); log = { x: mx, y: my + mh + u(10), w: mw, h: H - my - mh - u(10) - m }; } const cx = W - colW - m, ch = H - m - Math.max(m, BTN); pyr = { x: cx, y: m, w: colW, h: ch * 0.56 }; chart = { x: cx, y: m + ch * 0.56 + u(10), w: colW, h: ch * 0.44 - u(10) }; } else { mw = W - 2 * m; mh = mw * asp; if (mh > H * 0.5) { mh = H * 0.5; mw = mh / asp; } mx = (W - mw) / 2; my = m; const y0 = my + mh + u(16), ah = H - y0 - Math.max(m, BTN); if (ah > W * 1.1) { pyr = { x: m, y: y0, w: W - 2 * m, h: ah * 0.56 }; chart = { x: m, y: y0 + ah * 0.56 + u(10), w: W - 2 * m, h: ah * 0.44 - u(10) }; } else { const hw = (W - 3 * m) / 2; pyr = { x: m, y: y0, w: hw, h: ah }; chart = { x: 2 * m + hw, y: y0, w: hw, h: ah }; } } const k = mw / W_KM; return { mx, my, mw, mh, k, pyr, chart, log, X: (x) => mx + x * k, Y: (y) => my + y * k };}function polyline(api: Api, L: Lay, f: (x: number) => number, x0: number, x1: number, w: number, color: "ink" | "paper", dash?: number[]) { const c = api.ctx; c.beginPath(); for (let x = x0; x <= x1 + 1e-9; x += 0.05) { const X = L.X(x), Y = L.Y(f(x)); if (x === x0) c.moveTo(X, Y); else c.lineTo(X, Y); } c.lineWidth = w; c.strokeStyle = color === "ink" ? api.ink : api.paper; c.setLineDash(dash ?? []); c.stroke(); c.setLineDash([]);}function drawMap(api: Api, s: S, L: Lay) { const u = api.u, c = api.ctx, k = L.k; c.save(); c.beginPath(); c.rect(L.mx, L.my, L.mw, L.mh); c.clip(); // 等高線と田の記号(‖) const C = CONTOURS!; c.beginPath(); for (let t = 0; t < C.length; t += 4) { c.moveTo(L.X(C[t]), L.Y(C[t + 1])); c.lineTo(L.X(C[t + 2]), L.Y(C[t + 3])); } c.lineWidth = u(0.6); c.strokeStyle = api.ink; c.setLineDash([]); c.stroke(); const PD = PADDY!, ph = Math.max(1.2, u(2.1)), pg = Math.max(0.8, u(1.1)), userZ = s.zones.filter((z) => z.user); c.beginPath(); for (let t = 0; t < PD.length; t += 2) { const x = PD[t], y = PD[t + 1]; if (userZ.some((z) => x > z.x - 0.05 && x < z.x + z.w + 0.05 && y > z.y - 0.05 && y < z.y + z.h + 0.05)) continue; if (s.facs.some((f) => f.kind !== "zone" && Math.abs(f.x - x) < FOOT[f.kind] + 0.06 && Math.abs(f.y - y) < FOOT[f.kind] + 0.04)) continue; const X = L.X(x), Y = L.Y(y); c.moveTo(X - pg, Y - ph); c.lineTo(X - pg, Y + ph); c.moveTo(X + pg, Y - ph); c.lineTo(X + pg, Y + ph); } c.lineWidth = u(0.6); c.stroke(); // 川(横の線で塗る) const up: [number, number][] = [], dn: [number, number][] = []; for (let x = -0.05; x <= W_KM + 0.05; x += 0.05) { up.push([L.X(x), L.Y(riverY(x) - 0.035)]); dn.push([L.X(x), L.Y(riverY(x) + 0.035)]); } api.hatch([...up, ...[...dn].reverse()], { gap: Math.max(1.5, u(2)), angle: 0, w: u(0.5) }); api.poly(up, { w: u(0.9) }); api.poly(dn, { w: u(0.9) }); // 道: 国道は二重線、県道と集落の道は細い線 for (const z of s.zones) if (z.name === "集落") { const x = z.x + z.w / 2; api.line(L.X(x), L.Y(z.y + z.h / 2), L.X(x), L.Y(roadY(x)), { w: u(0.6), dash: [u(3), u(2)] }); } c.beginPath(); c.moveTo(L.X(roadX(0.7)), L.Y(0.7)); for (let y = 0.7; y <= railY(3.48) + 0.3; y += 0.05) c.lineTo(L.X(roadX(y)), L.Y(y)); c.lineWidth = u(1.1); c.strokeStyle = api.ink; c.stroke(); polyline(api, L, roadY, -0.1, W_KM + 0.1, Math.max(3, u(4.4)), "ink"); polyline(api, L, roadY, -0.1, W_KM + 0.1, Math.max(1.6, u(2.6)), "paper"); // 線路(白と黒の交互) polyline(api, L, railY, -0.1, W_KM + 0.1, Math.max(2.4, u(3.4)), "ink"); polyline(api, L, railY, -0.1, W_KM + 0.1, Math.max(1.2, u(1.8)), "paper", [u(8), u(8)]); // 住宅地: 区画の道と家。人の住む家は塗りの四角。空き家は、家が大きく描けるときは白抜きの四角(10 年以上なら斜線も)、小さいときは小さな点 c.beginPath(); for (const z of s.zones) for (let t = 0; t < z.streets.length; t += 4) { c.moveTo(L.X(z.streets[t]), L.Y(z.streets[t + 1])); c.lineTo(L.X(z.streets[t + 2]), L.Y(z.streets[t + 3])); } c.lineWidth = u(0.45); c.strokeStyle = api.ink; c.stroke(); const hsz = Math.max(1.1, SP * k * 0.62), hh = hsz / 2; c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && s.locc[l]) c.rect(L.X(s.lx[l]) - hh, L.Y(s.ly[l]) - hh, hsz, hsz); c.fillStyle = api.ink; c.fill(); if (hsz >= 3.4) { c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l]) c.rect(L.X(s.lx[l]) - hh + 0.4, L.Y(s.ly[l]) - hh + 0.4, hsz - 0.8, hsz - 0.8); c.lineWidth = 0.8; c.stroke(); c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l] && s.lvac[l] >= 10) { const X = L.X(s.lx[l]), Y = L.Y(s.ly[l]); c.moveTo(X - hh, Y + hh); c.lineTo(X + hh, Y - hh); } c.stroke(); } else { const q = Math.max(0.5, hsz * 0.22); c.beginPath(); for (let l = 0; l < s.nl; l++) if (s.lst[l] === 1 && !s.locc[l]) c.rect(L.X(s.lx[l]) - q, L.Y(s.ly[l]) - q, 2 * q, 2 * q); c.fill(); } // 駅から 1.5 km の範囲 for (const f of s.facs) if (f.kind === "station") api.circle(L.X(f.x), L.Y(f.y), R_ST * k, { w: u(1.1), dash: [u(4), u(3)] }); drawPeople(api, s, L); drawTrain(api, s, L); for (const f of s.facs) drawFac(api, s, L, f); api.label("川", L.X(0.3), L.Y(riverY(0.3)) + u(4), { size: Math.max(8, u(9.5)), align: "center" }); api.label("市へ", L.X(W_KM) - u(4), L.Y(railY(W_KM)) - u(7), { size: Math.max(8, u(9.5)), align: "right" }); c.restore(); // 枠・方位・縮尺・時刻・凡例 api.rect(L.mx, L.my, L.mw, L.mh, { w: u(1.2) }); const fs = Math.max(8, u(10.5)), bx = L.mx + u(10), by = L.my + L.mh - u(12); api.rect(bx - u(4), by - u(9), k + u(44), u(16), { fill: "paper", stroke: false }); api.line(bx, by, bx + k, by, { w: u(1.4) }); api.line(bx, by - u(4), bx, by + u(1), { w: u(1.2) }); api.line(bx + k, by - u(4), bx + k, by + u(1), { w: u(1.2) }); api.text("1 km", bx + k + u(6), by + u(3), { size: fs, font: "mono" }); const nx = L.mx + L.mw - u(16), ny = L.my + u(14); api.arrow(nx, ny + u(12), nx, ny - u(4), { w: u(1.2), head: u(5) }); api.label("北", nx, ny + u(24), { size: fs, align: "center" }); const tod = s.phase, word = tod < 0.38 ? "朝" : tod < 0.52 ? "昼" : tod < 0.86 ? "夕方" : "夜"; api.label(`${s.year} 年 ${word} ${clock(tod)}`, L.mx + u(10), L.my + u(18), { size: u(12.5), font: "mono" }); if (L.mw > u(430)) { const q = u(3), lgX = L.mx + L.mw - u(132), lgY = L.my + L.mh - u(10); api.rect(lgX - u(5), lgY - u(12), u(130), u(17), { fill: "paper", stroke: false }); api.rect(lgX, lgY - q * 2, q * 2, q * 2, { fill: "ink", stroke: false }); api.text("人の住む家", lgX + u(9), lgY, { size: Math.max(8, u(9.5)) }); if (hsz >= 3.4) api.rect(lgX + u(70), lgY - q * 2, q * 2, q * 2, { w: 0.8 }); else api.dot(lgX + u(70) + q, lgY - q, Math.max(0.5, hsz * 0.22)); api.text("空き家", lgX + u(79), lgY, { size: Math.max(8, u(9.5)) }); }}function drawTrain(api: Api, s: S, L: Lay) { if (!s.facs.some((f) => f.kind === "station")) return; const ph = s.phase, u = api.u; // 朝は東の市へ向かい(dir = 1)、夕方は市から戻る(dir = -1) for (const [t0, dir] of [[0.08, 1], [0.22, 1], [0.62, -1], [0.76, -1]] as const) { const f = (ph - t0) / 0.12; if (f < 0 || f > 1) continue; const head = dir < 0 ? W_KM + 0.4 - f * (W_KM + 1.2) : -0.4 + f * (W_KM + 1.2); for (let car = 0; car < 4; car++) { const x = head - dir * car * 0.2, x2 = x - dir * 0.17; api.line(L.X(x), L.Y(railY(x)), L.X(x2), L.Y(railY(x2)), { w: Math.max(3, u(5)) }); } }}function drawFac(api: Api, s: S, L: Lay, f: Fac) { const u = api.u, X = L.X(f.x), Y = L.Y(f.y), k = L.k, fs = Math.max(8, u(9.5)), c = api.ctx; let lab = f.name, ly = Y + u(16), top = Y - u(10), right = X + u(10); if (f.kind === "es" || f.kind === "jhs") { // 学校: 校庭と校舎。上に学校の地図記号「文」 const w = Math.max(u(15), (f.kind === "es" ? 0.2 : 0.24) * k), h = Math.max(u(10), 0.13 * k); api.rect(X - w / 2, Y - h / 2, w, h, { w: u(1), dash: f.open ? undefined : [u(2), u(2)] }); // 塗らない(昼は校庭に子どもの点が見える) if (f.open) { c.beginPath(); c.ellipse(X + w * 0.1, Y, w * 0.3, h * 0.28, 0, 0, TAU); c.lineWidth = u(0.6); c.strokeStyle = api.ink; c.stroke(); api.rect(X - w / 2, Y - h / 2, w * 0.18, h, { fill: "ink", stroke: false }); } else { api.line(X - w / 2, Y - h / 2, X + w / 2, Y + h / 2, { w: u(0.8) }); api.line(X - w / 2, Y + h / 2, X + w / 2, Y - h / 2, { w: u(0.8) }); } api.label("文", X, Y - h / 2 - u(3), { size: fs * 1.1, align: "center", weight: 700 }); lab = f.open ? f.name : `${f.name}(閉校)`; ly = Y + h / 2 + u(11); top = Y - h / 2 - u(14); right = X + w / 2; } else if (f.kind === "factory") { // 工場: のこぎり屋根の建物と、歯車の印 const w = Math.max(u(22), 0.3 * k), h = Math.max(u(12), 0.16 * k), x0 = X - w / 2, y0 = Y - h / 2, n = 4, pts: [number, number][] = [[x0, Y + h / 2]]; for (let t = 0; t < n; t++) { pts.push([x0 + (t * w) / n, y0 + h * 0.35]); pts.push([x0 + ((t + 0.75) * w) / n, y0]); pts.push([x0 + ((t + 0.75) * w) / n, y0 + h * 0.35]); } pts.push([x0 + w, y0 + h * 0.35], [x0 + w, Y + h / 2]); api.poly(pts, { close: true, w: u(1.1) }); api.hatch(pts, { gap: Math.max(2.4, u(4)), angle: 45, w: u(0.45) }); // 塗らない(昼は働く人の点が見える) const gx = x0 - u(7), gy = y0 + u(1), gr = u(3.6); for (let t = 0; t < 8; t++) { const a = (t / 8) * TAU; api.line(gx + Math.cos(a) * gr, gy + Math.sin(a) * gr, gx + Math.cos(a) * (gr + u(2)), gy + Math.sin(a) * (gr + u(2)), { w: u(1.4) }); } api.circle(gx, gy, gr, { fill: "paper", w: u(1.1) }); api.circle(gx, gy, gr * 0.35, { w: u(0.8) }); ly = Y + h / 2 + u(11); top = y0 - u(6); right = x0 + w; } else if (f.kind === "daycare") { const w = u(14), h = u(11); api.rect(X - w / 2, Y - h / 2, w, h, { fill: "paper", w: u(1) }); api.text("保", X, Y + fs * 0.36, { size: fs * 0.95, align: "center", weight: 700 }); ly = Y + h / 2 + u(11); top = Y - h / 2; right = X + w / 2; } else if (f.kind === "station") { const a = Math.atan2(railY(f.x + 0.05) - railY(f.x - 0.05), 0.1); c.save(); c.translate(X, Y); c.rotate(a); api.rect(-u(11), -u(4), u(22), u(8), { fill: "paper", w: u(1.3) }); c.restore(); ly = Y - u(9); top = Y - u(6); right = X + u(11); } else if (f.kind === "hall") { api.circle(X, Y, u(5), { fill: "paper", w: u(1.1) }); api.circle(X, Y, u(2.2), { w: u(1) }); ly = Y + u(15); } else if (f.kind === "hospital") { api.rect(X - u(5), Y - u(5), u(10), u(10), { fill: "paper", w: u(1) }); api.line(X - u(3), Y, X + u(3), Y, { w: u(1.4) }); api.line(X, Y - u(3), X, Y + u(3), { w: u(1.4) }); ly = Y + u(15); } else if (f.kind === "zone" && f.zone) { const z = f.zone; api.rect(L.X(z.x), L.Y(z.y), z.w * k, z.h * k, { w: u(0.9), dash: [u(4), u(3)] }); lab = "新しい住宅地"; ly = L.Y(z.y) - u(4); top = L.Y(z.y); right = L.X(z.x + z.w); } // 地図が小さいときは、役場・病院・保育所の名前を省いて重なりを減らす if (k >= 45 || (f.kind !== "hall" && f.kind !== "hospital" && f.kind !== "daycare")) api.label(lab, X, ly, { size: fs, align: "center" }); // つかめる施設の取っ手(四角)・長押しの進み・選んだ施設の輪と説明 if (f.kind !== "hall" && f.kind !== "hospital") { const q = u(2.6); api.rect(right + u(2) - q, top - q, 2 * q, 2 * q, { fill: "paper", w: u(1) }); } const d = s.drag; if (d && d.fac === f && !d.moved && !d.done && removable(f)) { const p = clamp((performance.now() - d.t0) / 650, 0, 1); if (p > 0.15) { c.beginPath(); c.arc(X, Y, u(20), -Math.PI / 2, -Math.PI / 2 + p * TAU); c.lineWidth = u(2.2); c.strokeStyle = api.ink; c.setLineDash([]); c.stroke(); } } if (s.sel === f) { if (f.kind !== "zone") api.circle(X, Y, u(19), { w: u(1), dash: [u(1.5), u(2.5)] }); const info = facInfo(s, f); c.font = `400 ${fs}px sans-serif`; const tw = c.measureText(info).width, tx = clamp(X - tw / 2, L.mx + u(6), L.mx + L.mw - tw - u(6)); api.label(info, tx, Math.max(L.my + u(40), top - u(10)), { size: fs }); }}function facInfo(s: S, f: Fac) { switch (f.kind) { case "factory": return `工場 ${FACTORY_JOBS} 人分の仕事・働く人 ${f.filled} 人`; case "daycare": return `保育所 定員 ${DAYCARE_CAP} 人・預ける子 ${f.users} 人`; case "station": return `駅 1.5 km の中に ${nf(f.users)} 人・市へ通う人 ${f.filled} 人`; case "es": case "jhs": return f.open ? `${f.name} 通う子ども ${f.users} 人` : `${f.name} 閉校`; case "zone": { let v = 0, h = 0; for (const l of f.zone!.lots) if (s.lst[l] === 1) { h++; if (!s.locc[l]) v++; } return `住宅地 家 ${h} 戸・空き家 ${v} 戸`; } default: return f.name; }}const niceUp = (v: number) => { const e = Math.pow(10, Math.floor(Math.log10(Math.max(1, v)))); for (const m of [1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 7, 8, 10]) if (m * e >= v) return m * e; return 10 * e; };/** 人口ピラミッド: 1 歳ごとの棒(左が男・右が女)。細い線は最初の年の形 */function drawPyramid(api: Api, s: S, b: Box, tfr: number) { const u = api.u, c = api.ctx, fs = Math.max(8, u(10)), A = AMAX + 1, st = s.st, rx = b.x + b.w; api.text("人口ピラミッド", b.x, b.y + u(12), { size: u(12.5), weight: 600 }); api.text(`${s.year} 年`, rx, b.y + u(12), { size: u(11.5), align: "right", font: "mono" }); api.text(`65 歳以上 ${fmt(st.old * 100, 1)} %・15 歳未満 ${fmt(st.young * 100, 1)} %`, b.x, b.y + u(28), { size: fs }); api.text(`合計特殊出生率 ${fmt(tfr, 2)}(人口置換水準 ${fmt(REPL, 2)})`, b.x, b.y + u(42), { size: fs }); const top = b.y + u(54), bot = b.y + b.h - u(28), gap = u(26), cx = b.x + b.w / 2, half = b.w / 2 - gap / 2; const bh = (bot - top) / 101, P = s.pyr, P0 = s.pyr0; const cnt = (Q: Float32Array, sx: number, a: number) => { if (a < 100) return Q[sx * A + a]; let t = 0; for (let x = 100; x <= AMAX; x++) t += Q[sx * A + x]; return t; }; let mx = 1; for (let a = 0; a <= 100; a++) for (let sx = 0; sx < 2; sx++) mx = Math.max(mx, cnt(P, sx, a), cnt(P0, sx, a)); const sc = niceUp(mx), wOf = (v: number) => (v / sc) * half, yOf = (a: number) => bot - (a + 1) * bh; const bars = () => { c.beginPath(); for (let a = 0; a <= 100; a++) { const y = yOf(a), m = wOf(cnt(P, 0, a)), f = wOf(cnt(P, 1, a)); c.rect(cx - gap / 2 - m, y, m, bh + 0.3); c.rect(cx + gap / 2, y, f, bh + 0.3); } }; bars(); c.fillStyle = api.ink; c.fill(); // 最初の年の形。棒の外では文字の色、棒の中では地の色で描き、どちらでも見えるようにする const outline = () => { c.beginPath(); for (const sx of [0, 1]) for (let a = 0; a <= 100; a++) { const w = wOf(cnt(P0, sx, a)), x = sx ? cx + gap / 2 + w : cx - gap / 2 - w, y0 = yOf(a), y1 = y0 + bh; if (a === 0) c.moveTo(x, y1); else c.lineTo(x, y1); c.lineTo(x, y0); } }; outline(); c.lineWidth = u(0.9); c.strokeStyle = api.ink; c.setLineDash([]); c.stroke(); c.save(); bars(); c.clip(); outline(); c.strokeStyle = api.paper; c.stroke(); c.restore(); // 年齢の目盛りと、15 歳・65 歳の線 for (let a = 0; a <= 100; a += 20) api.text(String(a), cx, yOf(a) + bh + fs * 0.35, { size: fs * 0.9, align: "center", font: "mono" }); for (const a of [15, 65]) { const y = yOf(a) + bh; api.line(b.x, y, b.x + b.w, y, { w: u(0.7), dash: [u(2), u(3)] }); } api.text("男", b.x, top + fs, { size: fs }); api.text("女", rx, top + fs, { size: fs, align: "right" }); api.text(`細い線は ${START} 年の形`, cx, bot + u(26), { size: fs * 0.9, align: "center" }); for (const v of [sc / 2, sc]) for (const sg of [-1, 1]) { const x = cx + sg * (gap / 2 + wOf(v)); api.line(x, bot + u(1), x, bot + u(4), { w: u(0.8) }); } api.line(b.x, bot + u(1), b.x + b.w, bot + u(1), { w: u(0.7) }); api.text(`${nf(sc)} 人`, cx - gap / 2 - wOf(sc), bot + u(14), { size: fs * 0.9, align: "left", font: "mono" }); api.text(`${nf(sc)} 人`, cx + gap / 2 + wOf(sc), bot + u(14), { size: fs * 0.9, align: "right", font: "mono" });}/** 地図の下の「できごと」: 新しい順に、入るだけ */function drawLog(api: Api, s: S, b: Box) { const u = api.u, fs = Math.max(8, u(10.5)), lh = fs * 1.55; api.line(b.x, b.y, b.x + b.w, b.y, { w: u(0.6), dash: [u(2), u(3)] }); api.text("できごと", b.x, b.y + u(16), { size: fs, weight: 600 }); const n = Math.max(0, Math.floor((b.h - u(22)) / lh)); if (!s.log.length) { api.text("まだ大きなできごとはありません", b.x + u(64), b.y + u(16), { size: fs }); return; } const c = api.ctx; c.save(); c.beginPath(); c.rect(b.x, b.y, b.w, b.h); c.clip(); for (let k = 0; k < Math.min(n, s.log.length); k++) api.text(s.log[k], b.x + u(64), b.y + u(16) + k * lh, { size: fs }); c.restore();}/** 総人口(実線・左の目盛り)と高齢化率(破線・右の目盛り)の移り変わり */function drawChart(api: Api, s: S, b: Box) { const u = api.u, fs = Math.max(8, u(10)), H = s.hist, n = H.n; api.text("総人口と高齢化率", b.x, b.y + u(12), { size: u(12.5), weight: 600 }); const x0 = b.x + u(34), x1 = b.x + b.w - u(30), y0 = b.y + u(28), y1 = b.y + b.h - u(18); const k0 = Math.max(0, n - 1 - 150), yrs = Math.min(150, Math.max(50, Math.ceil((n + 4) / 25) * 25)), X = (k: number) => x0 + ((k - k0) / yrs) * (x1 - x0); let pm = 1; for (let k = k0; k < n; k++) pm = Math.max(pm, H.pop[k]); const PM = niceUp(pm * 1.05), Yp = (v: number) => y1 - (v / PM) * (y1 - y0), Yo = (v: number) => y1 - (v / 0.7) * (y1 - y0); api.line(x0, y1, x1, y1, { w: u(0.8) }); api.line(x0, y0, x0, y1, { w: u(0.8) }); api.line(x1, y0, x1, y1, { w: u(0.8), dash: [u(2), u(2)] }); for (const v of [PM / 2, PM]) { api.line(x0 - u(4), Yp(v), x0, Yp(v), { w: u(0.8) }); api.text(nf(v), x0 - u(6), Yp(v) + fs * 0.35, { size: fs * 0.9, align: "right", font: "mono" }); } for (const v of [0.2, 0.4, 0.6]) { api.line(x1, Yo(v), x1 + u(4), Yo(v), { w: u(0.8) }); api.text(`${Math.round(v * 100)}%`, x1 + u(6), Yo(v) + fs * 0.35, { size: fs * 0.9, font: "mono" }); } const step = yrs > 100 ? 50 : 25; for (let yr = Math.ceil((START + k0) / step) * step; yr <= START + k0 + yrs; yr += step) { const x = X(yr - START); api.line(x, y1, x, y1 + u(4), { w: u(0.8) }); api.text(String(yr), x, y1 + u(14), { size: fs * 0.9, align: "center", font: "mono" }); } const pts: number[] = [], po: number[] = []; for (let k = k0; k < n; k++) { pts.push(X(k), Yp(H.pop[k])); po.push(X(k), Yo(H.old[k])); } if (n >= 2) { api.poly(pts, { w: u(1.8) }); api.poly(po, { w: u(1.3), dash: [u(4), u(3)] }); } const lx = X(n - 1), lp = Yp(H.pop[n - 1]), lo = Yo(H.old[n - 1]); api.dot(lx, lp, u(2.4)); api.dot(lx, lo, u(2.2)); // 札はグラフの右上に凡例として置く(点の横に置くと、下地が折れ線を隠すため) const t1 = `総人口 ${nf(H.pop[n - 1])} 人(実線)`, t2 = `高齢化率 ${fmt(H.old[n - 1] * 100, 1)} %(破線)`; api.text(t1, x1 - u(6), y0 + fs, { size: fs, align: "right" }); api.text(t2, x1 - u(6), y0 + fs * 2.4, { size: fs, align: "right" });}function readout(s: S) { const c = s.cur, st = s.st, j = s.job, d = (v: number) => (c.done ? nf(v) : "-"), dd = (v: number) => (c.done ? sgn(v) : "-"); return [ `年 ${s.year} 年(始めから ${s.year - START} 年) 総人口 ${nf(st.pop)} 人`, `65 歳以上の割合 ${fmt(st.old * 100, 1)} % 15 歳未満の割合 ${fmt(st.young * 100, 1)} %`, `その年の出生 ${d(c.births)} 人 死亡 ${d(c.deaths)} 人 自然増減 ${dd(c.births - c.deaths)} 人`, `転入 ${d(c.ins)} 人 転出 ${d(c.outs)} 人 社会増減 ${dd(c.ins - c.outs)} 人`, `空き家 ${nf(st.vac)} 戸(${fmt(st.houses ? (st.vac / st.houses) * 100 : 0, 1)} %) 仕事 ${nf(j.J)} 人分・働く人 ${nf(j.LF)} 人`, ];}