aardio 文档

aardio 范例: 自绘小游戏 - 绒线秘画(毛线拆画 / Thread Unweave)

import win.ui;
/*DSG{{*/
var winform = win.form(text="自绘小游戏 - 绒线秘画(毛线拆画 / Thread Unweave)";right=920;bottom=700;bgcolor=0x171126;border="dialog frame";max=false)
winform.add(
threadCanvas={cls="plus";left=0;top=0;right=920;bottom=700;db=1;dl=1;dr=1;dt=1;notify=1;z=1}
)
/*}}*/

import gdip;

/*
- 玩法:点击绣绷边缘发光的可抽线头,按压线依赖从上到下逐根抽走毛线;
被压线头带红叉,误拉会抖动并提示剩余针脚。支持 H 提示、R/右键重置、通关后单击/空格下一幅,配有 `sys.midiOut` 音效。
- 4 幅递增关卡:月下猫眠(10线)、花间蝶语(11线)、鲸落星海(12线)、锦鲤逐光(13线)。
隐藏画随收线进度由半透明逐步显现,完成后显示收线团、星级、短寿命纤维粒子与绣绷光环。
- 曲线/关卡:Catmull-Rom 采样曲线;只从真实交点中建立压线针脚。
依赖始终由低编号指向高编号,天然构成 DAG。4 关初始均有 2 个合法线头,依赖针脚分别为 8/13/15/16;
自动拆线序列已验证全部可解。
- 毛线表现:4 层线体(阴影/边缘/芯线/高光)+ 斜向捻纹 + 少量边缘绒毛;
交叉锁点用沿上层线方向的短压线针脚强化上下关系。线头用毛线球、编号、红叉与动态发光环区分状态。
- 关键性能经验:在 200% DPI(实际画布 1840×1400)逐帧重画整幅场景只有约 3.4 FPS。
最终改为“DPI 原尺寸场景位图缓存 + 动态层”架构:背景、文字、隐藏画、静态毛线、针脚、侧栏只在状态变化时重建;
逐帧仅贴缓存并绘制当前抽线、线头光环和短寿命粒子。缓存分辨率会跟随 `winform.dpiScale`,兼顾高分屏锐度和跨显示器 DPI。
200% DPI 下抽线阶段实测约 28.3 FPS、平均前景绘制约 23.7 ms,较原版提升约 8 倍;视觉验收无模糊、残影或错层。
*/
// 点击露出的发光线头,把压在上层的毛线逐根抽走。
// 彩线交叉处的小针脚表示压线关系;全部拆除后会显露藏在绣绷里的秘画。
var STATE_PLAYING = 1;
var STATE_SOLVED = 2;

var HOOP_CX = 330;
var HOOP_CY = 385;
var HOOP_RADIUS = 245;
var THREAD_RADIUS = 220;
var PULL_DURATION = 0.72;

var levels = [
    {
        name="月下猫眠";subtitle="月光藏在柔软的线层下面";art="cat";
        strandCount=10;variant=1;maxLocks=1;
        accent=0xFFFFD36A;fabricTop=0xFF241D46;fabricBottom=0xFF11152E;
        colors=[0xFF52E5FF,0xFFFF6FAE,0xFFFFCC58,0xFF9A7CFF,0xFF54E0A4,0xFFFF8A5B,0xFF4FA8FF,0xFFE76DFF,0xFFFFF08A,0xFF7DE8D6]
    },
    {
        name="花间蝶语";subtitle="让被缠住的蝶翼重新舒展";art="butterfly";
        strandCount=11;variant=2;maxLocks=2;
        accent=0xFFFF77C8;fabricTop=0xFF3A173E;fabricBottom=0xFF1A1531;
        colors=[0xFFFF6D9E,0xFFFFA75E,0xFFFFDF6A,0xFF6FE3A5,0xFF55C7FF,0xFF8A84FF,0xFFD56FFF,0xFFFF87D8,0xFF80F0DE,0xFFFFBC72,0xFFB794FF]
    },
    {
        name="鲸落星海";subtitle="潮汐会带走最后一缕乱线";art="whale";
        strandCount=12;variant=3;maxLocks=2;
        accent=0xFF61E8FF;fabricTop=0xFF142C50;fabricBottom=0xFF0A132B;
        colors=[0xFF4BE5FF,0xFF4C9FFF,0xFF6C7CFF,0xFFA66EFF,0xFFFF72C6,0xFFFF8A70,0xFFFFD764,0xFF62E5B1,0xFF61D5E8,0xFFB68CFF,0xFFFFA8D5,0xFF9EF4FF]
    },
    {
        name="锦鲤逐光";subtitle="解开水纹,让好运游回画中";art="koi";
        strandCount=13;variant=4;maxLocks=2;
        accent=0xFFFFB85C;fabricTop=0xFF173843;fabricBottom=0xFF0D202D;
        colors=[0xFFFF745E,0xFFFFA65A,0xFFFFD45E,0xFF73E0A8,0xFF4ED7D1,0xFF55B9FF,0xFF777EFF,0xFFB66DFF,0xFFFF71C6,0xFFFF947D,0xFFA7EB66,0xFF6DEAE4,0xFFFFE29A]
    }
];

var game = {
    state=STATE_PLAYING;
    level=1;
    cycle=1;
    strands=[];
    pulling=null;
    removedCount=0;
    moves=0;
    snags=0;
    pulse=0;
    hintTimer=2600;
    statusText="点击发光的线头,先拆最上层";
    statusTime=2600;
    particles=[];
    fiberClock=0;
    revealGlow=0;
};

// 缓存不会逐帧变化的完整场景,只动态绘制线头、抽线和短寿命粒子。
// 缓存分辨率跟随当前 DPI,逐帧只需原尺寸贴图,兼顾高分屏清晰度与速度。
var SCENE_CACHE_SCALE = winform.dpiScale(1,1);
var sceneCache;
var sceneDirty = true;

var lint = [];
for(i=1;54){
    table.push(lint,{
        x=math.random()*920;y=math.random()*700;
        size=0.5+math.random()*1.8;
        phase=math.random()*math.pi*2;
        drift=0.4+math.random()*0.8;
    });
}

import sys.midiOut;
var midiOut = sys.midiOut();

// -------------------- 曲线与压线关系 --------------------
var sampleCurve = function(anchors,steps){
    var points = [];
    for(i=1;#anchors-1){
        var p0 = anchors[math.max(1,i-1)];
        var p1 = anchors[i];
        var p2 = anchors[i+1];
        var p3 = anchors[math.min(#anchors,i+2)];
        for(step=0;steps-1){
            var t = step/steps;
            var t2 = t*t;
            var t3 = t2*t;
            table.push(points,{
                x=0.5*((2*p1.x)+(-p0.x+p2.x)*t+(2*p0.x-5*p1.x+4*p2.x-p3.x)*t2+(-p0.x+3*p1.x-3*p2.x+p3.x)*t3);
                y=0.5*((2*p1.y)+(-p0.y+p2.y)*t+(2*p0.y-5*p1.y+4*p2.y-p3.y)*t2+(-p0.y+3*p1.y-3*p2.y+p3.y)*t3);
            });
        }
    }
    var last = anchors[#anchors];
    table.push(points,{x=last.x;y=last.y});
    return points;
}

// 每根线从绣绷的一侧穿向另一侧,波幅与相位随关卡变化。
var makeAnchors = function(strandIndex,strandCount,variant){
    var endAngle = -math.pi/2+(strandIndex-1)*math.pi*2/strandCount;
    var sway = ((strandIndex+variant)%3-1)*0.16;
    var startAngle = endAngle+math.pi+sway;
    var sx = HOOP_CX+math.cos(startAngle)*THREAD_RADIUS;
    var sy = HOOP_CY+math.sin(startAngle)*THREAD_RADIUS;
    var ex = HOOP_CX+math.cos(endAngle)*THREAD_RADIUS;
    var ey = HOOP_CY+math.sin(endAngle)*THREAD_RADIUS;
    var dx = ex-sx;
    var dy = ey-sy;
    var length = math.sqrt(dx*dx+dy*dy);
    var nx = -dy/length;
    var ny = dx/length;
    var anchors = [{x=sx;y=sy}];
    for(k=1;3){
        var t = k/4;
        var amplitude = 46+variant*5;
        var wave = math.sin((t*2+strandIndex*0.37+variant*0.21)*math.pi)*amplitude
            + math.cos((strandIndex+k*variant)*0.73)*16;
        table.push(anchors,{
            x=sx+dx*t+nx*wave;
            y=sy+dy*t+ny*wave;
        });
    }
    table.push(anchors,{x=ex;y=ey});
    return anchors;
}

var segmentIntersection = function(a,b,c,d){
    var denominator = (a.x-b.x)*(c.y-d.y)-(a.y-b.y)*(c.x-d.x);
    if(math.abs(denominator)<0.0001) return null;
    var d1 = (b.x-a.x)*(c.y-a.y)-(b.y-a.y)*(c.x-a.x);
    var d2 = (b.x-a.x)*(d.y-a.y)-(b.y-a.y)*(d.x-a.x);
    var d3 = (d.x-c.x)*(a.y-c.y)-(d.y-c.y)*(a.x-c.x);
    var d4 = (d.x-c.x)*(b.y-c.y)-(d.y-c.y)*(b.x-c.x);
    if(d1*d2>=-0.01 || d3*d4>=-0.01) return null;
    var n1 = a.x*b.y-a.y*b.x;
    var n2 = c.x*d.y-c.y*d.x;
    return {
        x=(n1*(c.x-d.x)-(a.x-b.x)*n2)/denominator;
        y=(n1*(c.y-d.y)-(a.y-b.y)*n2)/denominator;
    };
}

var curveIntersection = function(pointsA,pointsB){
    for(i=1;#pointsA-1){
        for(j=1;#pointsB-1){
            var pt = segmentIntersection(pointsA[i],pointsA[i+1],pointsB[j],pointsB[j+1]);
            if(pt){
                pt.indexA=i;
                pt.indexB=j;
                return pt;
            }
        }
    }
}

var isAvailable = function(strand){
    if(strand.removed || game.pulling==strand.id) return false;
    for(i,blockerId in strand.blockers){
        if(!game.strands[blockerId].removed) return false;
    }
    return true;
}

var remainingBlockers = function(strand){
    var count = 0;
    for(i,blockerId in strand.blockers){
        if(!game.strands[blockerId].removed) count++;
    }
    return count;
}

var countAvailable = function(){
    var count = 0;
    for(i,strand in game.strands){
        if(isAvailable(strand)) count++;
    }
    return count;
}

var initLevel = function(levelIndex){
    game.level = levelIndex;
    game.state = STATE_PLAYING;
    game.strands = [];
    game.pulling = null;
    game.removedCount = 0;
    game.moves = 0;
    game.snags = 0;
    game.hintTimer = 2600;
    game.statusText = "点击发光的线头,先拆最上层";
    game.statusTime = 2600;
    game.particles = [];
    game.fiberClock = 0;
    game.revealGlow = 0;
    sceneDirty = true;

    var levelData = levels[levelIndex];
    for(i=1;levelData.strandCount){
        var anchors = makeAnchors(i,levelData.strandCount,levelData.variant);
        table.push(game.strands,{
            id=i;
            color=levelData.colors[(i-1)%#levelData.colors+1];
            points=sampleCurve(anchors,10);
            blockers=[];
            locks=[];
            removed=false;
            pull=0;
            failPulse=0;
        });
    }

    // 只把部分真实交叉点设为“压线针脚”,形成清晰而可解的有向无环依赖。
    // 最上面的两根线没有针脚,玩家始终至少有一个合法起点。
    var freeTop = 2;
    for(i=1;#game.strands-freeTop){
        var candidates = [];
        for(j=i+1;#game.strands){
            var pt = curveIntersection(game.strands[i].points,game.strands[j].points);
            if(pt) table.push(candidates,{id=j;point=pt});
        }

        if(#candidates){
            var pos = (i*levelData.variant*2)%#candidates+1;
            var picked = candidates[pos];
            table.push(game.strands[i].blockers,picked.id);
            table.push(game.strands[i].locks,{blocker=picked.id;point=picked.point});

            if(levelData.maxLocks>=2 && #candidates>=3 && (i+levelData.variant)%2==0){
                var picked2 = candidates[#candidates];
                if(picked2.id==picked.id) picked2=candidates[1];
                if(picked2.id!=picked.id){
                    table.push(game.strands[i].blockers,picked2.id);
                    table.push(game.strands[i].locks,{blocker=picked2.id;point=picked2.point});
                }
            }
        }
    }
}

var nextLevel = function(){
    var nextIndex = game.level+1;
    if(nextIndex>#levels){
        nextIndex=1;
        game.cycle++;
    }
    initLevel(nextIndex);
}

// -------------------- 动画反馈与游戏操作 --------------------
var alphaColor = function(color,alpha){
    alpha = math.floor(math.clamp(alpha,0,255));
    return (alpha<<24)|(color&0x00FFFFFF);
}

var spawnFibers = function(x,y,count,color,burst){
    for(i=1;count){
        var angle = math.random()*math.pi*2;
        var speed = 28+math.random()*(burst ? 150 : 70);
        var life = 0.32+math.random()*0.58;
        table.push(game.particles,{
            x=x;y=y;
            vx=math.cos(angle)*speed;
            vy=math.sin(angle)*speed;
            life=life;maxLife=life;
            length=3+math.random()*8;
            size=1+math.random()*1.8;
            color=color;
        });
    }
}

var pullTip = function(strand){
    var eased = strand.pull*strand.pull*(3-2*strand.pull);
    var count = math.max(2,math.floor(#strand.points*(1-eased)));
    return strand.points[count],count;
}

var finishPull = function(strand){
    var availableBefore = [];
    for(i,s in game.strands) availableBefore[i]=isAvailable(s);

    strand.removed = true;
    strand.pull = 1;
    game.removedCount++;
    game.pulling = null;
    game.moves++;
    sceneDirty = true;

    var endpoint = strand.points[1];
    spawnFibers(endpoint.x,endpoint.y,16,strand.color,false);

    for(i,s in game.strands){
        if(!s.removed && !availableBefore[i] && isAvailable(s)){
            var looseEnd = s.points[#s.points];
            spawnFibers(looseEnd.x,looseEnd.y,7,s.color,false);
        }
    }

    if(game.removedCount==#game.strands){
        game.state = STATE_SOLVED;
        game.revealGlow = 0;
        game.statusText = "秘画已经显现";
        game.statusTime = 999999;
        spawnFibers(HOOP_CX,HOOP_CY,54,levels[game.level].accent,true);
        if(midiOut) midiOut.play("changeInstrument(10), 1_,3_,5_,1'__,5_,3_,1__", "C5", 95, 72);
    }
    else{
        game.statusText = "抽出第 "++strand.id++" 根线,新的线头松开了";
        game.statusTime = 1200;
        if(midiOut) midiOut.play("changeInstrument(10), 3_,5_", "C5", 58);
    }
}

var tryPull = function(strandId){
    if(game.state==STATE_SOLVED){
        nextLevel();
        return;
    }
    if(game.pulling) return;
    var strand = game.strands[strandId];
    if(!strand || strand.removed) return;

    if(!isAvailable(strand)){
        var blockedCount = remainingBlockers(strand);
        strand.failPulse = 1;
        game.snags++;
        game.statusText = "线头 "++strand.id++" 仍被 "++blockedCount++" 个针脚压住";
        game.statusTime = 1300;
        sceneDirty = true;
        var endpoint = strand.points[#strand.points];
        spawnFibers(endpoint.x,endpoint.y,8,0xFFFF668C,false);
        if(midiOut) midiOut.play("changeInstrument(115), 5_,3_", "C3", 80);
        return;
    }

    game.pulling = strand.id;
    strand.pull = 0;
    game.fiberClock = 0;
    game.hintTimer = 0;
    game.statusText = "正在抽出第 "++strand.id++" 根线";
    game.statusTime = 900;
    sceneDirty = true;
    if(midiOut) midiOut.play("changeInstrument(98), 1_,5_", "C6", 38);
}

initLevel(1);

// -------------------- 输入 --------------------
var logicalMousePos = function(lParam){
    var x,y = win.getMessagePos(lParam);
    var dpiX,dpiY = winform.dpiScale(1,1);
    return x/dpiX,y/dpiY;
}

var hitLooseEnd = function(x,y){
    for(i=#game.strands;1;-1){
        var strand = game.strands[i];
        if(!strand.removed && game.pulling!=i){
            var endpoint = strand.points[#strand.points];
            if(math.dist(x,y,endpoint.x,endpoint.y)<=23) return i;
        }
    }
}

winform.threadCanvas.onMouseDown = function(wParam,lParam){
    if(game.state==STATE_SOLVED){
        nextLevel();
        return;
    }
    var x,y = logicalMousePos(lParam);
    var strandId = hitLooseEnd(x,y);
    if(strandId) tryPull(strandId);
}

winform.threadCanvas.onRightMouseUp = function(){
    initLevel(game.level);
}

winform.threadCanvas.dlgCode = 4/*_DLGC_WANTALLKEYS*/;
winform.threadCanvas.onKeyDown = function(keyCode,lParam,repeat){
    if(repeat) return;
    if(keyCode==0x52/*_VK_R*/){
        initLevel(game.level);
    }
    elseif(keyCode==0x48/*_VK_H*/ && game.state==STATE_PLAYING){
        game.hintTimer=2200;
        game.statusText="发光的线头可以安全抽出";
        game.statusTime=2200;
        sceneDirty=true;
        if(midiOut) midiOut.play("changeInstrument(10), 1_,3_", "C6", 45);
    }
    elseif((keyCode==0x20/*_VK_SPACE*/ || keyCode==0x0D/*_VK_RETURN*/) && game.state==STATE_SOLVED){
        nextLevel();
    }
}

// -------------------- 绘图辅助 --------------------
var drawText = function(graphics,text,size,x,y,width,height,color,bold,align){
    var style = bold ? 1/*_FontStyleBold*/ : 0;
    var textFont = gdip.font("Microsoft YaHei UI",size,style,2/*_UnitPixel*/);
    var format = gdip.stringFormat();
    format.align = align;
    format.lineAlign = 1/*_StringAlignmentCenter*/;
    var brush = gdip.solidBrush(color);
    graphics.drawString(text,textFont,::RECTF(x,y,width,height),format,brush);
    brush.delete();
    format.delete();
    textFont.delete();
}

var drawFabric = function(graphics,levelData){
    var fabric = gdip.lineBrush(
        ::POINTF(HOOP_CX,HOOP_CY-HOOP_RADIUS),
        ::POINTF(HOOP_CX,HOOP_CY+HOOP_RADIUS),
        levelData.fabricTop,levelData.fabricBottom
    );
    graphics.fillCircle(fabric,HOOP_CX,HOOP_CY,HOOP_RADIUS);
    fabric.delete();

    // 交错经纬线在拆线过程中逐渐淡去,完成画面不会残留“未拆毛线”的错觉。
    var revealRatio = game.removedCount/#game.strands;
    var lightAlpha = math.floor(14-revealRatio*10);
    var darkAlpha = math.floor(10-revealRatio*7);
    var weavePen1 = gdip.pen((lightAlpha<<24)|0xFFFFFF,0.75);
    var weavePen2 = gdip.pen((darkAlpha<<24)|0x000000,0.75);
    for(pos=-220;220;12){
        graphics.drawLine(weavePen1,HOOP_CX+pos,HOOP_CY-240,HOOP_CX+pos+48,HOOP_CY+240);
        graphics.drawLine(weavePen2,HOOP_CX-240,HOOP_CY+pos,HOOP_CX+240,HOOP_CY+pos+28);
    }
    weavePen2.delete();
    weavePen1.delete();
}

var drawCatArt = function(graphics,alpha){
    // 多层半透明圆柔化月晕,避免两个生硬同心圆。
    for(i=1;5){
        var glowRadius=128-i*8;
        var glowAlpha=alpha*(0.035+i*0.018);
        var moonGlow=gdip.solidBrush(alphaColor(0xFFFFD979,glowAlpha));
        graphics.fillCircle(moonGlow,365,315,glowRadius);
        moonGlow.delete();
    }
    var moon = gdip.solidBrush(alphaColor(0xFFFFE9A5,alpha*0.84));
    graphics.fillCircle(moon,365,315,82);
    moon.delete();

    var hill = gdip.solidBrush(alphaColor(0xFF342857,alpha*0.88));
    graphics.fillClosedCurve(hill,[[85,525],[175,455],[270,510],[390,458],[575,535],[575,630],[85,630]]);
    hill.delete();

    // 坐姿猫使用竖向身体、厚尾巴和前爪,轮廓更舒展。
    var cat = gdip.solidBrush(alphaColor(0xFF201832,alpha));
    graphics.fillEllipse(cat,304,384,128,170);
    graphics.fillCircle(cat,385,365,59);
    graphics.fillPolygon(cat,[[343,333],[355,276],[383,326]]);
    graphics.fillPolygon(cat,[[397,323],[435,282],[431,345]]);
    graphics.fillEllipse(cat,327,492,43,72);
    graphics.fillEllipse(cat,374,493,43,72);
    cat.delete();

    var tail = gdip.pen(alphaColor(0xFF201832,alpha),31);
    graphics.drawCurve2(tail,0.58,[[318,478],[245,445],[213,508],[285,548]]);
    tail.delete();

    var outline = gdip.pen(alphaColor(0xFFCBB6F0,alpha*0.34),2.2);
    graphics.drawEllipse(outline,304,384,128,170);
    graphics.drawCircle(outline,385,365,59);
    graphics.drawPolygon(outline,[[343,333],[355,276],[383,326]]);
    graphics.drawPolygon(outline,[[397,323],[435,282],[431,345]]);
    outline.delete();

    // 弯月形闭眼、鼻尖与胡须让“猫眠”表情更生动。
    var facePen = gdip.pen(alphaColor(0xFFFFD75D,alpha),3.2);
    graphics.drawCurve2(facePen,0.5,[[358,362],[367,369],[376,362]]);
    graphics.drawCurve2(facePen,0.5,[[394,362],[403,369],[412,362]]);
    graphics.drawLine(facePen,384,377,388,379);
    facePen.delete();
    var whisker = gdip.pen(alphaColor(0xFFFFE8B8,alpha*0.7),1.4);
    graphics.drawLine(whisker,370,383,340,378);
    graphics.drawLine(whisker,370,388,338,391);
    graphics.drawLine(whisker,402,383,432,378);
    graphics.drawLine(whisker,402,388,434,391);
    whisker.delete();

    var star = gdip.solidBrush(alphaColor(0xFFFFFFFF,alpha*0.78));
    for(i,pt in [[205,260],[250,205],[475,270],[485,390],[155,360]]){
        graphics.fillCircle(star,pt[1],pt[2],2.2);
        graphics.fillRect(star,pt[1]-0.6,pt[2]-6,1.2,12);
    }
    star.delete();
}

var drawButterflyArt = function(graphics,alpha){
    var glow = gdip.solidBrush(alphaColor(0xFFFF63BE,alpha*0.15));
    graphics.fillCircle(glow,330,375,150);
    glow.delete();

    var pink = gdip.solidBrush(alphaColor(0xFFFF6EBF,alpha*0.82));
    var violet = gdip.solidBrush(alphaColor(0xFF9D79FF,alpha*0.82));
    var gold = gdip.solidBrush(alphaColor(0xFFFFD870,alpha*0.9));
    graphics.fillClosedCurve(pink,[[320,370],[265,245],[155,250],[185,355],[292,397]]);
    graphics.fillClosedCurve(violet,[[340,370],[395,245],[505,250],[475,355],[368,397]]);
    graphics.fillClosedCurve(violet,[[302,405],[220,385],[180,485],[278,508],[326,427]]);
    graphics.fillClosedCurve(pink,[[358,405],[440,385],[480,485],[382,508],[334,427]]);
    graphics.fillCircle(gold,235,315,13);
    graphics.fillCircle(gold,425,315,13);
    graphics.fillCircle(gold,248,450,10);
    graphics.fillCircle(gold,412,450,10);
    gold.delete();violet.delete();pink.delete();

    var body = gdip.solidBrush(alphaColor(0xFF211736,alpha));
    graphics.fillEllipse(body,318,315,24,135);
    graphics.fillCircle(body,330,310,16);
    body.delete();

    var antenna = gdip.pen(alphaColor(0xFFFFE8B5,alpha),3);
    graphics.drawCurve2(antenna,0.5,[[327,300],[300,270],[278,275]]);
    graphics.drawCurve2(antenna,0.5,[[333,300],[360,270],[382,275]]);
    antenna.delete();

    var flower = gdip.solidBrush(alphaColor(0xFFFFB4D8,alpha*0.75));
    for(i,pt in [[150,420],[505,405],[205,545],[455,555]]){
        for(k=0;5){
            var a=k*math.pi/3;
            graphics.fillCircle(flower,pt[1]+math.cos(a)*11,pt[2]+math.sin(a)*11,7);
        }
    }
    flower.delete();
}

var drawWhaleArt = function(graphics,alpha){
    var seaGlow = gdip.solidBrush(alphaColor(0xFF4ADFFF,alpha*0.13));
    graphics.fillEllipse(seaGlow,130,240,410,300);
    seaGlow.delete();

    var whale = gdip.solidBrush(alphaColor(0xFF56BFE8,alpha*0.88));
    graphics.fillClosedCurve(whale,[[145,385],[220,305],[370,285],[505,345],[470,410],[335,455],[205,445]]);
    graphics.fillPolygon(whale,[[155,382],[95,330],[115,395],[82,440],[166,418]]);
    graphics.fillClosedCurve(whale,[[330,430],[390,470],[335,500],[300,448]]);
    whale.delete();

    var belly = gdip.solidBrush(alphaColor(0xFFB7F3FF,alpha*0.58));
    graphics.fillClosedCurve(belly,[[205,405],[320,425],[445,385],[392,438],[270,458]]);
    belly.delete();

    var eye = gdip.solidBrush(alphaColor(0xFF13263D,alpha));
    graphics.fillCircle(eye,426,345,5);
    eye.delete();

    var wavePen = gdip.pen(alphaColor(0xFF8DEEFF,alpha*0.7),3);
    for(i=0;3){
        graphics.drawCurve2(wavePen,0.55,[[145,520+i*18],[235,500+i*18],[330,525+i*18],[425,502+i*18],[515,522+i*18]]);
    }
    wavePen.delete();

    var bubble = gdip.pen(alphaColor(0xFFB9F7FF,alpha*0.72),2);
    for(i,b in [[210,270,8],[260,220,5],[455,245,9],[505,295,5],[165,245,4]]){
        graphics.drawCircle(bubble,b[1],b[2],b[3]);
    }
    bubble.delete();
}

var drawKoiArt = function(graphics,alpha){
    var water = gdip.pen(alphaColor(0xFF74E8D8,alpha*0.32),3);
    graphics.drawArc(water,145,220,370,330,20,145);
    graphics.drawArc(water,125,250,410,310,190,125);
    graphics.drawArc(water,190,285,280,230,35,250);
    water.delete();

    var koi1 = gdip.solidBrush(alphaColor(0xFFFF855F,alpha*0.9));
    graphics.fillClosedCurve(koi1,[[180,330],[255,270],[365,290],[420,350],[355,385],[250,380]]);
    graphics.fillPolygon(koi1,[[185,330],[125,285],[145,345],[112,385],[190,365]]);
    koi1.delete();

    var koi2 = gdip.solidBrush(alphaColor(0xFFFFD071,alpha*0.9));
    graphics.fillClosedCurve(koi2,[[480,455],[405,510],[300,480],[250,425],[320,395],[425,405]]);
    graphics.fillPolygon(koi2,[[478,455],[535,500],[520,440],[555,405],[475,420]]);
    koi2.delete();

    var patch = gdip.solidBrush(alphaColor(0xFFFFFFFF,alpha*0.72));
    graphics.fillEllipse(patch,270,287,72,85);
    graphics.fillEllipse(patch,337,413,70,77);
    patch.delete();

    var eyes = gdip.solidBrush(alphaColor(0xFF1B2635,alpha));
    graphics.fillCircle(eyes,385,328,5);
    graphics.fillCircle(eyes,278,447,5);
    eyes.delete();

    var lotus = gdip.solidBrush(alphaColor(0xFFFF91C8,alpha*0.72));
    for(k=0;7){
        var a=k*math.pi/4;
        graphics.fillEllipse(lotus,180+math.cos(a)*17-7,510+math.sin(a)*12-12,14,24);
    }
    lotus.delete();
}

var drawHiddenArt = function(graphics){
    var ratio = game.removedCount/#game.strands;
    // 初始能隐约辨出主题轮廓,仍保留逐步揭画的期待感。
    var alpha = 78+ratio*177;
    var artName = levels[game.level].art;
    if(artName=="cat") drawCatArt(graphics,alpha);
    elseif(artName=="butterfly") drawButterflyArt(graphics,alpha);
    elseif(artName=="whale") drawWhaleArt(graphics,alpha);
    else drawKoiArt(graphics,alpha);
}

var partialPoints = function(points,count){
    var part = [];
    for(i=1;count) table.push(part,points[i]);
    return part;
}

var drawStrand = function(graphics,strand){
    if(strand.removed) return;
    var count = #strand.points;
    var points = strand.points;
    if(game.pulling==strand.id){
        var tip;
        tip,count = pullTip(strand);
        points = partialPoints(strand.points,count);
    }

    var shadowPen = gdip.pen(0x62000000,15);
    var edgePen = gdip.pen(alphaColor(strand.color,225),11);
    var corePen = gdip.pen(alphaColor(strand.color,255),7.5);
    var shinePen = gdip.pen(0x3EFFFFFF,0.9);
    graphics.drawLines(shadowPen,points);
    graphics.drawLines(edgePen,points);
    graphics.drawLines(corePen,points);
    graphics.drawLines(shinePen,points);
    shinePen.delete();corePen.delete();edgePen.delete();shadowPen.delete();

    // 斜向捻纹 + 线体边缘的短绒毛,削弱光滑塑料管观感。
    var fiberPen = gdip.pen(0x68FFFFFF,0.9);
    var fuzzPen = gdip.pen(alphaColor(strand.color,78),0.75);
    for(i=5;count-2;7){
        var p0=points[i-1];
        var p1=points[i+1];
        var dx=p1.x-p0.x;
        var dy=p1.y-p0.y;
        var length=math.sqrt(dx*dx+dy*dy);
        if(length>0.01){
            var nx=-dy/length;
            var ny=dx/length;
            graphics.drawLine(fiberPen,points[i].x-nx*3.6,points[i].y-ny*3.6,points[i].x+nx*3.6,points[i].y+ny*3.6);
            var direction=1;
            if((i+strand.id)%2==0) direction=-1;
            var hair=4+(i*strand.id)%4;
            graphics.drawLine(fuzzPen,
                points[i].x+nx*direction*4.2,points[i].y+ny*direction*4.2,
                points[i].x+nx*direction*(4.2+hair),points[i].y+ny*direction*(4.2+hair)
            );
        }
    }
    fuzzPen.delete();
    fiberPen.delete();

    if(game.pulling==strand.id){
        var tip = points[#points];
        var tipGlow = gdip.solidBrush(alphaColor(strand.color,72));
        var tipBrush = gdip.solidBrush(strand.color);
        graphics.fillCircle(tipGlow,tip.x,tip.y,13+math.sin(game.pulse/65)*2);
        graphics.fillCircle(tipBrush,tip.x,tip.y,5.5);
        tipBrush.delete();tipGlow.delete();
    }
}

var drawLockStitches = function(graphics,excludedStrandId){
    for(i,strand in game.strands){
        if(!strand.removed && strand.id!=excludedStrandId){
            for(k,lock in strand.locks){
                var blocker = game.strands[lock.blocker];
                if(!blocker.removed && blocker.id!=excludedStrandId){
                    var p=lock.point;
                    var pointIndex=math.clamp(p.indexB,2,#blocker.points-1);
                    var p0=blocker.points[pointIndex-1];
                    var p1=blocker.points[pointIndex+1];
                    var dx=p1.x-p0.x;
                    var dy=p1.y-p0.y;
                    var length=math.sqrt(dx*dx+dy*dy);
                    var tx=dx/length*7.5;
                    var ty=dy/length*7.5;
                    var stitchShadow=gdip.pen(0xD0151028,16);
                    var stitch=gdip.pen(blocker.color,11.5);
                    var gleam=gdip.pen(0x9AFFFFFF,1.2);
                    graphics.drawLine(stitchShadow,p.x-tx,p.y-ty,p.x+tx,p.y+ty);
                    graphics.drawLine(stitch,p.x-tx,p.y-ty,p.x+tx,p.y+ty);
                    graphics.drawLine(gleam,p.x-tx*0.75,p.y-ty*0.75,p.x+tx*0.75,p.y+ty*0.75);
                    gleam.delete();stitch.delete();stitchShadow.delete();
                }
            }
        }
    }
}

var drawLooseEndBase = function(graphics,strand){
    if(strand.removed || game.pulling==strand.id) return;
    var endpoint=strand.points[#strand.points];
    var x=endpoint.x;
    var y=endpoint.y;
    var available=isAvailable(strand);

    var shadow=gdip.solidBrush(0x88000000);
    var ball=gdip.solidBrush(strand.color);
    graphics.fillCircle(shadow,x+2,y+3,16.5);
    graphics.fillCircle(ball,x,y,15.5);
    ball.delete();shadow.delete();

    var yarnPen=gdip.pen(0x72FFFFFF,1.2);
    graphics.drawArc(yarnPen,x-10,y-7,20,14,12,156);
    graphics.drawArc(yarnPen,x-9,y-3,18,12,188,150);
    graphics.drawArc(yarnPen,x-7,y-11,14,22,75,160);
    yarnPen.delete();

    var badge=gdip.solidBrush(0xA8181328);
    graphics.fillCircle(badge,x,y,8.4);
    badge.delete();
    drawText(graphics,tostring(strand.id),strand.id>=10 ? 9 : 10.5,x-9,y-9,18,18,0xFFFFFFFF,true,1);

    if(!available){
        var knotBack=gdip.solidBrush(0xE82A2038);
        graphics.fillCircle(knotBack,x+11,y+11,6.2);
        knotBack.delete();
        var knotPen=gdip.pen(0xFFFF8BA8,1.8);
        graphics.drawLine(knotPen,x+8,y+8,x+14,y+14);
        graphics.drawLine(knotPen,x+14,y+8,x+8,y+14);
        knotPen.delete();
    }
}

var drawLooseEndEffect = function(graphics,strand){
    if(strand.removed || game.pulling==strand.id) return;
    var endpoint=strand.points[#strand.points];
    var pulse=0.5+0.5*math.sin(game.pulse/240+strand.id);

    if(isAvailable(strand)){
        var x=endpoint.x;
        var y=endpoint.y;
        var glowAlpha=game.hintTimer>0 ? 120+pulse*85 : 55+pulse*50;
        var glowRing=gdip.pen(alphaColor(strand.color,glowAlpha),7+2*pulse);
        var readyRing=gdip.pen(game.hintTimer>0 ? 0xEFFFFFFF : 0xA8FFFFFF,2.2);
        graphics.drawCircle(glowRing,x,y,21+3*pulse);
        graphics.drawCircle(readyRing,x,y,17.5+pulse*1.8);
        if(game.hintTimer>0) graphics.drawArc(readyRing,x-22,y-22,44,44,game.pulse/4+strand.id*20,125);
        readyRing.delete();glowRing.delete();
    }
    elseif(strand.failPulse>0){
        var fail=strand.failPulse;
        var shakeX=math.sin(game.pulse/22)*fail*5;
        var failRing=gdip.pen(alphaColor(0xFFFF4779,fail*190),5+fail*3);
        graphics.drawCircle(failRing,endpoint.x+shakeX,endpoint.y,20+fail*3);
        failRing.delete();
    }
}

var drawHoopFrame = function(graphics,levelData){
    var halo = gdip.pen(alphaColor(levelData.accent,30+game.revealGlow*65),22+game.revealGlow*8);
    var outer = gdip.pen(0xFF7A4D2B,18);
    var wood = gdip.pen(0xFFD39755,11);
    var shine = gdip.pen(0xFFFFD994,2.2);
    graphics.drawCircle(halo,HOOP_CX,HOOP_CY,HOOP_RADIUS+2);
    graphics.drawCircle(outer,HOOP_CX,HOOP_CY,HOOP_RADIUS+2);
    graphics.drawCircle(wood,HOOP_CX,HOOP_CY,HOOP_RADIUS+1);
    graphics.drawArc(shine,HOOP_CX-HOOP_RADIUS-1,HOOP_CY-HOOP_RADIUS-1,(HOOP_RADIUS+1)*2,(HOOP_RADIUS+1)*2,198,128);
    shine.delete();wood.delete();outer.delete();halo.delete();

    var clasp = gdip.solidBrush(0xFFD8A964);
    var claspDark = gdip.solidBrush(0xFF684329);
    graphics.fillRoundRect(claspDark,303,116,54,30,8);
    graphics.fillRoundRect(clasp,309,119,42,23,6);
    graphics.fillRect(claspDark,326,104,8,18);
    graphics.fillCircle(clasp,330,104,7);
    claspDark.delete();clasp.delete();
}

var drawYarnBall = function(graphics,x,y,strand){
    var slot = gdip.solidBrush(0x72201A35);
    graphics.fillCircle(slot,x,y,19);
    slot.delete();
    if(strand.removed){
        var glow = gdip.solidBrush(alphaColor(strand.color,45));
        var shadow=gdip.solidBrush(0x76000000);
        var ball = gdip.solidBrush(strand.color);
        graphics.fillCircle(glow,x,y,23);
        graphics.fillEllipse(shadow,x-14,y-11,30,31);
        graphics.fillCircle(ball,x,y,16);
        ball.delete();shadow.delete();glow.delete();
        var darkPen=gdip.pen(0x4A151126,1.4);
        var pen = gdip.pen(0x7AFFFFFF,1.1);
        graphics.drawArc(darkPen,x-13,y-7,26,16,8,165);
        graphics.drawArc(darkPen,x-11,y-1,22,14,178,160);
        graphics.drawArc(pen,x-11,y-9,22,14,10,155);
        graphics.drawArc(pen,x-9,y-3,18,13,185,145);
        graphics.drawArc(pen,x-7,y-13,14,26,72,170);
        graphics.drawCurve2(pen,0.5,[[x+12,y+7],[x+19,y+12],[x+22,y+8]]);
        pen.delete();darkPen.delete();
    }
    else{
        var ring = gdip.pen(0x385E5875,1.2);
        graphics.drawCircle(ring,x,y,18);
        ring.delete();
    }
    drawText(graphics,tostring(strand.id),9,x-10,y-10,20,20,strand.removed ? 0xFFFFFFFF : 0x708E88A2,true,1);
}

var drawSidePanel = function(graphics,width,height){
    var levelData=levels[game.level];
    var panelShadow=gdip.solidBrush(0x48000000);
    var panel=gdip.solidBrush(0xB51C1730);
    graphics.fillRoundRect(panelShadow,626,142,270,516,22);
    graphics.fillRoundRect(panel,620,136,270,516,22);
    panel.delete();panelShadow.delete();

    drawText(graphics,"第 "++game.level++" / "++#levels++" 幅",12,642,151,100,24,0xAFCFC8E5,false,0);
    drawText(graphics,levelData.name,22,638,177,230,38,levelData.accent,true,0);
    drawText(graphics,levelData.subtitle,11,640,214,225,25,0xAFCFC8DF,false,0);

    var progressBack=gdip.solidBrush(0x442D2742);
    var progress=gdip.solidBrush(alphaColor(levelData.accent,205));
    graphics.fillRoundRect(progressBack,644,251,220,10,5);
    graphics.fillRoundRect(progress,644,251,220*game.removedCount/#game.strands,10,5);
    progress.delete();progressBack.delete();

    var status = "已收 "++game.removedCount++" / "++#game.strands++"    可抽 "++countAvailable();
    drawText(graphics,status,12,642,265,222,26,0xFFEAE4F5,true,1);
    drawText(graphics,"收 线 篮",13,642,295,222,26,0xFFB9B0CE,true,1);

    for(i,strand in game.strands){
        var col=(i-1)%4;
        var row=math.floor((i-1)/4);
        drawYarnBall(graphics,670+col*58,340+row*58,strand);
    }

    if(game.state==STATE_PLAYING){
        drawText(graphics,game.statusText,12,640,520,230,46,0xFFDAD3E8,false,1);
    }

    if(game.state==STATE_SOLVED){
        var stars = game.snags==0 ? 3 : (game.snags<=2 ? 2 : 1);
        var starText="";
        for(i=1;3) starText ++= i<=stars ? "★ " : "☆ ";
        drawText(graphics,"秘 画 显 现",18,642,560,222,28,levelData.accent,true,1);
        drawText(graphics,starText,22,642,590,222,30,0xFFFFD66F,true,1);
        drawText(graphics,"单击 / 空格:下一幅",11,642,621,222,22,0xC5EAE4F3,false,1);
    }
    else{
        drawText(graphics,"H:提示可抽线头",11,642,574,222,22,0xAEBFB8D1,false,1);
        drawText(graphics,"R 或右键:重置本幅",11,642,600,222,22,0xAEBFB8D1,false,1);
        drawText(graphics,"误拉 "++game.snags++" 次",11,642,624,222,20,0x8FAFA8C0,false,1);
    }
}

var buildSceneCache = function(){
    if(sceneCache) sceneCache.delete();

    var cacheWidth=math.floor(920*SCENE_CACHE_SCALE);
    var cacheHeight=math.floor(700*SCENE_CACHE_SCALE);
    sceneCache=gdip.bitmap(cacheWidth,cacheHeight);
    var graphics=gdip.graphics(sceneCache);
    graphics.scale(SCENE_CACHE_SCALE,SCENE_CACHE_SCALE);
    graphics.smoothingMode=4/*_SmoothingModeAntiAlias*/;
    graphics.textRenderingHint=4/*_TextRenderingHintAntiAlias*/;

    var levelData=levels[game.level];
    var background=gdip.lineBrush(::POINTF(0,0),::POINTF(920,700),0xFF25162F,0xFF080C19);
    graphics.fillRect(background,0,0,920,700);
    background.delete();

    // 背景绒屑冻结在缓存中;只有真正影响玩法的元素逐帧动画。
    for(i,speck in lint){
        var alpha=math.floor(28+(math.sin(game.pulse/700*speck.drift+speck.phase)+1)*28);
        var lintBrush=gdip.solidBrush((alpha<<24)|0xC9B9DD);
        graphics.fillCircle(lintBrush,speck.x,speck.y,speck.size);
        lintBrush.delete();
    }

    drawText(graphics,"绒 线 秘 画",27,0,14,620,38,0xFFF6EEFF,true,1);
    drawText(graphics,"THREAD UNWEAVE",10,0,50,620,22,0x86CFC4E1,false,1);
    drawText(graphics,"看清交叉处的压线针脚,从最上层线头开始抽离",13,0,79,620,25,0xC7DCD5E8,false,1);

    var boardShadow=gdip.solidBrush(0x58000000);
    graphics.fillCircle(boardShadow,HOOP_CX+10,HOOP_CY+14,HOOP_RADIUS+16);
    boardShadow.delete();

    var clipState=graphics.save();
    var hoopClip=gdip.path();
    hoopClip.addCircle(HOOP_CX,HOOP_CY,HOOP_RADIUS);
    graphics.setClipPath(hoopClip);
    drawFabric(graphics,levelData);
    drawHiddenArt(graphics);

    // 抽动中的毛线留给动态层;其他毛线与针脚只在状态变化时重建。
    for(i,strand in game.strands){
        if(strand.id!=game.pulling) drawStrand(graphics,strand);
    }
    drawLockStitches(graphics,game.pulling);

    // 所有静态线头进入缓存;动态层只叠加呼吸光环与误拉反馈。
    for(i,strand in game.strands) drawLooseEndBase(graphics,strand);

    hoopClip.delete();
    graphics.restore(clipState);
    drawHoopFrame(graphics,levelData);
    drawSidePanel(graphics,920,700);

    graphics.delete();
    sceneDirty=false;
}

winform.threadCanvas.onDrawBackground = function(graphics,rc,backgroundColor,color){
    return true;
}

winform.threadCanvas.onDrawForeground = function(graphics,rc,foregroundRect,foregroundColor,color,font){
    var dpiX,dpiY=winform.dpiScale(1,1);
    if(math.abs(SCENE_CACHE_SCALE-dpiX)>0.001){
        SCENE_CACHE_SCALE=dpiX;
        sceneDirty=true;
    }
    if(sceneDirty || !sceneCache) buildSceneCache();

    graphics.interpolationMode=3/*_InterpolationModeBilinear*/;
    graphics.drawImageRect(sceneCache,0,0,rc.width,rc.height);

    var saved=graphics.save();
    graphics.scale(dpiX,dpiY);
    graphics.smoothingMode=4/*_SmoothingModeAntiAlias*/;
    graphics.textRenderingHint=4/*_TextRenderingHintAntiAlias*/;

    var clipState=graphics.save();
    var hoopClip=gdip.path();
    hoopClip.addCircle(HOOP_CX,HOOP_CY,HOOP_RADIUS);
    graphics.setClipPath(hoopClip);

    if(game.pulling) drawStrand(graphics,game.strands[game.pulling]);
    for(i,strand in game.strands){
        if(isAvailable(strand) || strand.failPulse>0) drawLooseEndEffect(graphics,strand);
    }

    // 短寿命纤维粒子数量受控,既有拉丝感也不会长期积累。
    for(i,p in game.particles){
        var ratio=math.clamp(p.life/p.maxLife,0,1);
        var particlePen=gdip.pen(alphaColor(p.color,ratio*210),p.size);
        var speed=math.sqrt(p.vx*p.vx+p.vy*p.vy);
        var nx=p.length;
        var ny=0;
        if(speed>0.01){
            nx=p.vx/speed*p.length;
            ny=p.vy/speed*p.length;
        }
        graphics.drawLine(particlePen,p.x,p.y,p.x-nx,p.y-ny);
        particlePen.delete();
    }

    hoopClip.delete();
    graphics.restore(clipState);

    if(game.state==STATE_SOLVED && game.revealGlow>0){
        var levelData=levels[game.level];
        var haloAlpha=35+game.revealGlow*(45+20*math.sin(game.pulse/180));
        var solvedHalo=gdip.pen(alphaColor(levelData.accent,haloAlpha),5+game.revealGlow*4);
        graphics.drawCircle(solvedHalo,HOOP_CX,HOOP_CY,HOOP_RADIUS+3);
        solvedHalo.delete();
    }

    graphics.restore(saved);
}

// -------------------- 动画 --------------------
winform.threadCanvas.onAnimation = function(prevState,beginning,change,timestamp,duration,deltaMs){
    deltaMs=math.min(deltaMs,60);
    game.pulse+=deltaMs;
    game.hintTimer=math.max(0,game.hintTimer-deltaMs);
    game.statusTime=math.max(0,game.statusTime-deltaMs);
    if(game.statusTime==0 && game.state==STATE_PLAYING && game.statusText!="选择绣绷边缘的彩色线头"){
        game.statusText="选择绣绷边缘的彩色线头";
        sceneDirty=true;
    }

    var dt=deltaMs/1000;
    for(i,strand in game.strands){
        strand.failPulse=math.max(0,strand.failPulse-dt*2.3);
    }

    if(game.pulling){
        var strand=game.strands[game.pulling];
        strand.pull=math.min(1,strand.pull+dt/PULL_DURATION);
        game.fiberClock+=deltaMs;
        if(game.fiberClock>=54){
            game.fiberClock=0;
            var tip=pullTip(strand);
            spawnFibers(tip.x,tip.y,2,strand.color,false);
        }
        if(strand.pull>=1) finishPull(strand);
    }

    if(game.state==STATE_SOLVED){
        game.revealGlow=math.min(1,game.revealGlow+dt/0.8);
    }

    for(i=#game.particles;1;-1){
        var p=game.particles[i];
        p.x+=p.vx*dt;
        p.y+=p.vy*dt;
        p.vx*=0.985;
        p.vy=p.vy*0.985+12*dt;
        p.life-=dt;
        if(p.life<=0) table.remove(game.particles,i);
    }
    return true;
}

winform.threadCanvas.cursor="hand";
winform.threadCanvas.startAnimation(24);
winform.threadCanvas.setFocus();

winform.show();
win.loopMessage();

Markdown 格式