aardio 文档

aardio 范例: 自绘小游戏 - 见缝插针(Core Ball)

import win.ui;
/*DSG{{*/
var winform = win.form(text="自绘小游戏 - 见缝插针(Core Ball)";right=439;bottom=679;bgcolor=0x0B1020;border="dialog frame";max=false)
winform.add(
gameBox={cls="plus";left=0;top=0;right=440;bottom=680;db=1;dl=1;dr=1;dt=1;notify=1;z=1}
)
/*}}*/

import gdip;
import sys.midiOut;

var midiOut = sys.midiOut();

// 逻辑坐标始终为 440 × 680,绘图时整体适配 DPI。
var VIEW_W = 440;
var VIEW_H = 680;

var GEOMETRY = {
    cx = 220;
    cy = 255;
    coreRadius = 52;
    pinLength = 88;
    ballRadius = 11.5;
    launchTipY = 500;
    shootSpeed = 1600;
    ballGap = 2.5;
}
GEOMETRY.orbitRadius = GEOMETRY.coreRadius + GEOMETRY.pinLength + GEOMETRY.ballRadius;
GEOMETRY.targetTipY = GEOMETRY.cy + GEOMETRY.coreRadius;
GEOMETRY.collisionDistance = GEOMETRY.ballRadius * 2 + GEOMETRY.ballGap;

var LEVELS = [
    {mode="STEADY";speed=70;initial=3;shots=7;phase=18;accent=0xFF4CC9F0},
    {mode="REVERSE";speed=84;initial=4;shots=8;phase=0;accent=0xFF72EFDD},
    {mode="PULSE";speed=92;initial=4;shots=9;phase=27;accent=0xFFFFC857},
    {mode="STOP_GO";speed=103;initial=5;shots=9;phase=9;accent=0xFFFF7A90},
    {mode="ALTERNATE";speed=112;initial=5;shots=10;phase=31;accent=0xFFC77DFF},
    {mode="STEADY";speed=128;initial=6;shots=10;phase=15;accent=0xFF5EE27A},
    {mode="REVERSE_PULSE";speed=122;initial=6;shots=11;phase=4;accent=0xFFFF9F43},
    {mode="STOP_GO";speed=136;initial=6;shots=11;phase=23;accent=0xFF54A0FF},
    {mode="ALTERNATE";speed=142;initial=7;shots=11;phase=11;accent=0xFFFF6B9D},
    {mode="PULSE";speed=148;initial=7;shots=12;phase=29;accent=0xFF7BED9F}
];

var MODE_POOL = ["STEADY","REVERSE","PULSE","STOP_GO","ALTERNATE","REVERSE_PULSE"];
var ACCENTS = [0xFF4CC9F0,0xFF72EFDD,0xFFFFC857,0xFFFF7A90,0xFFC77DFF,0xFF5EE27A];

var Game = {
    level = 1;
    maxUnlocked = 1;
    status = "playing"; // playing / paused / won / lost
    soundEnabled = true;

    mode = "STEADY";
    baseSpeed = 70;
    currentSpeed = 70;
    accent = 0xFF4CC9F0;
    rotation = 0;
    clock = 0;

    pins = [];
    shots = [];
    queue = 0;
    shotCooldown = 0;

    particles = [];
    ripples = [];
    corePulse = 0;
    launchKick = 0;
    shake = 0;
    shakeX = 0;
    shakeY = 0;
    flashColor = 0xFFFFFFFF;
    flashAlpha = 0;
}

var normalizeAngle = function(angle){
    angle = angle % 360;
    if(angle < 0) angle += 360;
    return angle;
}

var angleDistance = function(a,b){
    var diff = math.abs(normalizeAngle(a) - normalizeAngle(b));
    return (diff > 180) ? 360 - diff : diff;
}

// 点到线段距离平方,用于高速飞针的连续碰撞检测,避免低帧率穿透。
var pointSegmentDistance2 = function(px,py,x1,y1,x2,y2){
    var vx = x2 - x1;
    var vy = y2 - y1;
    var length2 = vx * vx + vy * vy;
    if(length2 <= 0.000001){
        var dx = px - x1;
        var dy = py - y1;
        return dx * dx + dy * dy;
    }

    var t = ((px - x1) * vx + (py - y1) * vy) / length2;
    t = math.clamp(t,0,1);
    var qx = x1 + vx * t;
    var qy = y1 + vy * t;
    var dx = px - qx;
    var dy = py - qy;
    return dx * dx + dy * dy;
}

// 所有已插针共享同一旋转量,所以在局部角度中即可做精确弦长判定。
var checkAngleCollision = function(newLocalAngle,pinList){
    for(i=1;#pinList;1){
        var diff = angleDistance(newLocalAngle,pinList[i].angle);
        var chord = 2 * GEOMETRY.orbitRadius * math.sin(diff * math.pi / 360);
        if(chord < GEOMETRY.collisionDistance) return true,i;
    }
    return false;
}

var getLevelConfig = function(lv){
    if(lv <= #LEVELS) return LEVELS[lv];

    var modeIndex = ((lv - 1) % #MODE_POOL) + 1;
    var accentIndex = ((lv - 1) % #ACCENTS) + 1;
    return {
        mode = MODE_POOL[modeIndex];
        speed = 105 + (lv * 13) % 58;
        initial = 5 + (lv % 3);
        shots = math.min(14,9 + math.floor(lv / 3));
        phase = (lv * 37) % 360;
        accent = ACCENTS[accentIndex];
    }
}

var getModeName = function(mode){
    if(mode == "REVERSE") return "逆向匀速";
    if(mode == "PULSE") return "呼吸变速";
    if(mode == "STOP_GO") return "节拍启停";
    if(mode == "ALTERNATE") return "平滑往复";
    if(mode == "REVERSE_PULSE") return "逆向脉冲";
    return "匀速旋转";
}

var getAngularSpeed = function(){
    var base = Game.baseSpeed;
    var t = Game.clock;

    if(Game.mode == "REVERSE") return -base;
    if(Game.mode == "PULSE") return base * (0.42 + 1.08 * math.abs(math.sin(t * 2.05)));
    if(Game.mode == "REVERSE_PULSE") return -base * (0.42 + 1.08 * math.abs(math.sin(t * 2.15)));
    if(Game.mode == "ALTERNATE") return base * 1.42 * math.sin(t * 1.72);

    if(Game.mode == "STOP_GO"){
        var cycle = t % 2.5;
        if(cycle < 1.42) return base * 1.34;
        if(cycle < 1.72) return base * 1.34 * (1 - (cycle - 1.42) / 0.3);
        if(cycle < 2.16) return 0;
        return base * 1.34 * ((cycle - 2.16) / 0.34);
    }

    return base;
}

var playShootSound = function(){
    if(Game.soundEnabled && midiOut) midiOut.play("changeInstrument(115),5_","C6",38);
}

var playInsertSound = function(){
    if(Game.soundEnabled && midiOut) midiOut.play("changeInstrument(10),1_","C6",42);
}

var playLoseSound = function(){
    if(Game.soundEnabled && midiOut) midiOut.play("changeInstrument(47),7_,6_,5__","C3",145,25);
}

var playWinSound = function(){
    if(Game.soundEnabled && midiOut) midiOut.play("changeInstrument(10),1_,3_,5_,1'__","C4",105,35);
}

var addRipple = function(x,y,color,startRadius,maxRadius){
    table.push(Game.ripples,{
        x=x;y=y;color=color;r=startRadius;maxRadius=maxRadius;life=0;maxLife=0.42
    });
}

var addBurst = function(x,y,color,count,power,gravity){
    for(i=1;count;1){
        var angle = math.random() * math.pi * 2;
        var speed = power * (0.4 + math.random() * 0.75);
        table.push(Game.particles,{
            x=x;y=y;
            vx=math.cos(angle)*speed;
            vy=math.sin(angle)*speed - gravity * 0.12;
            gravity=gravity;
            life=0;
            maxLife=0.34 + math.random() * 0.48;
            size=2.2 + math.random() * 3.8;
            color=color;
            streak=(i % 3 == 0)
        });
    }
}

var addConfetti = function(){
    var colors = [Game.accent,0xFFFFC857,0xFFFF6B9D,0xFF72EFDD,0xFFFFFFFF];
    for(i=1;64;1){
        var angle = math.random() * math.pi * 2;
        var speed = 120 + math.random() * 260;
        table.push(Game.particles,{
            x=GEOMETRY.cx;y=GEOMETRY.cy;
            vx=math.cos(angle)*speed;
            vy=math.sin(angle)*speed-95;
            gravity=270;
            life=0;
            maxLife=0.85+math.random()*0.65;
            size=3+math.random()*4.5;
            color=colors[math.random(1,#colors)];
            streak=true
        });
    }
}

var startLevel = function(lv){
    lv = math.max(1,lv);
    var cfg = getLevelConfig(lv);

    Game.level = lv;
    Game.status = "playing";
    Game.mode = cfg.mode;
    Game.baseSpeed = cfg.speed;
    Game.currentSpeed = cfg.speed;
    Game.accent = cfg.accent;
    Game.rotation = 0;
    Game.clock = 0;
    Game.queue = cfg.shots;
    Game.shotCooldown = 0;
    Game.pins = [];
    Game.shots = [];
    Game.particles = [];
    Game.ripples = [];
    Game.corePulse = 0;
    Game.launchKick = 0;
    Game.shake = 0;
    Game.shakeX = 0;
    Game.shakeY = 0;
    Game.flashAlpha = 0;

    for(i=1;cfg.initial;1){
        table.push(Game.pins,{
            angle=normalizeAngle((i-1)*360/cfg.initial+cfg.phase);
            num=null;
            initial=true;
            hit=false
        });
    }
}

var loseLevel = function(x,y,pinIndex){
    if(Game.status != "playing") return;

    Game.status = "lost";
    Game.shots = [];
    Game.shake = 16;
    Game.flashColor = 0xFFFF4D67;
    Game.flashAlpha = 0.48;
    if(pinIndex) Game.pins[pinIndex].hit = true;
    addRipple(x,y,0xFFFF4D67,9,62);
    addBurst(x,y,0xFFFF4D67,34,260,210);
    playLoseSound();
}

var winLevel = function(){
    if(Game.status != "playing") return;

    Game.status = "won";
    Game.maxUnlocked = math.max(Game.maxUnlocked,Game.level+1);
    Game.flashColor = Game.accent;
    Game.flashAlpha = 0.34;
    Game.corePulse = 1;
    addRipple(GEOMETRY.cx,GEOMETRY.cy,Game.accent,GEOMETRY.coreRadius,170);
    addConfetti();
    playWinSound();
}

// 按下立即发射。视觉挤压改为发射后的反冲,不再用松开事件增加输入延迟。
var firePin = function(){
    if(Game.status == "lost"){
        startLevel(Game.level);
        return;
    }
    if(Game.status == "won"){
        startLevel(Game.level+1);
        return;
    }
    if(Game.status == "paused"){
        Game.status = "playing";
        return;
    }
    if(Game.status != "playing") return;
    if(Game.queue <= 0 || Game.shotCooldown > 0 || #Game.shots >= 3) return;

    var num = Game.queue;
    Game.queue--;
    Game.shotCooldown = 0.075;
    Game.launchKick = 1;

    table.push(Game.shots,{
        tipY=GEOMETRY.launchTipY;
        age=0;
        num=num
    });

    addRipple(GEOMETRY.cx,GEOMETRY.launchTipY+GEOMETRY.pinLength+GEOMETRY.ballRadius,Game.accent,7,30);
    playShootSound();
}

startLevel(1);

// 常驻绘图资源。画笔使用世界单位,使线宽随 DPI 整体缩放。
var Draw = {
    outerBg = gdip.solidBrush(0xFF070B16);
    canvasBg = gdip.solidBrush(0xFF0B1020);
    panelBrush = gdip.solidBrush(0xB8151D31);
    maskBrush = gdip.solidBrush(0xCC080C17);
    textBrush = gdip.solidBrush(0xFFF4F7FB);
    subTextBrush = gdip.solidBrush(0xFFD0D8E5);
    darkTextBrush = gdip.solidBrush(0xFF09101E);
    fxBrush = gdip.solidBrush(0xFFFFFFFF);

    guidePen = gdip.pen(0x243E5575,1.2);
    pinShadowPen = gdip.pen(0x95040911,5.2);
    pinPen = gdip.pen(0xFFD7DFEC,2.4);
    accentPen = gdip.pen(0xFF4CC9F0,2.5);
    fxPen = gdip.pen(0xFFFFFFFF,2);

    titleFont = gdip.font("Segoe UI",22,1,2/*_UnitPixel*/);
    levelFont = gdip.font("Segoe UI",17,1,2/*_UnitPixel*/);
    labelFont = gdip.font("Microsoft YaHei UI",11,0,2/*_UnitPixel*/);
    smallFont = gdip.font("Microsoft YaHei UI",10.5,0,2/*_UnitPixel*/);
    numberFont = gdip.font("Segoe UI",10,1,2/*_UnitPixel*/);
    coreNumberFont = gdip.font("Segoe UI",31,1,2/*_UnitPixel*/);
    resultFont = gdip.font("Microsoft YaHei UI",24,1,2/*_UnitPixel*/);

    centerFormat = gdip.stringFormat();
    leftFormat = gdip.stringFormat();
    rightFormat = gdip.stringFormat();
}
Draw.centerFormat.align = 1;
Draw.centerFormat.lineAlign = 1;
Draw.leftFormat.align = 0;
Draw.leftFormat.lineAlign = 1;
Draw.rightFormat.align = 2;
Draw.rightFormat.lineAlign = 1;
Draw.guidePen.dashStyle = 2/*_DashStyleDot*/;

var ARGB = function(alpha,color){
    alpha = math.clamp(math.floor(alpha),0,255);
    return (alpha << 24) | (color & 0x00FFFFFF);
}

var drawText = function(graphics,text,font,x,y,width,height,format,brush){
    graphics.drawString(text,font,::RECTF(x,y,width,height),format,brush);
}

var drawStaticBackdrop = function(graphics){
    graphics.fillRectangle(Draw.canvasBg,0,0,VIEW_W,VIEW_H);

    // 低成本的固定星点与同心轨道,避免逐帧创建图形资源。
    for(i=1;28;1){
        var x = 12 + (i * 83) % 416;
        var y = 82 + (i * 47) % 350;
        var radius = 0.55 + (i % 3) * 0.35;
        Draw.fxBrush.color = ARGB(35 + (i % 4) * 12,0xFFFFFFFF);
        graphics.fillCircle(Draw.fxBrush,x,y,radius);
    }

    Draw.fxPen.color = 0x182A3850;
    Draw.fxPen.width = 1;
    graphics.drawCircle(Draw.fxPen,GEOMETRY.cx,GEOMETRY.cy,GEOMETRY.orbitRadius);
    graphics.drawCircle(Draw.fxPen,GEOMETRY.cx,GEOMETRY.cy,GEOMETRY.orbitRadius+18);

    // 顶部信息栏。
    drawText(graphics,"CORE BALL",Draw.titleFont,20,17,180,34,Draw.leftFormat,Draw.textBrush);
    drawText(graphics,"LEVEL " + string.format("%02d",Game.level),Draw.levelFont,20,52,150,26,Draw.leftFormat,Draw.textBrush);
    drawText(graphics,"已解锁 " + Game.maxUnlocked,Draw.smallFont,286,24,134,20,Draw.rightFormat,Draw.subTextBrush);

    Draw.fxBrush.color = ARGB(32,Game.accent);
    graphics.fillRoundRect(Draw.fxBrush,154,52,132,27,13);
    Draw.fxPen.color = ARGB(135,Game.accent);
    Draw.fxPen.width = 1.2;
    graphics.drawRoundRect(Draw.fxPen,154,52,132,27,13);
    drawText(graphics,getModeName(Game.mode),Draw.smallFont,154,52,132,27,Draw.centerFormat,Draw.textBrush);

    var soundText = Game.soundEnabled ? "M  音效开" : "M  已静音";
    drawText(graphics,soundText,Draw.smallFont,294,53,126,22,Draw.rightFormat,Draw.subTextBrush);

    // 两侧淡导轨只提示发射方向,不把飞行针与下一针串成一条线。
    graphics.drawLine(Draw.guidePen,GEOMETRY.cx-7,GEOMETRY.targetTipY+17,GEOMETRY.cx-7,GEOMETRY.launchTipY-15);
    graphics.drawLine(Draw.guidePen,GEOMETRY.cx+7,GEOMETRY.targetTipY+17,GEOMETRY.cx+7,GEOMETRY.launchTipY-15);
}

var drawPin = function(graphics,pin,dx,dy){
    var worldAngle = normalizeAngle(Game.rotation+pin.angle);
    var rad = worldAngle*math.pi/180;
    var cosValue = math.cos(rad);
    var sinValue = math.sin(rad);

    var x1 = GEOMETRY.cx+dx+GEOMETRY.coreRadius*cosValue;
    var y1 = GEOMETRY.cy+dy+GEOMETRY.coreRadius*sinValue;
    var x2 = GEOMETRY.cx+dx+(GEOMETRY.orbitRadius-GEOMETRY.ballRadius)*cosValue;
    var y2 = GEOMETRY.cy+dy+(GEOMETRY.orbitRadius-GEOMETRY.ballRadius)*sinValue;
    var bx = GEOMETRY.cx+dx+GEOMETRY.orbitRadius*cosValue;
    var by = GEOMETRY.cy+dy+GEOMETRY.orbitRadius*sinValue;

    var pinColor = pin.hit ? 0xFFFF4D67 : (pin.initial ? 0xFFD7DFEC : Game.accent);
    Draw.pinPen.color = pinColor;
    graphics.drawLine(Draw.pinShadowPen,x1,y1,x2,y2);
    graphics.drawLine(Draw.pinPen,x1,y1,x2,y2);

    Draw.fxBrush.color = 0x70000000;
    graphics.fillCircle(Draw.fxBrush,bx+2.2,by+3,GEOMETRY.ballRadius+1.3);
    Draw.fxBrush.color = pinColor;
    graphics.fillCircle(Draw.fxBrush,bx,by,GEOMETRY.ballRadius);
    Draw.fxBrush.color = ARGB(92,0xFFFFFFFF);
    graphics.fillCircle(Draw.fxBrush,bx-3.2,by-3.6,2.5);

    if(pin.num){
        drawText(graphics,pin.num,Draw.numberFont,bx-GEOMETRY.ballRadius,by-GEOMETRY.ballRadius,
            GEOMETRY.ballRadius*2,GEOMETRY.ballRadius*2,Draw.centerFormat,Draw.darkTextBrush);
    }
}

var drawCoreAndPins = function(graphics,dx,dy){
    var cx = GEOMETRY.cx+dx;
    var cy = GEOMETRY.cy+dy;
    var pulse = Game.corePulse;
    var radius = GEOMETRY.coreRadius*(1+0.075*pulse);

    // 光晕与旋向弧放在针杆后面,针杆不会被弧线切断。
    Draw.fxBrush.color = ARGB(22+42*pulse,Game.accent);
    graphics.fillCircle(Draw.fxBrush,cx,cy,radius+18+8*pulse);
    var sweep = (Game.currentSpeed >= 0) ? 58 : -58;
    Draw.fxPen.color = ARGB(165,Game.accent);
    Draw.fxPen.width = 2;
    graphics.drawArc(Draw.fxPen,cx-radius-10,cy-radius-10,(radius+10)*2,(radius+10)*2,
        normalizeAngle(Game.rotation-28),sweep);

    for(i=1;#Game.pins;1) drawPin(graphics,Game.pins[i],dx,dy);

    Draw.fxBrush.color = 0xFF151D2D;
    if(Game.status == "lost") Draw.fxBrush.color = 0xFF321826;
    graphics.fillCircle(Draw.fxBrush,cx,cy,radius);

    Draw.accentPen.color = (Game.status == "lost") ? 0xFFFF4D67 : Game.accent;
    Draw.accentPen.width = 3;
    graphics.drawCircle(Draw.accentPen,cx,cy,radius);

    var pending = Game.queue+#Game.shots;
    drawText(graphics,pending,Draw.coreNumberFont,cx-radius,cy-radius-8,radius*2,radius*1.65,
        Draw.centerFormat,Draw.textBrush);
    drawText(graphics,"待插",Draw.smallFont,cx-radius,cy+22,radius*2,19,
        Draw.centerFormat,Draw.subTextBrush);
}

var drawShot = function(graphics,shot,dx,dy){
    var tipY = shot.tipY+dy;
    var ballY = shot.tipY+GEOMETRY.pinLength+GEOMETRY.ballRadius+dy;
    var cx = GEOMETRY.cx+dx;

    Draw.fxPen.color = ARGB(55,Game.accent);
    Draw.fxPen.width = 6;
    graphics.drawLine(Draw.fxPen,cx,tipY+4,cx,ballY-GEOMETRY.ballRadius);
    Draw.accentPen.color = Game.accent;
    Draw.accentPen.width = 2.6;
    graphics.drawLine(Draw.accentPen,cx,tipY,cx,ballY-GEOMETRY.ballRadius);

    var squeeze = math.max(0,1-shot.age/0.075);
    var ballW = GEOMETRY.ballRadius*2*(1+0.32*squeeze);
    var ballH = GEOMETRY.ballRadius*2*(1-0.28*squeeze);
    Draw.fxBrush.color = Game.accent;
    graphics.fillEllipse(Draw.fxBrush,cx-ballW/2,ballY-ballH/2,ballW,ballH);
    Draw.fxBrush.color = ARGB(90,0xFFFFFFFF);
    graphics.fillCircle(Draw.fxBrush,cx-3,ballY-3,2.3);
    drawText(graphics,shot.num,Draw.numberFont,cx-GEOMETRY.ballRadius,ballY-GEOMETRY.ballRadius,
        GEOMETRY.ballRadius*2,GEOMETRY.ballRadius*2,Draw.centerFormat,Draw.darkTextBrush);
}

var drawLauncher = function(graphics){
    if(Game.status != "playing" || Game.queue <= 0) return;

    var kick = Game.launchKick;
    var tipY = GEOMETRY.launchTipY+kick*7;
    var ballY = tipY+GEOMETRY.pinLength+GEOMETRY.ballRadius;
    var ballW = GEOMETRY.ballRadius*2*(1+0.28*kick);
    var ballH = GEOMETRY.ballRadius*2*(1-0.25*kick);

    Draw.fxPen.color = ARGB(55+80*kick,Game.accent);
    Draw.fxPen.width = 1.5+kick*2;
    graphics.drawCircle(Draw.fxPen,GEOMETRY.cx,ballY,GEOMETRY.ballRadius+8+kick*10);

    Draw.accentPen.color = Game.accent;
    Draw.accentPen.width = 2.3;
    graphics.drawLine(Draw.accentPen,GEOMETRY.cx,tipY,GEOMETRY.cx,ballY-ballH/2);
    Draw.fxBrush.color = Game.accent;
    graphics.fillEllipse(Draw.fxBrush,GEOMETRY.cx-ballW/2,ballY-ballH/2,ballW,ballH);
    drawText(graphics,Game.queue,Draw.numberFont,GEOMETRY.cx-GEOMETRY.ballRadius,ballY-GEOMETRY.ballRadius,
        GEOMETRY.ballRadius*2,GEOMETRY.ballRadius*2,Draw.centerFormat,Draw.darkTextBrush);
    drawText(graphics,"下一针",Draw.smallFont,GEOMETRY.cx+22,ballY-11,64,22,Draw.leftFormat,Draw.subTextBrush);

    var showCount = math.min(7,Game.queue-1);
    var firstX = GEOMETRY.cx-(showCount-1)*13;
    for(i=1;showCount;1){
        var alpha = 185-(i-1)*18;
        Draw.fxBrush.color = ARGB(alpha,Game.accent);
        graphics.fillCircle(Draw.fxBrush,firstX+(i-1)*26,632,4.4);
    }
}

var drawEffects = function(graphics,dx,dy){
    for(i=1;#Game.ripples;1){
        var ripple = Game.ripples[i];
        var alpha = 190*(1-ripple.life/ripple.maxLife);
        Draw.fxPen.color = ARGB(alpha,ripple.color);
        Draw.fxPen.width = 2.2;
        graphics.drawCircle(Draw.fxPen,ripple.x+dx,ripple.y+dy,ripple.r);
    }

    for(i=1;#Game.particles;1){
        var particle = Game.particles[i];
        var alpha = 255*(1-particle.life/particle.maxLife);
        if(particle.streak){
            Draw.fxPen.color = ARGB(alpha,particle.color);
            Draw.fxPen.width = math.max(1,particle.size*0.42);
            graphics.drawLine(Draw.fxPen,particle.x+dx,particle.y+dy,
                particle.x-particle.vx*0.025+dx,particle.y-particle.vy*0.025+dy);
        }
        else{
            Draw.fxBrush.color = ARGB(alpha,particle.color);
            graphics.fillCircle(Draw.fxBrush,particle.x+dx,particle.y+dy,particle.size);
        }
    }
}

var drawBottomUi = function(graphics){
    var speed = math.floor(math.abs(Game.currentSpeed)+0.5);
    var direction = (Game.currentSpeed >= 0) ? "顺时针" : "逆时针";
    if(math.abs(Game.currentSpeed) < 1) direction = "停顿";

    drawText(graphics,direction+"  "+speed+"°/s",Draw.smallFont,20,452,150,22,Draw.leftFormat,Draw.subTextBrush);
    drawText(graphics,"按下即发射 · 鼠标 / 空格",Draw.labelFont,50,646,340,24,Draw.centerFormat,Draw.subTextBrush);
}

var drawStateOverlay = function(graphics){
    if(Game.status == "playing") return;

    Draw.maskBrush.color = 0xBC080C17;
    graphics.fillRectangle(Draw.maskBrush,0,0,VIEW_W,VIEW_H);
    Draw.panelBrush.color = 0xEC151D31;
    graphics.fillRoundRect(Draw.panelBrush,46,250,348,164,18);

    var title = "暂停";
    var detail = "点击画面或按 P 继续";
    var titleColor = Game.accent;

    if(Game.status == "lost"){
        title = "碰撞了";
        detail = "点击重试 · R 重新开始";
        titleColor = 0xFFFF4D67;
    }
    elseif(Game.status == "won"){
        title = "关卡完成";
        detail = "点击进入下一关";
        titleColor = Game.accent;
    }

    Draw.fxBrush.color = titleColor;
    drawText(graphics,title,Draw.resultFont,66,274,308,48,Draw.centerFormat,Draw.fxBrush);
    drawText(graphics,detail,Draw.labelFont,66,329,308,30,Draw.centerFormat,Draw.textBrush);

    if(Game.status != "paused"){
        drawText(graphics,"← / → 选择已解锁关卡",Draw.smallFont,66,368,308,22,Draw.centerFormat,Draw.subTextBrush);
    }
}

winform.gameBox.onDrawBackground = function(graphics,rc,backgroundColor,foregroundColor){
    graphics.clear(0xFF070B16);
    return true;
}

winform.gameBox.onDrawForeground = function(graphics,rc,foregroundRect,foregroundColor,color,font){
    graphics.smoothingMode = 4/*_SmoothingModeAntiAlias*/;
    graphics.textRenderingHint = 4/*_TextRenderingHintAntiAlias*/;

    graphics.fillRectangle(Draw.outerBg,0,0,rc.width,rc.height);

    var scale = math.min(rc.width/VIEW_W,rc.height/VIEW_H);
    var offsetX = (rc.width-VIEW_W*scale)/2;
    var offsetY = (rc.height-VIEW_H*scale)/2;

    var state = graphics.save();
    graphics.translate(offsetX,offsetY);
    graphics.scale(scale,scale);
    graphics.setClipRect(0,0,VIEW_W,VIEW_H);

    drawStaticBackdrop(graphics);

    var dx = Game.shakeX;
    var dy = Game.shakeY;
    drawCoreAndPins(graphics,dx,dy);
    for(i=1;#Game.shots;1) drawShot(graphics,Game.shots[i],dx,dy);
    drawLauncher(graphics);
    drawEffects(graphics,dx,dy);
    drawBottomUi(graphics);

    if(Game.flashAlpha > 0){
        Draw.fxBrush.color = ARGB(Game.flashAlpha*255,Game.flashColor);
        graphics.fillRectangle(Draw.fxBrush,0,0,VIEW_W,VIEW_H);
    }

    drawStateOverlay(graphics);
    graphics.restore(state);
}

var insertShot = function(shot){
    var localAngle = normalizeAngle(90-Game.rotation);
    var collided,pinIndex = checkAngleCollision(localAngle,Game.pins);
    if(collided){
        loseLevel(GEOMETRY.cx,GEOMETRY.cy+GEOMETRY.orbitRadius,pinIndex);
        return false;
    }

    table.push(Game.pins,{
        angle=localAngle;
        num=shot.num;
        initial=false;
        hit=false
    });

    Game.corePulse = 1;
    Game.shake = math.max(Game.shake,2.6);
    addRipple(GEOMETRY.cx,GEOMETRY.cy+GEOMETRY.coreRadius,Game.accent,8,42);
    addBurst(GEOMETRY.cx,GEOMETRY.cy+GEOMETRY.orbitRadius,Game.accent,9,115,90);
    playInsertSound();
    return true;
}

winform.gameBox.onAnimation = function(state,beginning,change,timestamp,duration,deltaMs){
    var dt = deltaMs/1000;
    if(dt <= 0) dt = 0.016;
    dt = math.min(dt,0.055); // 长时间切出后不让游戏在不可见状态跳过半圈。

    if(Game.status == "playing" || Game.status == "won"){
        Game.clock += dt;
        var speed = getAngularSpeed();
        if(Game.status == "won") speed *= 0.35;
        Game.currentSpeed = speed;
        Game.rotation = normalizeAngle(Game.rotation+speed*dt);
    }

    Game.shotCooldown = math.max(0,Game.shotCooldown-dt);
    Game.corePulse = math.max(0,Game.corePulse-dt*4.8);
    Game.launchKick = math.max(0,Game.launchKick-dt*8.5);
    Game.flashAlpha = math.max(0,Game.flashAlpha-dt*1.9);

    if(Game.shake > 0.08){
        Game.shake *= math.exp(-12*dt);
        Game.shakeX = (math.random()-0.5)*Game.shake*2;
        Game.shakeY = (math.random()-0.5)*Game.shake*2;
    }
    else{
        Game.shake = 0;
        Game.shakeX = 0;
        Game.shakeY = 0;
    }

    if(Game.status == "playing" && #Game.shots){
        var aliveShots = [];
        var collisionRadius2 = GEOMETRY.collisionDistance*GEOMETRY.collisionDistance;

        for(i=1;#Game.shots;1){
            var shot = Game.shots[i];
            shot.age += dt;

            var oldTipY = shot.tipY;
            var newTipY = oldTipY-GEOMETRY.shootSpeed*dt;
            var oldBallY = oldTipY+GEOMETRY.pinLength+GEOMETRY.ballRadius;
            var newBallY = newTipY+GEOMETRY.pinLength+GEOMETRY.ballRadius;
            var collidedIndex = null;
            var hitY = newBallY;

            for(k=1;#Game.pins;1){
                var pin = Game.pins[k];
                var worldAngle = normalizeAngle(Game.rotation+pin.angle);
                var rad = worldAngle*math.pi/180;
                var pinX = GEOMETRY.cx+GEOMETRY.orbitRadius*math.cos(rad);
                var pinY = GEOMETRY.cy+GEOMETRY.orbitRadius*math.sin(rad);

                if(pointSegmentDistance2(pinX,pinY,GEOMETRY.cx,oldBallY,GEOMETRY.cx,newBallY) < collisionRadius2){
                    collidedIndex = k;
                    hitY = math.clamp(pinY,newBallY,oldBallY);
                    break;
                }
            }

            if(collidedIndex){
                loseLevel(GEOMETRY.cx,hitY,collidedIndex);
                break;
            }

            shot.tipY = newTipY;
            if(shot.tipY <= GEOMETRY.targetTipY){
                shot.tipY = GEOMETRY.targetTipY;
                if(!insertShot(shot)) break;
            }
            else{
                table.push(aliveShots,shot);
            }
        }

        if(Game.status == "playing"){
            Game.shots = aliveShots;
            if(Game.queue == 0 && #Game.shots == 0) winLevel();
        }
    }

    var aliveParticles = [];
    for(i=1;#Game.particles;1){
        var particle = Game.particles[i];
        particle.life += dt;
        if(particle.life < particle.maxLife){
            particle.x += particle.vx*dt;
            particle.y += particle.vy*dt;
            particle.vy += particle.gravity*dt;
            particle.vx *= math.exp(-1.2*dt);
            table.push(aliveParticles,particle);
        }
    }
    Game.particles = aliveParticles;

    var aliveRipples = [];
    for(i=1;#Game.ripples;1){
        var ripple = Game.ripples[i];
        ripple.life += dt;
        ripple.r = math.lerp(ripple.r,ripple.maxRadius,1-math.exp(-9*dt));
        if(ripple.life < ripple.maxLife) table.push(aliveRipples,ripple);
    }
    Game.ripples = aliveRipples;

    return true;
}

winform.gameBox.onMouseDown = function(wParam,lParam){
    firePin();
}

winform.gameBox.dlgCode = 4/*_DLGC_WANTALLKEYS*/;
winform.gameBox.onKeyDown = function(keyCode,lParam,repeat){
    if((keyCode == 0x20/*_VK_SPACE*/ || keyCode == 0x0D/*_VK_RETURN*/ || keyCode == 0x26/*_VK_UP*/) && !repeat){
        firePin();
    }
    elseif(keyCode == 'R'#){
        startLevel(Game.level);
    }
    elseif(keyCode == 'P'#){
        if(Game.status == "paused") Game.status = "playing";
        elseif(Game.status == "playing") Game.status = "paused";
    }
    elseif(keyCode == 'M'#){
        Game.soundEnabled = !Game.soundEnabled;
        if(Game.soundEnabled && midiOut) midiOut.play("changeInstrument(10),1_","C6",55);
    }
    elseif(keyCode == 0x25/*_VK_LEFT*/){
        if(Game.level > 1) startLevel(Game.level-1);
    }
    elseif(keyCode == 0x27/*_VK_RIGHT*/){
        if(Game.level < Game.maxUnlocked) startLevel(Game.level+1);
    }
}

winform.gameBox.onFocusLost = function(){
    if(Game.status == "playing") Game.status = "paused";
}

winform.onClose = function(){
    if(midiOut){
        midiOut.reset();
        midiOut.close();
        midiOut = null;
    }

    for name,obj in Draw{
        if(obj[["delete"]]) obj.delete();
    }
}

winform.gameBox.startAnimation(16);
winform.show();
winform.gameBox.setFocus();
win.loopMessage();
Markdown 格式