| """参数化 SVG 渲染: traits + age + stage + sick + mood → 蛐蛐形象. |
| |
| 设计原则: 零依赖、零成本、演化连续可见。 |
| 映射(可随时调,但保持单调直觉): |
| 勇 brave → 触须张角/前肢姿态(越勇越张扬) |
| 萌 cute → 眼睛大小/身体圆润度 |
| 怨 grudge → 体色向紫黑偏移/眉角下压 |
| 智 wit → 额头花纹复杂度(条纹数) |
| 馋 glutton → 肚子大小 |
| stage → 整体尺寸 + 翅膀层数(蜕皮=大变样) |
| sick → 整体降饱和 + 绿色病气 + 眼睛变 X |
| """ |
| from __future__ import annotations |
|
|
| from traits import TRAIT_KEYS, molt_stage |
|
|
|
|
| def _lerp(a: float, b: float, t: float) -> float: |
| return a + (b - a) * t |
|
|
|
|
| def _color(traits: dict, sick: bool) -> tuple[str, str]: |
| """主体色: 健康=暖棕→怨气高偏紫黑; 生病=灰绿.""" |
| g = traits.get("grudge", 0.5) |
| if sick: |
| return "#7a8a6a", "#5a6a4a" |
| r = int(_lerp(150, 90, g)) |
| gr = int(_lerp(95, 60, g)) |
| b = int(_lerp(60, 110, g)) |
| return f"rgb({r},{gr},{b})", f"rgb({max(r-40,0)},{max(gr-30,0)},{max(b-20,0)})" |
|
|
|
|
| def render_svg(traits: dict, feed_count: int = 0, sick: bool = False, |
| mood: str = "calm", width: int = 360, height: int = 280) -> str: |
| """返回完整 <svg> 字符串.""" |
| t = {k: float(traits.get(k, 0.5)) for k in TRAIT_KEYS} |
| stage = molt_stage(feed_count) |
| scale = _lerp(0.7, 1.15, min(stage, 3) / 3) |
| body_fill, body_dark = _color(t, sick) |
|
|
| cx, cy = width / 2, height / 2 + 20 |
| belly = _lerp(34, 52, t["glutton"]) * scale |
| body_ry = _lerp(belly * 0.78, belly * 0.95, t["cute"]) |
| eye_r = _lerp(4.5, 9.5, t["cute"]) * scale |
| ant_spread = _lerp(8, 42, t["brave"]) |
| ant_len = _lerp(55, 85, t["brave"]) * scale |
| stripes = 1 + int(round(t["wit"] * 4)) |
| brow_drop = _lerp(-3, 5, t["grudge"]) |
|
|
| head_cx = cx - belly * 0.95 |
| head_cy = cy - body_ry * 0.45 |
| head_r = _lerp(16, 22, t["cute"]) * scale |
|
|
| parts: list[str] = [] |
| parts.append( |
| f'<svg xmlns="http://www.w3.org/2000/svg" width="{width}" height="{height}" ' |
| f'viewBox="0 0 {width} {height}">' |
| ) |
| |
| parts.append(f'<ellipse cx="{cx}" cy="{cy + body_ry + 14}" rx="{belly * 1.6}" ry="10" fill="#00000014"/>') |
| |
| leg = belly * 1.25 |
| parts.append( |
| f'<path d="M {cx + belly * 0.5} {cy} q {leg * 0.55} {-leg * 0.7} {leg * 0.32} {leg * 0.05} ' |
| f'l {-leg * 0.12} {leg * 0.62}" stroke="{body_dark}" stroke-width="{5 * scale}" fill="none" stroke-linecap="round"/>' |
| ) |
| |
| parts.append(f'<ellipse cx="{cx}" cy="{cy}" rx="{belly}" ry="{body_ry}" fill="{body_fill}"/>') |
| |
| for i in range(stage + 1): |
| wy = cy - body_ry * 0.55 - i * 4 * scale |
| parts.append( |
| f'<ellipse cx="{cx + 6}" cy="{wy}" rx="{belly * 0.85}" ry="{body_ry * 0.45}" ' |
| f'fill="{body_dark}" opacity="{0.55 - i * 0.12}"/>' |
| ) |
| |
| for i in range(stripes): |
| sx = cx - belly * 0.4 + i * (belly * 0.8 / max(stripes - 1, 1)) |
| parts.append( |
| f'<line x1="{sx}" y1="{cy - body_ry * 0.25}" x2="{sx}" y2="{cy + body_ry * 0.35}" ' |
| f'stroke="{body_dark}" stroke-width="{2 * scale}" opacity="0.6"/>' |
| ) |
| |
| parts.append(f'<circle cx="{head_cx}" cy="{head_cy}" r="{head_r}" fill="{body_fill}"/>') |
| |
| for sign in (-1, 1): |
| ang = (-90 + sign * ant_spread) * 3.14159 / 180 |
| ex = head_cx + ant_len * 1.0 * __import__("math").cos(ang) * (0.6 if sign < 0 else 0.8) |
| ey = head_cy + ant_len * __import__("math").sin(ang) |
| mx = head_cx + sign * 10 |
| my = head_cy - ant_len * 0.6 |
| parts.append( |
| f'<path d="M {head_cx} {head_cy - head_r * 0.7} Q {mx} {my} {ex} {ey}" ' |
| f'stroke="{body_dark}" stroke-width="{2.2 * scale}" fill="none" stroke-linecap="round"/>' |
| ) |
| |
| eye_x, eye_y = head_cx - head_r * 0.25, head_cy - head_r * 0.15 |
| if sick: |
| s = eye_r * 0.8 |
| parts.append( |
| f'<g stroke="#3a3a3a" stroke-width="2.4" stroke-linecap="round">' |
| f'<line x1="{eye_x - s}" y1="{eye_y - s}" x2="{eye_x + s}" y2="{eye_y + s}"/>' |
| f'<line x1="{eye_x - s}" y1="{eye_y + s}" x2="{eye_x + s}" y2="{eye_y - s}"/></g>' |
| ) |
| |
| parts.append(f'<circle cx="{head_cx - head_r * 1.4}" cy="{head_cy - head_r}" r="5" fill="#9ab87a" opacity="0.8"/>') |
| else: |
| parts.append(f'<circle cx="{eye_x}" cy="{eye_y}" r="{eye_r}" fill="#241a12"/>') |
| parts.append(f'<circle cx="{eye_x + eye_r * 0.3}" cy="{eye_y - eye_r * 0.3}" r="{eye_r * 0.3}" fill="#fff"/>') |
| |
| parts.append( |
| f'<line x1="{eye_x - eye_r * 1.4}" y1="{eye_y - eye_r * 1.6 + brow_drop}" ' |
| f'x2="{eye_x + eye_r * 1.2}" y2="{eye_y - eye_r * 1.6 - brow_drop}" ' |
| f'stroke="#241a12" stroke-width="2.6" stroke-linecap="round"/>' |
| ) |
| |
| smile = {"happy": 6, "excited": 7, "loved": 6, "content": 4, "calm": 1, |
| "sad": -5, "angry": -6, "hurt": -6, "disgusted": -4}.get(mood, 1) |
| parts.append( |
| f'<path d="M {head_cx - 7} {head_cy + head_r * 0.45} q 7 {smile} 14 0" ' |
| f'stroke="#241a12" stroke-width="2.2" fill="none" stroke-linecap="round"/>' |
| ) |
| parts.append("</svg>") |
| return "".join(parts) |
|
|
|
|
| def svg_params(traits: dict, feed_count: int, sick: bool, mood: str) -> dict: |
| """快照里存的渲染参数(回放史料).""" |
| return { |
| "traits": {k: round(float(traits.get(k, 0.5)), 4) for k in TRAIT_KEYS}, |
| "stage": molt_stage(feed_count), |
| "sick": bool(sick), |
| "mood": mood, |
| "feed_count": feed_count, |
| } |
|
|