medieval-kit/my-registry/models/iron-lantern/model.ts
CRT a3300ff233 Kit'i 26 modele çıkar, doğrulamayı ve inceleyiciyi genişlet
Modeller (13 → 26): chest, bench, torch, lantern, bell, sign, hay bale,
sack, broom, tankard, book, phial, coin pouch. Kürek, çapa ve yaba
render'lara bakılarak yeniden yazıldı.

core:
- createKitModel artık tipli `actions` ve `update(dt)` taşıyor
- BuiltPart.extras: bir parça birden çok materyal yuvası kullanabiliyor
  (sandığın kapağı = meşe tahta + demir kayış, birlikte dönmek zorunda)
- BuiltPart.origin: parça başına dönme merkezi (menteşe, sarkaç)
- bendGeometry / roughenGeometry / mottleGeometry
- 5 yeni materyal yuvası: steel, brass, straw, cloth, leather, glass
- bandGeometry({ inner }) — serbest halkalar kapalı katı olabilsin

my-registry/meta.ts: katalog metadata'sı artık build.ts ve viewer için
tek kaynak. Doğrulama modelle metadata'nın uyuştuğunu denetliyor ve bu
eklendiği gün dört modelde gerçek bir sapma yakaladı.

Doğrulama (~500 kontrol):
- metadata uyumu, eylem semantiği, kare hızı bağımsızlığı
- scripts/render.ts: tarayıcısız yazılım rasterleyici. Bütün doğrulama
  geometrikti; "bu kürek küreğe benzemiyor" diyebilmek için modele
  bakmak gerekiyordu
- scripts/verify-glb.ts: dışa aktar → geri oku → karşılaştır

GLB dışa aktarımı: viewer düğmesi ve toplu CLI aynı kodu kullanıyor.

Viewer:
- parça paneli (solo/izole) ve materyal yuvası paneli (canlı renk override)
- kit görünümü: 26 model tek sahnede, ölçek ve ton tutarlılığı için
- configure() sonrası gölge bayraklarının düşmesi düzeltildi
2026-08-21 09:44:58 +07:00

214 lines
8 KiB
TypeScript

/**
* @medieval-kit/iron-lantern
*
* Elde taşınan ya da bir çengele asılan demir fener: altıgen bir kafes, cam
* paneller, içinde yağ kandili.
*
* Meşaleden farkı sadece biçim değil. Meşale sarf malzemesi, fener ALET —
* pahalı, saklanan, miras kalan bir şey. Bu yüzden geometri de daha "yapılmış"
* görünüyor: dövme köşe dikmeleri, havalandırma bacası, taşıma halkası.
*
* Camın iki sonucu var ve ikisi de burada görünür:
*
* - `glass` yuvası SAYDAM ve `depthWrite` kapalı. Bu olmadan cam, arkasındaki
* fitili gizliyordu; şeffaf bir yüzey derinlik yazmamalı.
* - Cam paneller kafesin `extras` gövdesi. Ayrı parça yapmak mantıklı
* gelmişti ama yanlış: kafes ile cam TEK ANLAM — biri hareket ederse
* diğeri de eder. Bölünen şey sadece materyal.
*/
import type { BufferGeometry } from 'three'
import {
bandGeometry,
boxGeometry,
createKitModel,
createTinter,
jitter,
latheGeometry,
mergeColoured,
prismGeometry,
type Level,
} from '../core/index.ts'
export interface IronLanternConfig {
/** Gövde yüksekliği, halka hariç (metre). */
readonly height: number
/** Kafesin köşeden köşeye yarıçapı (metre). */
readonly radius: number
/** Kaç köşe. 4 fener, 6 daha zengin. */
readonly sides: number
/** Alev boyu, gövde yüksekliğinin oranı olarak. */
readonly flameHeight: number
/** Titremenin genliği. 0 = sabit alev. */
readonly flicker: number
readonly seed: number
}
export const ironLanternDefaults: IronLanternConfig = {
height: 0.26,
radius: 0.075,
sides: 6,
flameHeight: 0.22,
flicker: 1,
seed: 71,
}
export type IronLanternParts = 'frame' | 'font' | 'flame' | 'handle'
export interface IronLanternActions {
setLit(lit: boolean): void
isLit(): boolean
}
export function createModel(overrides: Partial<IronLanternConfig> = {}) {
let lit = true
let elapsed = 0
return createKitModel<IronLanternConfig, 'iron' | 'glass' | 'char' | 'ember', IronLanternParts, IronLanternActions>({
id: 'iron-lantern',
defaults: ironLanternDefaults,
slots: ['iron', 'glass', 'char', 'ember'],
build: ({ config, random }) => {
const tint = createTinter(random)
const half = config.height / 2
const sides = Math.max(3, Math.round(config.sides))
const bar = config.radius * 0.13
// Camlı bölüm gövdenin ortası: altta yağ haznesi, üstte baca için yer.
const glassBottom = -half + config.height * 0.2
const glassTop = half - config.height * 0.26
// --- Kafes -------------------------------------------------------------
const iron: BufferGeometry[] = []
// Taban tabağı ve tepe şapkası: ikisi de altıgen, biri düz biri konik.
iron.push(prismGeometry(
config.radius * 1.08, config.radius, config.height * 0.11, sides,
[0, -half + config.height * 0.055, 0], tint('iron', -0.04, 0.7),
))
iron.push(prismGeometry(
config.radius * 1.12, config.radius * 0.42, config.height * 0.19, sides,
[0, half - config.height * 0.16, 0], tint('iron', 0.03, 0.7),
))
// Baca: sıcak hava çıkmazsa alev söner. İşlevsel bir detay ve siluetin
// tepesini feneri fener yapan şey.
iron.push(prismGeometry(
config.radius * 0.4, config.radius * 0.34, config.height * 0.09, sides,
[0, half - config.height * 0.025, 0], tint('iron', 0.07, 0.7),
))
// Köşe dikmeleri: cam panellerin arasında kalan dövme demir çubuklar.
const step = (Math.PI * 2) / sides
for (let i = 0; i < sides; i += 1) {
const a = i * step
const post = boxGeometry(
[bar, glassTop - glassBottom + config.height * 0.1, bar * 1.15],
[0, 0, 0],
tint('iron', jitter(random, 0.05), 0.7),
)
// Önce yönlendir, sonra taşı — ters sıra dikmeyi yörüngeye savurur.
post.rotateY(a)
post.translate(
Math.sin(a) * config.radius * 0.97,
(glassBottom + glassTop) / 2,
Math.cos(a) * config.radius * 0.97,
)
iron.push(post)
}
// Cam panelleri tutan alt ve üst çerçeve.
for (const y of [glassBottom, glassTop]) {
iron.push(bandGeometry(config.radius * 0.99, y, bar * 1.1, bar * 0.8, sides,
tint('iron', -0.02, 0.7)))
}
// --- Cam -----------------------------------------------------------------
// Paneller dikmelerden biraz İÇERİDE: aynı yarıçapta olsalardı yan
// yüzleri dikmelerin yüzleriyle aynı düzleme oturur ve titrerdi.
const glass = prismGeometry(
config.radius * 0.9, config.radius * 0.9, glassTop - glassBottom, sides,
[0, (glassBottom + glassTop) / 2, 0], tint('glass', 0.04, 0.4),
{ capTop: false, capBottom: false },
)
// --- Yağ haznesi ve fitil --------------------------------------------------
const fontTop = glassBottom + config.height * 0.16
const font = latheGeometry([
{ y: glassBottom - config.height * 0.02, radius: config.radius * 0.5 },
{ y: glassBottom + config.height * 0.06, radius: config.radius * 0.62 },
{ y: fontTop, radius: config.radius * 0.44 },
], sides * 2, [0, 0, 0], tint('glass', -0.05, 0.4), { capTop: true })
const wick = prismGeometry(
config.radius * 0.075, config.radius * 0.05, config.height * 0.09, 4,
[0, fontTop + config.height * 0.035, 0], tint('char', 0.05),
)
// --- Alev --------------------------------------------------------------
// Meşalenin alevinin küçüğü ve daha sakini: kapalı bir fenerde alev
// rüzgâr almaz, o yüzden titremesi de daha az.
const flameHeight = config.height * config.flameHeight
const flameBase = fontTop + config.height * 0.07
const flameProfile: Level[] = [
{ y: 0, radius: flameHeight * 0.2 },
{ y: flameHeight * 0.22, radius: flameHeight * 0.3 },
{ y: flameHeight * 0.58, radius: flameHeight * 0.19 },
{ y: flameHeight, radius: flameHeight * 0.03 },
]
const flame = mergeColoured([
latheGeometry(flameProfile, 6, [0, 0, 0], tint('ember', 0.06, 0.35),
{ colourTop: tint('emberTip', 0.02, 0.35) }),
])
// --- Taşıma halkası ------------------------------------------------------
const handle: BufferGeometry[] = [bandGeometry(
config.radius * 0.3, half + config.height * 0.1, bar * 0.9, bar * 0.55, 8,
tint('iron', 0.06, 0.7), { inner: true },
)]
// Halkayı bacaya bağlayan dil.
handle.push(boxGeometry(
[bar * 1.1, config.height * 0.1, bar],
[0, half + config.height * 0.045, 0],
tint('iron', 0.02, 0.7),
))
return {
frame: {
slot: 'iron' as const,
geometry: mergeColoured(iron),
extras: [{ slot: 'glass' as const, geometry: mergeColoured([glass]) }],
},
font: {
slot: 'glass' as const,
geometry: mergeColoured([font]),
extras: [{ slot: 'char' as const, geometry: mergeColoured([wick]) }],
},
flame: {
slot: 'ember' as const,
geometry: flame,
origin: [0, flameBase, 0] as const,
},
handle: { slot: 'iron' as const, geometry: mergeColoured(handle) },
}
},
actions: ({ parts }) => {
parts.flame.anchor.visible = lit
return {
setLit: (next) => { lit = next; parts.flame.anchor.visible = next },
isLit: () => lit,
}
},
update: (dt, { parts, getConfig }) => {
if (!lit) return
const amount = getConfig().flicker
if (amount === 0) return
elapsed += Math.max(0, dt)
// Meşaleninkinden yavaş ve küçük: cam alevi rüzgârdan koruyor.
const pulse = Math.sin(elapsed * 6.4) * 0.06 + Math.sin(elapsed * 11.1 + 0.9) * 0.035
const anchor = parts.flame.anchor
anchor.scale.set(1 - pulse * 0.4 * amount, 1 + pulse * amount, 1 - pulse * 0.4 * amount)
anchor.rotation.z = Math.sin(elapsed * 4.7 + 1.3) * 0.03 * amount
},
}, overrides)
}