.katex { display: block; text-align: center; white-space: nowrap; }
.katex-display > .katex > .katex-html { display: block; }
.katex-display > .katex > .katex-html > .tag { position: absolute; right: 0px; }
.katex { font: 1.21em/1.2 KaTeX_Main, "Times New Roman", serif; text-indent: 0px; text-rendering: auto; }
.katex * { }
.katex .katex-mathml { position: absolute; clip: rect(1px, 1px, 1px, 1px); padding: 0px; border: 0px; height: 1px; width: 1px; overflow: hidden; }
.katex .katex-html { }
.katex .katex-html > .newline { display: block; }
.katex .base { position: relative; display: inline-block; white-space: nowrap; width: min-content; }
.katex .strut { display: inline-block; }
.katex .textbf { font-weight: bold; }
.katex .textit { font-style: italic; }
.katex .textrm { font-family: KaTeX_Main; }
.katex .textsf { font-family: KaTeX_SansSerif; }
.katex .texttt { font-family: KaTeX_Typewriter; }
.katex .mathit { font-family: KaTeX_Math; font-style: italic; }
.katex .mathrm { font-style: normal; }
.katex .mathbf { font-family: KaTeX_Main; font-weight: bold; }
.katex .boldsymbol { font-family: KaTeX_Math; font-weight: bold; font-style: italic; }
.katex .amsrm { font-family: KaTeX_AMS; }
.katex .mathbb, .katex .textbb { font-family: KaTeX_AMS; }
.katex .mathcal { font-family: KaTeX_Caligraphic; }
.katex .mathfrak, .katex .textfrak { font-family: KaTeX_Fraktur; }
.katex .mathtt { font-family: KaTeX_Typewriter; }
.katex .mathscr, .katex .textscr { font-family: KaTeX_Script; }
.katex .mathsf, .katex .textsf { font-family: KaTeX_SansSerif; }
.katex .mainit { font-family: KaTeX_Main; font-style: italic; }
.katex .mainrm { font-family: KaTeX_Main; font-style: normal; }
.katex .vlist-t { display: inline-table; table-layout: fixed; }
.katex .vlist-r { display: table-row; }
.katex .vlist { display: table-cell; vertical-align: bottom; position: relative; }
.katex .vlist > span { display: block; height: 0px; position: relative; }
.katex .vlist > span > span { display: inline-block; }
.katex .vlist > span > .pstrut { overflow: hidden; width: 0px; }
.katex .vlist-t2 { margin-right: -2px; }
.katex .vlist-s { display: table-cell; vertical-align: bottom; font-size: 1px; width: 2px; min-width: 2px; }
.katex .msupsub { text-align: left; }
.katex .mfrac > span > span { text-align: center; }
.katex .mfrac .frac-line { display: inline-block; width: 100%; border-bottom-style: solid; }
.katex .mspace { display: inline-block; }
.katex .llap, .katex .rlap, .katex .clap { width: 0px; position: relative; }
.katex .llap > .inner, .katex .rlap > .inner, .katex .clap > .inner { position: absolute; }
.katex .llap > .fix, .katex .rlap > .fix, .katex .clap > .fix { display: inline-block; }
.katex .llap > .inner { right: 0px; }
.katex .rlap > .inner, .katex .clap > .inner { left: 0px; }
.katex .clap > .inner > span { margin-left: -50%; margin-right: 50%; }
.katex .rule { display: inline-block; border: 0px solid; position: relative; }
.katex .overline .overline-line, .katex .underline .underline-line, .katex .hline { display: inline-block; width: 100%; border-bottom-style: solid; }
.katex .hdashline { display: inline-block; width: 100%; border-bottom-style: dashed; }
.katex .sqrt > .root { margin-left: 0.277778em; margin-right: -0.555556em; }
.katex .sizing, .katex .fontsize-ensurer { display: inline-block; }
.katex .sizing.reset-size1.size1, .katex .fontsize-ensurer.reset-size1.size1 { font-size: 1em; }
.katex .sizing.reset-size1.size2, .katex .fontsize-ensurer.reset-size1.size2 { font-size: 1.2em; }
.katex .sizing.reset-size1.size3, .katex .fontsize-ensurer.reset-size1.size3 { font-size: 1.4em; }
.katex .sizing.reset-size1.size4, .katex .fontsize-ensurer.reset-size1.size4 { font-size: 1.6em; }
.katex .sizing.reset-size1.size5, .katex .fontsize-ensurer.reset-size1.size5 { font-size: 1.8em; }
.katex .sizing.reset-size1.size6, .katex .fontsize-ensurer.reset-size1.size6 { font-size: 2em; }
.katex .sizing.reset-size1.size7, .katex .fontsize-ensurer.reset-size1.size7 { font-size: 2.4em; }
.katex .sizing.reset-size1.size8, .katex .fontsize-ensurer.reset-size1.size8 { font-size: 2.88em; }
.katex .sizing.reset-size1.size9, .katex .fontsize-ensurer.reset-size1.size9 { font-size: 3.456em; }
.katex .sizing.reset-size1.size10, .katex .fontsize-ensurer.reset-size1.size10 { font-size: 4.148em; }
.katex .sizing.reset-size1.size11, .katex .fontsize-ensurer.reset-size1.size11 { font-size: 4.976em; }
.katex .sizing.reset-size2.size1, .katex .fontsize-ensurer.reset-size2.size1 { font-size: 0.833333em; }
.katex .sizing.reset-size2.size2, .katex .fontsize-ensurer.reset-size2.size2 { font-size: 1em; }
.katex .sizing.reset-size2.size3, .katex .fontsize-ensurer.reset-size2.size3 { font-size: 1.16667em; }
.katex .sizing.reset-size2.size4, .katex .fontsize-ensurer.reset-size2.size4 { font-size: 1.33333em; }
.katex .sizing.reset-size2.size5, .katex .fontsize-ensurer.reset-size2.size5 { font-size: 1.5em; }
.katex .sizing.reset-size2.size6, .katex .fontsize-ensurer.reset-size2.size6 { font-size: 1.66667em; }
.katex .sizing.reset-size2.size7, .katex .fontsize-ensurer.reset-size2.size7 { font-size: 2em; }
.katex .sizing.reset-size2.size8, .katex .fontsize-ensurer.reset-size2.size8 { font-size: 2.4em; }
.katex .sizing.reset-size2.size9, .katex .fontsize-ensurer.reset-size2.size9 { font-size: 2.88em; }
.katex .sizing.reset-size2.size10, .katex .fontsize-ensurer.reset-size2.size10 { font-size: 3.45667em; }
.katex .sizing.reset-size2.size11, .katex .fontsize-ensurer.reset-size2.size11 { font-size: 4.14667em; }
.katex .sizing.reset-size3.size1, .katex .fontsize-ensurer.reset-size3.size1 { font-size: 0.714286em; }
.katex .sizing.reset-size3.size2, .katex .fontsize-ensurer.reset-size3.size2 { font-size: 0.857143em; }
.katex .sizing.reset-size3.size3, .katex .fontsize-ensurer.reset-size3.size3 { font-size: 1em; }
.katex .sizing.reset-size3.size4, .katex .fontsize-ensurer.reset-size3.size4 { font-size: 1.14286em; }
.katex .sizing.reset-size3.size5, .katex .fontsize-ensurer.reset-size3.size5 { font-size: 1.28571em; }
.katex .sizing.reset-size3.size6, .katex .fontsize-ensurer.reset-size3.size6 { font-size: 1.42857em; }
.katex .sizing.reset-size3.size7, .katex .fontsize-ensurer.reset-size3.size7 { font-size: 1.71429em; }
.katex .sizing.reset-size3.size8, .katex .fontsize-ensurer.reset-size3.size8 { font-size: 2.05714em; }
.katex .sizing.reset-size3.size9, .katex .fontsize-ensurer.reset-size3.size9 { font-size: 2.46857em; }
.katex .sizing.reset-size3.size10, .katex .fontsize-ensurer.reset-size3.size10 { font-size: 2.96286em; }
.katex .sizing.reset-size3.size11, .katex .fontsize-ensurer.reset-size3.size11 { font-size: 3.55429em; }
.katex .sizing.reset-size4.size1, .katex .fontsize-ensurer.reset-size4.size1 { font-size: 0.625em; }
.katex .sizing.reset-size4.size2, .katex .fontsize-ensurer.reset-size4.size2 { font-size: 0.75em; }
.katex .sizing.reset-size4.size3, .katex .fontsize-ensurer.reset-size4.size3 { font-size: 0.875em; }
.katex .sizing.reset-size4.size4, .katex .fontsize-ensurer.reset-size4.size4 { font-size: 1em; }
.katex .sizing.reset-size4.size5, .katex .fontsize-ensurer.reset-size4.size5 { font-size: 1.125em; }
.katex .sizing.reset-size4.size6, .katex .fontsize-ensurer.reset-size4.size6 { font-size: 1.25em; }
.katex .sizing.reset-size4.size7, .katex .fontsize-ensurer.reset-size4.size7 { font-size: 1.5em; }
.katex .sizing.reset-size4.size8, .katex .fontsize-ensurer.reset-size4.size8 { font-size: 1.8em; }
.katex .sizing.reset-size4.size9, .katex .fontsize-ensurer.reset-size4.size9 { font-size: 2.16em; }
.katex .sizing.reset-size4.size10, .katex .fontsize-ensurer.reset-size4.size10 { font-size: 2.5925em; }
.katex .sizing.reset-size4.size11, .katex .fontsize-ensurer.reset-size4.size11 { font-size: 3.11em; }
.katex .sizing.reset-size5.size1, .katex .fontsize-ensurer.reset-size5.size1 { font-size: 0.555556em; }
.katex .sizing.reset-size5.size2, .katex .fontsize-ensurer.reset-size5.size2 { font-size: 0.666667em; }
.katex .sizing.reset-size5.size3, .katex .fontsize-ensurer.reset-size5.size3 { font-size: 0.777778em; }
.katex .sizing.reset-size5.size4, .katex .fontsize-ensurer.reset-size5.size4 { font-size: 0.888889em; }
.katex .sizing.reset-size5.size5, .katex .fontsize-ensurer.reset-size5.size5 { font-size: 1em; }
.katex .sizing.reset-size5.size6, .katex .fontsize-ensurer.reset-size5.size6 { font-size: 1.11111em; }
.katex .sizing.reset-size5.size7, .katex .fontsize-ensurer.reset-size5.size7 { font-size: 1.33333em; }
.katex .sizing.reset-size5.size8, .katex .fontsize-ensurer.reset-size5.size8 { font-size: 1.6em; }
.katex .sizing.reset-size5.size9, .katex .fontsize-ensurer.reset-size5.size9 { font-size: 1.92em; }
.katex .sizing.reset-size5.size10, .katex .fontsize-ensurer.reset-size5.size10 { font-size: 2.30444em; }
.katex .sizing.reset-size5.size11, .katex .fontsize-ensurer.reset-size5.size11 { font-size: 2.76444em; }
.katex .sizing.reset-size6.size1, .katex .fontsize-ensurer.reset-size6.size1 { font-size: 0.5em; }
.katex .sizing.reset-size6.size2, .katex .fontsize-ensurer.reset-size6.size2 { font-size: 0.6em; }
.katex .sizing.reset-size6.size3, .katex .fontsize-ensurer.reset-size6.size3 { font-size: 0.7em; }
.katex .sizing.reset-size6.size4, .katex .fontsize-ensurer.reset-size6.size4 { font-size: 0.8em; }
.katex .sizing.reset-size6.size5, .katex .fontsize-ensurer.reset-size6.size5 { font-size: 0.9em; }
.katex .sizing.reset-size6.size6, .katex .fontsize-ensurer.reset-size6.size6 { font-size: 1em; }
.katex .sizing.reset-size6.size7, .katex .fontsize-ensurer.reset-size6.size7 { font-size: 1.2em; }
.katex .sizing.reset-size6.size8, .katex .fontsize-ensurer.reset-size6.size8 { font-size: 1.44em; }
.katex .sizing.reset-size6.size9, .katex .fontsize-ensurer.reset-size6.size9 { font-size: 1.728em; }
.katex .sizing.reset-size6.size10, .katex .fontsize-ensurer.reset-size6.size10 { font-size: 2.074em; }
.katex .sizing.reset-size6.size11, .katex .fontsize-ensurer.reset-size6.size11 { font-size: 2.488em; }
.katex .sizing.reset-size7.size1, .katex .fontsize-ensurer.reset-size7.size1 { font-size: 0.416667em; }
.katex .sizing.reset-size7.size2, .katex .fontsize-ensurer.reset-size7.size2 { font-size: 0.5em; }
.katex .sizing.reset-size7.size3, .katex .fontsize-ensurer.reset-size7.size3 { font-size: 0.583333em; }
.katex .sizing.reset-size7.size4, .katex .fontsize-ensurer.reset-size7.size4 { font-size: 0.666667em; }
.katex .sizing.reset-size7.size5, .katex .fontsize-ensurer.reset-size7.size5 { font-size: 0.75em; }
.katex .sizing.reset-size7.size6, .katex .fontsize-ensurer.reset-size7.size6 { font-size: 0.833333em; }
.katex .sizing.reset-size7.size7, .katex .fontsize-ensurer.reset-size7.size7 { font-size: 1em; }
.katex .sizing.reset-size7.size8, .katex .fontsize-ensurer.reset-size7.size8 { font-size: 1.2em; }
.katex .sizing.reset-size7.size9, .katex .fontsize-ensurer.reset-size7.size9 { font-size: 1.44em; }
.katex .sizing.reset-size7.size10, .katex .fontsize-ensurer.reset-size7.size10 { font-size: 1.72833em; }
.katex .sizing.reset-size7.size11, .katex .fontsize-ensurer.reset-size7.size11 { font-size: 2.07333em; }
.katex .sizing.reset-size8.size1, .katex .fontsize-ensurer.reset-size8.size1 { font-size: 0.347222em; }
.katex .sizing.reset-size8.size2, .katex .fontsize-ensurer.reset-size8.size2 { font-size: 0.416667em; }
.katex .sizing.reset-size8.size3, .katex .fontsize-ensurer.reset-size8.size3 { font-size: 0.486111em; }
.katex .sizing.reset-size8.size4, .katex .fontsize-ensurer.reset-size8.size4 { font-size: 0.555556em; }
.katex .sizing.reset-size8.size5, .katex .fontsize-ensurer.reset-size8.size5 { font-size: 0.625em; }
.katex .sizing.reset-size8.size6, .katex .fontsize-ensurer.reset-size8.size6 { font-size: 0.694444em; }
.katex .sizing.reset-size8.size7, .katex .fontsize-ensurer.reset-size8.size7 { font-size: 0.833333em; }
.katex .sizing.reset-size8.size8, .katex .fontsize-ensurer.reset-size8.size8 { font-size: 1em; }
.katex .sizing.reset-size8.size9, .katex .fontsize-ensurer.reset-size8.size9 { font-size: 1.2em; }
.katex .sizing.reset-size8.size10, .katex .fontsize-ensurer.reset-size8.size10 { font-size: 1.44028em; }
.katex .sizing.reset-size8.size11, .katex .fontsize-ensurer.reset-size8.size11 { font-size: 1.72778em; }
.katex .sizing.reset-size9.size1, .katex .fontsize-ensurer.reset-size9.size1 { font-size: 0.289352em; }
.katex .sizing.reset-size9.size2, .katex .fontsize-ensurer.reset-size9.size2 { font-size: 0.347222em; }
.katex .sizing.reset-size9.size3, .katex .fontsize-ensurer.reset-size9.size3 { font-size: 0.405093em; }
.katex .sizing.reset-size9.size4, .katex .fontsize-ensurer.reset-size9.size4 { font-size: 0.462963em; }
.katex .sizing.reset-size9.size5, .katex .fontsize-ensurer.reset-size9.size5 { font-size: 0.520833em; }
.katex .sizing.reset-size9.size6, .katex .fontsize-ensurer.reset-size9.size6 { font-size: 0.578704em; }
.katex .sizing.reset-size9.size7, .katex .fontsize-ensurer.reset-size9.size7 { font-size: 0.694444em; }
.katex .sizing.reset-size9.size8, .katex .fontsize-ensurer.reset-size9.size8 { font-size: 0.833333em; }
.katex .sizing.reset-size9.size9, .katex .fontsize-ensurer.reset-size9.size9 { font-size: 1em; }
.katex .sizing.reset-size9.size10, .katex .fontsize-ensurer.reset-size9.size10 { font-size: 1.20023em; }
.katex .sizing.reset-size9.size11, .katex .fontsize-ensurer.reset-size9.size11 { font-size: 1.43981em; }
.katex .sizing.reset-size10.size1, .katex .fontsize-ensurer.reset-size10.size1 { font-size: 0.24108em; }
.katex .sizing.reset-size10.size2, .katex .fontsize-ensurer.reset-size10.size2 { font-size: 0.289296em; }
.katex .sizing.reset-size10.size3, .katex .fontsize-ensurer.reset-size10.size3 { font-size: 0.337512em; }
.katex .sizing.reset-size10.size4, .katex .fontsize-ensurer.reset-size10.size4 { font-size: 0.385728em; }
.katex .sizing.reset-size10.size5, .katex .fontsize-ensurer.reset-size10.size5 { font-size: 0.433944em; }
.katex .sizing.reset-size10.size6, .katex .fontsize-ensurer.reset-size10.size6 { font-size: 0.48216em; }
.katex .sizing.reset-size10.size7, .katex .fontsize-ensurer.reset-size10.size7 { font-size: 0.578592em; }
.katex .sizing.reset-size10.size8, .katex .fontsize-ensurer.reset-size10.size8 { font-size: 0.694311em; }
.katex .sizing.reset-size10.size9, .katex .fontsize-ensurer.reset-size10.size9 { font-size: 0.833173em; }
.katex .sizing.reset-size10.size10, .katex .fontsize-ensurer.reset-size10.size10 { font-size: 1em; }
.katex .sizing.reset-size10.size11, .katex .fontsize-ensurer.reset-size10.size11 { font-size: 1.19961em; }
.katex .sizing.reset-size11.size1, .katex .fontsize-ensurer.reset-size11.size1 { font-size: 0.200965em; }
.katex .sizing.reset-size11.size2, .katex .fontsize-ensurer.reset-size11.size2 { font-size: 0.241158em; }
.katex .sizing.reset-size11.size3, .katex .fontsize-ensurer.reset-size11.size3 { font-size: 0.28135em; }
.katex .sizing.reset-size11.size4, .katex .fontsize-ensurer.reset-size11.size4 { font-size: 0.321543em; }
.katex .sizing.reset-size11.size5, .katex .fontsize-ensurer.reset-size11.size5 { font-size: 0.361736em; }
.katex .sizing.reset-size11.size6, .katex .fontsize-ensurer.reset-size11.size6 { font-size: 0.401929em; }
.katex .sizing.reset-size11.size7, .katex .fontsize-ensurer.reset-size11.size7 { font-size: 0.482315em; }
.katex .sizing.reset-size11.size8, .katex .fontsize-ensurer.reset-size11.size8 { font-size: 0.578778em; }
.katex .sizing.reset-size11.size9, .katex .fontsize-ensurer.reset-size11.size9 { font-size: 0.694534em; }
.katex .sizing.reset-size11.size10, .katex .fontsize-ensurer.reset-size11.size10 { font-size: 0.833601em; }
.katex .sizing.reset-size11.size11, .katex .fontsize-ensurer.reset-size11.size11 { font-size: 1em; }
.katex .delimsizing.size1 { font-family: KaTeX_Size1; }
.katex .delimsizing.size2 { font-family: KaTeX_Size2; }
.katex .delimsizing.size3 { font-family: KaTeX_Size3; }
.katex .delimsizing.size4 { font-family: KaTeX_Size4; }
.katex .delimsizing.mult .delim-size1 > span { font-family: KaTeX_Size1; }
.katex .delimsizing.mult .delim-size4 > span { font-family: KaTeX_Size4; }
.katex .nulldelimiter { display: inline-block; width: 0.12em; }
.katex .delimcenter { position: relative; }
.katex .op-symbol { position: relative; }
.katex .op-symbol.small-op { font-family: KaTeX_Size1; }
.katex .op-symbol.large-op { font-family: KaTeX_Size2; }
.katex .op-limits > .vlist-t { text-align: center; }
.katex .accent > .vlist-t { text-align: center; }
.katex .accent .accent-body:not(.accent-full) { width: 0px; }
.katex .accent .accent-body { position: relative; }
.katex .overlay { display: block; }
.katex .mtable .vertical-separator { display: inline-block; margin: 0px -0.025em; border-right: 0.05em solid; }
.katex .mtable .vs-dashed { border-right: 0.05em dashed; }
.katex .mtable .arraycolsep { display: inline-block; }
.katex .mtable .col-align-c > .vlist-t { text-align: center; }
.katex .mtable .col-align-l > .vlist-t { text-align: left; }
.katex .mtable .col-align-r > .vlist-t { text-align: right; }
.katex .svg-align { text-align: left; }
.katex svg, .screenShotTempCanvas { display: block; position: absolute; width: 100%; height: inherit; fill: currentcolor; stroke: currentcolor; fill-rule: nonzero; fill-opacity: 1; stroke-width: 1; stroke-linecap: butt; stroke-linejoin: miter; stroke-miterlimit: 4; stroke-dasharray: none; stroke-dashoffset: 0; stroke-opacity: 1; }
.katex svg path { stroke: none; }
.katex .stretchy { width: 100%; display: block; position: relative; overflow: hidden; }
.katex .stretchy::before, .katex .stretchy::after { content: ""; }
.katex .hide-tail { width: 100%; position: relative; overflow: hidden; }
.katex .halfarrow-left { position: absolute; left: 0px; width: 50.2%; overflow: hidden; }
.katex .halfarrow-right { position: absolute; right: 0px; width: 50.2%; overflow: hidden; }
.katex .brace-left { position: absolute; left: 0px; width: 25.1%; overflow: hidden; }
.katex .brace-center { position: absolute; left: 25%; width: 50%; overflow: hidden; }
.katex .brace-right { position: absolute; right: 0px; width: 25.1%; overflow: hidden; }
.katex .x-arrow-pad { padding: 0px 0.5em; }
.katex .x-arrow, .katex .mover, .katex .munder { text-align: center; }
.katex .boxpad { padding: 0px 0.3em; }
.katex .fbox { box-sizing: border-box; border: 0.04em solid black; }
.katex .fcolorbox { box-sizing: border-box; border: 0.04em solid; }
.katex .cancel-pad { padding: 0px 0.2em; }
.katex .cancel-lap { margin-left: -0.2em; margin-right: -0.2em; }
.katex .sout { border-bottom-style: solid; border-bottom-width: 0.08em; }
.output_wrapper pre code { display: -webkit-box !important; }
.output_wrapper .hljs{display: block; overflow-x: auto; padding: 0.5em; background: rgb(251, 241, 199);}

.output_wrapper .hljs,.output_wrapper .hljs-subst{color: rgb(60, 56, 54);}

.output_wrapper .hljs-deletion,.output_wrapper .hljs-formula,.output_wrapper .hljs-keyword,.output_wrapper .hljs-link,.output_wrapper .hljs-selector-tag{color: rgb(157, 0, 6);}

.output_wrapper .hljs-built_in,.output_wrapper .hljs-emphasis,.output_wrapper .hljs-name,.output_wrapper .hljs-quote,.output_wrapper .hljs-strong,.output_wrapper .hljs-title,.output_wrapper .hljs-variable{color: rgb(7, 102, 120);}

.output_wrapper .hljs-attr,.output_wrapper .hljs-params,.output_wrapper .hljs-template-tag,.output_wrapper .hljs-type{color: rgb(181, 118, 20);}

.output_wrapper .hljs-builtin-name,.output_wrapper .hljs-doctag,.output_wrapper .hljs-literal,.output_wrapper .hljs-number{color: rgb(143, 63, 113);}

.output_wrapper .hljs-code,.output_wrapper .hljs-meta,.output_wrapper .hljs-regexp,.output_wrapper .hljs-selector-id,.output_wrapper .hljs-template-variable{color: rgb(175, 58, 3);}

.output_wrapper .hljs-addition,.output_wrapper .hljs-meta-string,.output_wrapper .hljs-section,.output_wrapper .hljs-selector-attr,.output_wrapper .hljs-selector-class,.output_wrapper .hljs-string,.output_wrapper .hljs-symbol{color: rgb(121, 116, 14);}

.output_wrapper .hljs-attribute,.output_wrapper .hljs-bullet,.output_wrapper .hljs-class,.output_wrapper .hljs-function,.output_wrapper .hljs-function .hljs-keyword,.output_wrapper .hljs-meta-keyword,.output_wrapper .hljs-selector-pseudo,.output_wrapper .hljs-tag{color: rgb(66, 123, 88);}

.output_wrapper .hljs-comment{color: rgb(146, 131, 116);}

.output_wrapper .hljs-link_label,.output_wrapper .hljs-literal,.output_wrapper .hljs-number{color: rgb(143, 63, 113);}

.output_wrapper .hljs-comment,.output_wrapper .hljs-emphasis{font-style: italic;}

.output_wrapper .hljs-section,.output_wrapper .hljs-strong,.output_wrapper .hljs-tag{font-weight: bold;}

.output_wrapper pre code {line-height: 18px; font-size: 14px; font-weight: normal; word-spacing: 0px; letter-spacing: 0px;}
.output_wrapper{font-size: 16px; color: rgb(62, 62, 62); line-height: 1.6; word-spacing: 0px; letter-spacing: 0px; font-family: "Helvetica Neue", Helvetica, "Hiragino Sans GB", "Microsoft YaHei", Arial, sans-serif; background-image: linear-gradient(90deg, rgba(50, 0, 0, 0.05) 3%, rgba(0, 0, 0, 0) 3%), linear-gradient(360deg, rgba(50, 0, 0, 0.05) 3%, rgba(0, 0, 0, 0) 3%); background-size: 20px 20px; background-position: center center;}

.output_wrapper *{font-size: inherit; color: inherit; line-height: inherit; margin: 0px; padding: 0px;}

.output_wrapper p{margin: 1.5em 0px;}

.output_wrapper h1,.output_wrapper h2,.output_wrapper h3,.output_wrapper h4,.output_wrapper h5,.output_wrapper h6{margin: 1.5em 0px; font-weight: bold;}

.output_wrapper h1{font-size: 1.6em;}

.output_wrapper h2{font-size: 1.4em;}

.output_wrapper h3{font-size: 1.3em;}

.output_wrapper h4{font-size: 1.2em;}

.output_wrapper h5{font-size: 1em;}

.output_wrapper h6{font-size: 1em;}

.output_wrapper ul,.output_wrapper ol{padding-left: 32px;}

.output_wrapper ul{list-style-type: disc;}

.output_wrapper ol{list-style-type: decimal;}

.output_wrapper li *{}

.output_wrapper li{margin-bottom: 0.5em;}

.output_wrapper .code_size_default{line-height: 18px; font-size: 14px; font-weight: normal; word-spacing: 0px; letter-spacing: 0px;}

.output_wrapper .code_size_tight{line-height: 15px; font-size: 11px; font-weight: normal; word-spacing: -3px; letter-spacing: 0px;}

.output_wrapper pre code{font-family: Consolas, Inconsolata, Courier, monospace; border-radius: 0px;}

.output_wrapper blockquote{display: block; padding: 15px 15px 15px 1rem; font-size: 0.9em; margin: 1em 0px; color: rgb(129, 145, 152); border-left: 6px solid rgb(220, 230, 240); background: rgb(242, 247, 251); overflow: auto; overflow-wrap: normal; word-break: normal;}

.output_wrapper blockquote p{margin: 0px;}

.output_wrapper a{text-decoration: none; color: rgb(30, 107, 184); overflow-wrap: break-word;}

.output_wrapper strong{font-weight: bold;}

.output_wrapper em{font-style: italic;}

.output_wrapper del{font-style: italic;}

.output_wrapper strong em{font-weight: bold;}

.output_wrapper hr{height: 1px; margin: 1.5rem 0px; border-right: none; border-bottom: none; border-left: none; border-image: initial; border-top: 1px dashed rgb(165, 165, 165);}

.output_wrapper code{overflow-wrap: break-word; padding: 2px 4px; border-radius: 4px; margin: 0px 2px; color: rgb(233, 105, 0); background: rgb(248, 248, 248);}

.output_wrapper img{display: block; margin: 0px auto; max-width: 100%;}

.output_wrapper figcaption{margin-top: 10px; text-align: center; color: rgb(153, 153, 153); font-size: 0.7em;}

.output_wrapper table{display: table; width: 100%; text-align: left;}

.output_wrapper tbody{border: 0px;}

.output_wrapper table tr{border-width: 1px 0px 0px; border-right-style: initial; border-bottom-style: initial; border-left-style: initial; border-right-color: initial; border-bottom-color: initial; border-left-color: initial; border-image: initial; border-top-style: solid; border-top-color: rgb(204, 204, 204); background-color: white;}

.output_wrapper table tr th,.output_wrapper table tr td{font-size: 1em; border: 1px solid rgb(204, 204, 204); padding: 0.5em 1em; text-align: left;}

.output_wrapper table tr th{font-weight: bold; background-color: rgb(240, 240, 240);}

.output_wrapper .katex-display{font-size: 1.22em;}

.output_wrapper .katex{padding: 8px 3px;}

.output_wrapper .katex-display > .katex{display: inline-block; text-align: center; padding: 3px;}

.output_wrapper .katex img{display: inline-block; vertical-align: middle;}

.output_wrapper a[href^="#"] sup{vertical-align: super; margin: 0px 2px; padding: 1px 3px; color: rgb(255, 255, 255); background: rgb(102, 102, 102); font-size: 0.7em;}

.output_wrapper .task-list-list{list-style-type: none;}

.output_wrapper .task-list-list.checked{color: rgb(62, 62, 62);}

.output_wrapper .task-list-list.uncheck{color: rgb(191, 193, 191);}

.output_wrapper .task-list-list .icon_uncheck,.output_wrapper .task-list-list .icon_check{display: inline-block; vertical-align: middle; margin-right: 10px;}

.output_wrapper .task-list-list .icon_check::before{content: "√"; border: 2px solid rgb(62, 62, 62); color: red;}

.output_wrapper .task-list-list .icon_uncheck::before{content: "x"; border: 2px solid rgb(191, 193, 191); color: rgb(191, 193, 191);}

.output_wrapper .task-list-list .icon_check::before,.output_wrapper .task-list-list .icon_uncheck::before{padding: 2px 8px 2px 5px; border-radius: 5px;}

.output_wrapper .toc{margin-left: 25px;}

.output_wrapper .toc_item{display: block;}

.output_wrapper .toc_left{margin-left: 25px;}

.output_wrapper pre code{border-radius: 3px; border-width: 1px 1px 1px 6px; border-style: solid; border-color: rgb(204, 204, 204) rgb(204, 204, 204) rgb(204, 204, 204) rgb(33, 152, 99);}

.output_wrapper pre code .linenum{padding-right: 20px; word-spacing: 0px;}

.output_wrapper .hljs{color: rgb(169, 183, 198); background: rgb(40, 43, 46); display: block; overflow-x: auto; padding: 0.5em;}

.output_wrapper .hljs-params{color: rgb(255, 152, 35);}

.output_wrapper .hljs-number,.output_wrapper .hljs-literal,.output_wrapper .hljs-symbol,.output_wrapper .hljs-bullet{color: rgb(174, 135, 250);}

.output_wrapper .hljs-function,.output_wrapper .hljs-built_in,.output_wrapper .hljs-name,.output_wrapper .hljs-keyword,.output_wrapper .hljs-selector-tag,.output_wrapper .hljs-deletion{color: rgb(248, 35, 117);}

.output_wrapper .hljs-variable,.output_wrapper .hljs-template-variable,.output_wrapper .hljs-link{color: rgb(98, 151, 85);}

.output_wrapper .hljs-comment,.output_wrapper .hljs-quote{color: rgb(128, 128, 128);}

.output_wrapper .hljs-meta{color: rgb(91, 218, 237);}

.output_wrapper .hljs-string,.output_wrapper .hljs-attribute,.output_wrapper .hljs-addition{color: rgb(238, 220, 112);}

.output_wrapper .hljs-attr,.output_wrapper .hljs-section,.output_wrapper .hljs-title,.output_wrapper .hljs-type{color: rgb(165, 218, 45);}

.output_wrapper .hljs-selector-class{color: rgb(165, 218, 45);}

.output_wrapper .hljs-emphasis{font-style: italic;}

.output_wrapper .hljs-strong{font-weight: bold;}

.output_wrapper p{margin: 1.5em 0px;}

.output_wrapper h1,.output_wrapper h2,.output_wrapper h3,.output_wrapper h4,.output_wrapper h5,.output_wrapper h6{margin: 1.5em 0px; font-weight: bold;}

.output_wrapper h1{font-size: 1.6em;}

.output_wrapper h2{font-size: 1.4em;}

.output_wrapper h3{font-size: 1.3em;}

.output_wrapper h4{font-size: 1.2em;}

.output_wrapper h5{font-size: 1em;}

.output_wrapper h6{font-size: 1em;}

.output_wrapper h3{border-bottom: 2px solid rgb(62, 62, 62); margin-bottom: 50px;}

.output_wrapper h3 span{display: inline-block; padding: 10px 0px;}

.output_wrapper h3 span::first-letter,.output_wrapper h3 .firstletter{color: rgb(255, 255, 255); padding: 10px 15px; margin-right: 20px; background: rgb(62, 62, 62);}
-->

首先是调试利器

一些项目组开发战斗系统时,可能会优先开发涉及表现的相关功能,迭代的新增战斗表现,修复Bug,直到整个战斗表现看起来相当完整了,到了后期再应策划要求,补充战斗录像系统。笔者项目是在开发中期加入战斗录像功能的,在经历完整个战斗开发阶段后,得出的经验是尽可能在基础框架搭建、前后台开始联调阶段就同步开发战斗录像系统,利用战斗录像来辅助系统开发、调试。到了项目中后期,战斗录像会发挥更大的用处,此时战斗系统已经提交到SVN版本控制,项目组所有人都可以体验到战斗系统,所有人都或多或少地扮演测试人员的角色,项目群会频繁地反馈战斗系统的表现问题,诸如报错、卡死,单位诈尸等等,什么反馈都会有,总之发挥你的想象力。当时开发会频繁地奔波于各个项目组成员的电脑面前,沟通、查看日志,尝试弄清问题,有了战斗录像后,我们会让对方给录像文件,在本地环境重放战斗录像,重现现场慢慢定位问题。
战斗录像能多大程度的辅助开发调试,取决于相关工具链有多完善,下面介绍妖尾项目对于工具链的打造。首先简单看下战斗录像框架:

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP
一般来说,网络底层往上还会有一层业务网络层,妖尾的业务网络层分成两个,一个负责普通业务逻辑,一个网络层供战斗专用。通过在战斗网络层接口插桩,战斗录像模块就能收集一场战斗的所有数据。战斗结束后,自动将该场战斗数据保存成本地录像文件,当然,我们还要提供手动保存录像接口,以便战斗中途卡死了也能保存录像。虽然有了战斗数据,还要配备一套完整功能的GUI工具才能提高调试效率,因此笔者基于Unity开发了战斗录像播放器工具。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

上图是战斗录像播放器经过几次迭代后的截图,除了实现最基本的播放录像、查看数据功能,还有查看设备数据,上传/下载录像、生成战斗播报、差量构建指定回合战场包等功能。笔者觉得在开发初期,先实现播放录像、查看数据的功能就能满足大部分调试需求了,开发时间成本只有2-3天,但它会在之后1-2个月甚至更久的前期开发阶段帮你缩短调试定位时间,节省更多时间早(bu)点(cun)下(zai)班(de),或帮策划做更多需求,重要的是解放心态,不再疲于沟通Bug,构造现场,因为现场就在录像里。

简单描述这套调试工具的使用姿势:

  • 开发过程中遇到了战斗Bug,如果在第一时间无法判断Bug原因,先保存录像,再逐步分析问题。
  • 选择报错的战斗录像,通过时间戳/快速模式重跑战斗,逐步缩小问题范围:观察战斗录像播到第几个回合报错,是资源加载、选招还是表演阶段报错,通过报错前的日志,逐步定位是哪个类哪个接口的问题,再猜测并验证某行代码,直到问题解决。
  • 如果不是卡死报错,战斗也能跑完,但策划反馈某个技能/Buff表现与预期不同,就要查看关键表演包的数据,看是后台传的有问题,还是前台表现没做对。
  • 上面两类问题的排查通常是无法一步到位的,排查过程会不断追踪代码给可疑代码打Log,会临时修改某些变量,会临时修改某段代码逻辑,依靠不断重跑战斗来验证。
  • 解决Bug的过程也少不了跟后台的沟通,在这之前,后台重数据轻表现,前台重表现轻数据,导致一种现象就是后台找前台问表现,前台找后台问数据,沟通成本比较高。有了这套工具,前台开发对于这场战斗包括服务器、角色ID、战斗ID、战场ID,协议数据等信息都了如指掌,快速分析出是前台问题就直接修复,是后台问题就告诉对方去修复哪块数据。

初期在项目组内推行用录像反馈战斗Bug时,我们让大家把保存下来的录像文件单发给战斗开发来调试,很快发现用户体验并不友好,不是所有人都是开发,大家不清楚录像保存到哪个目录了,找到目录,他们也弄不清楚要发哪个录像给开发。在忍受了一段时间的灵魂三连问后,笔者又加上了录像上传/下载功能。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP
记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

上面两张图是录像上传/下载流程及录像下载页面。我们将Bug反馈操作简化成游戏内一键反馈,点击按钮就能自动保存录像文件,并将二进制文件数据Base64编码成字符串,利用魔方质管组帮忙搭建的Web服务,通过Http请求将数据上传到Web服务器保存数据库,开发通过Web页面就可以搜索/下载base64字符串格式的录像文件,最后录像播放接口做适配,支持二进制/base64字符串两种格式数据的录像播放,整个环节就打通了。

开发阶段我们自行开发了战斗录像来辅助调试,确实也是到了战斗系统基本稳定后,策划们才前后提了战斗录像的正式需求,先做了一版基于服务器保存的活动录像,又做了一版基于客户端保存的战斗录像大厅。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

前后做这两版录像需求,虽然都是观看录像,但其实现大不相同,因此需要谨慎设计整个录像模块,让两套逻辑独立并行,能共用底层功能,并尽量保持外部接口一致性。上图是整个战斗录像的模块划分,可划分为实现战斗录像基础功能的核心模块,及涉及界面UI的两版业务功能模块。BattleReplayManager是核心类,它对外接收录像相关的控制请求,对内调度其他核心模块类,获取/保存/构造数据,控制录像播放流程,并通过给战斗网络层发送协议数据影响战斗表现。

服务器录像

基于服务器保存的活动录像,所有数据都由服务器提供。前台首先发送观看录像请求,接收录像概要数据包,获取战斗波次/回合等信息用于显示和跳回合。收到初始战场包后进入战斗,在每回合表演完后请求下一回合表演数据。正常播放录像时,收到的协议数据跟普通战斗是一样的,但如果在战斗中途跳回合,除了新回合的表演包,还会收到新回合的战场包,用于恢复新回合初的战场单位状态。这个过程跟战斗断线重连恢复战场是同一套逻辑,因此把战斗断线重连的坑填完,实现服务器录像基本没有难点。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

客户端录像

相对服务器录像,实现基于客户端保存的录像功能要考虑比较多问题:

  1. 确定录像数据结构,用什么数据结构存储一场战斗的所有协议及相关信息较优?
  2. 保证录制数据完整性。网络抖动、切出游戏再切回来等场景可能会导致少了某回合表演数据怎么办?
  3. 如何实现跳回合。一场正常战斗的协议包,除了初始战场包,每个回合只有表演包,没有战场包,跳回合怎么恢复战场状态?
  4. 录像上传/下载的传输策略。协议收发有64kb限制,录像文件大小超过了怎么办?
  5. 保证用户体验。评估极限情况的录像文件大小,保证流畅的录像观看体验。

模块开发初期就考虑这些问题,就可以避免基础设计出错,后期积重难返的尴尬情况。

1. 录像文件结构

首先是确定录像文件格式,由于妖尾协议基于pb通信,录像文件一开始就没有打算自定义二进制格式,而是直接基于pb定义数据结构,这样有几点好处:

  1. pb传输效率高,而且开发熟悉pb,不像自定义格式还有理解成本,开发效率也高。
  2. 协议与录像文件采用同种格式,比较容易根据查看列表,上传/下载录像等业务去反推最优的录像文件数据结构。让每份录像文件既可以有战斗录像数据,也有关于录像大厅的业务数据,一次设计,解决两个问题。
  3. pb支持数据结构嵌套,列表,能做出录像头、录像数据块设计,上传/下载协议也容易切分录像文件做分块传输。

基于几点考虑,录像文件由BattleReplayFile录像头、BattleReplayFileBlock录像数据块两部分组成。BattleReplayFile的blocks字段用于存放BattleReplayFileBlock列表,BattleReplayFile其他字段是概要信息。这样查看录像列表时,后台只需要返回不带blocks数据的BattleReplayFile列表即可。上传/下载录像时也可以先传录像头、再批量分次传录像数据块。

message BattleReplayFile
{
    optional string name = 1;                       // 录像文件名
    repeated BattleReplayFileBlock blocks = 2;      // 协议文件块
    optional uint32 block_num = 3;                  // 协议文件总块数
    repeated string ext_info_keys = 4;              // 录像额外信息参数Key
    repeated string ext_info_values = 5;            // 录像额外信息参数Value
    ... // id、时间、双方成员、回合、波次等录像概要信息
    ... // 简介、点赞、收藏等录像大厅业务信息
} message BattleReplayFileBlock
{
    optional uint32 index = 1;                  // 协议块序号
    optional string name = 2;                   // 协议类名
    optional bytes data = 3;                    // 协议数据
    ... //时间、回合等其他信息
}

2. 录像文件校验

网络抖动、切出游戏再切回来等情况导致断线重连,可能导致战斗录像数据损坏,因此保存本地前先做录像文件校验,判断有没有丢关键协议包,包括初始战场包、入包表演包、各回合表演包及退出战场包,保证协议包序,通过校验才保存录像文件,不通过就提示玩家录像数据损坏无法保存。

3. 录像回合跳转

一场战斗录像单靠收到的协议包,可以正常顺序播放整个战斗,却不能跳转回合播放,因为中间跳过了几回合的表演演算,战斗逻辑层无法将战场数据修正成跳转回合的状态。服务器录像可以依靠后台发跳转回合战场包做恢复,客户端录像就要靠前台自己处理,用录像表演包演算出跳转回合的战场状态。

第一直觉是在战斗逻辑层处理跳出的表演包,只是跳过表演,直接做数据演算,但稍加思考会发现有很多问题:战斗逻辑层里,数据与表现基本耦合在一起,毕竟这样的编码实现方式最直观。想抽离表现只演算数据,只能在原有代码里加ifelse分支,重写数据演算逻辑。几十个表演类,新增这么多分支,编码再加调试,必然失去对代码的把控,也破坏了原有系统稳定性。即使哼哧哼哧硬写下来,也会发现只实现了向后跳转回合,没实现向前跳转回合,因为战斗逻辑层实现的是按回合往下演算的逻辑。

跳出这个误区,我们认为战斗录像数据应该要有每个回合的战场包,跳转时供战斗逻辑层重置回合战场,因此后台修改了战斗逻辑,每回合都会发当回合战场包,这些战场包做了特殊标记,只用于录像存储,不会影响战斗逻辑,实现起来很快,但也清楚有明显效率问题。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

基本上,战场包都会比表演包大,甚至大很多,如果某个回合技能不太复杂,那表演包数据其实非常小,为了实现跳回合,由后台给每个回合加发战场包,会非常影响战斗的协议数据量,保存录像文件变大,也会增加上传/下载录像时的负担。这么实现不合理的点在于,每回合战场包其实是冗余数据,每回合状态是可以通过初始战场包加表演包推算出来的。为了优化这个问题,前台实现了一个战场包构建器,以初始战场包、回合1~n-1表演包为输入,输出目标回合n的战场包。这样在保存录像时不需要保存回合战场包,录像跳转回合时由构造器动态生成战场包即可。编写调试战场包构建器时,要注意检查前后台的战场包差异,我们会打印战场包数据,通过Beyond Compare查看差异,不断调整代码,直到构建的关键数据一致为止。战场包构建器调试好后,只要后续不新增表演类型,就可以保证构建器可信可用,即使新增表演,代码工作量也很少。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

优化完做下简单测试,打了一场40回合的5v5 pvp战斗保存录像,比较两种方案的保存录像文件大小:优化后文件大小是优化前的65%,减少了252KB,由于5v5pvp表演复杂,因此回合表演包数据本身也非常多,换做是一般的战斗,数据优化比率会更高。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

4. 录像上传/下载策略

妖尾一次协议收发有64KB大小限制,看前面的数据可知,回合数比较多的战斗录像文件大小肯定会超过64KB,我们既不希望上传/下载录像单次传输的数据量超过64KB,又不希望单次传输数据量太少,导致协议发送次数过多,浪费太多时间在RRT上,因此采用的录像传输策略是,首次传输单独发送录像头,后续传输录像数据块切块传输,保证每次传输的所有BattleReplayFileBlock的data总大小不超过50KB。采用这样的策略,5回合以内的小型战斗基本都能分2次传输完毕,像上面的5v5 pvp大型战斗则需要进行11次传输。这就引出了下个问题思考,大型战斗的录像观看会不会有体验问题。

5.流式传输及录像缓存

战斗录像大厅的设计初衷,是让玩家可以自主分享/观看他们觉得满意的战斗录像,所以我们猜测玩家会比较多的上传/下载/观看大型pvp战斗录像,对于上传而言并不会有什么问题,因为就是一次性操作,但对下载/观看场景就要尽量进行优化,我们不希望玩家每次看录像,都要有感知地等待一会,等上10次网络回包,下载完录像文件才能观看录像,也不希望玩家每次看录像都得重复下载文件,对玩家的手机流量也很不友好。

针对这两点问题,战斗录像参考网络视频的做法,加上了流式传输及录像缓存的特性。

记录战斗记录你,详解妖尾战斗录像系统[Unity]-LMLPHP

如上图所示,流式传输的目的在于优化玩家观看新录像的体验,不管一个完整的录像有多大,需要多少次传输才能完成,只需要先获得部分头部数据,就能观看录像。前台只需要头2次回包,获取录像概况、初始回合战场包和表演包,就足以表演第1回合的战斗,进入录像战斗后,静默下载其余的录像数据,一般后续的录像数据下载速度远远快于战斗表演速度,这样完全不影响整场战斗的录像观看。假设网络环境极端恶劣,表演完当前回合战斗后,后续录像数据还没返回,BattleReplayManager会每帧轮询等待下个回合表演数据,即使网络断掉了拿不到数据,玩家仍然可以点击按钮退出战斗录像。

录像缓存的目的则在于优化玩家重复观看录像的体验,减少流量消耗。当看过一次录像,下载了完整的录像数据后,前台就会把录像保存到本地缓存起来了,尽管录像头里存储了部分战斗录像大厅的字段,比如点赞、收藏数等,这些字段数据会失效,但战斗数据是不会变的。查看大厅的录像列表时,后台会返回只有录像头BattleReplayFile,没有数据块BattleReplayFileBlock的列表,玩家请求观看时,判断本地缓存有没有该录像缓存,有就不再走原来的下载流程,直接读取缓存文件播放即可。

洋洋洒洒写了一些关于战斗录像的总结,也确实是因为录像系统对战斗开发调试有所帮助,作为一个功能系统,也需要在早期考虑一些问题,做设计和优化,希望本文能对MMORPG或其他类型游戏战斗的设计开发,提供一些借鉴经验。

附上我们的游戏官网[妖精的尾巴:魔导少年],快来玩吧~

05-23 00:51