상세 컨텐츠

본문 제목

[ 월드 엔드 프론트라인 ] 롤20 세팅 자료 모음

롤 20, TRPG 배포용 자료/매크로

by 배포하는 Shen 2024. 1. 15. 22:04

본문

  월드 엔드 프론트라인을 하시려는 분들이 탐라에 슬슬 보이기 시작해서, 정리도 할 겸 겸사겸사 올려봅니다. 

  몽그린 님께서 개발해주신 다이스체크 api도 있고, 이번에도 수치변동을 확인하기 위한 양천일염님의 api를 수정한 코드도 있습니다. 이 룰은 스킬 출력을 채팅창에 할 수 있어야 좋은데 커스텀 시트도 없는 편이라 이래저래 간이로 개발했다보니... 엉성한데다가 세팅할 때 손이 많이 가는 편이긴 합니다. 

  코코포리아에서 공식에서 주는 원터치 세팅 파일과 채팅 팔레트, 다이스봇을 사용하는 것이 개인적으로 세팅 시간은 훨씬 덜 들었습니다. 대신, 줄바꿈을 이용하고 싶으시거나, 롤20이 더 편하다! 하시는 분들도 분명히 있으실 것이기에 포스팅이 길어지더라도 최대한 꾹꾹 눌러담아 보았습니다. 

 

  제 기준으로 작성했기 때문에 꼭 이런 식으로 세팅하지 않으셔도 됩니다. 

  월드 엔드 프론트라인의 트윈룰 격인 세계의 숨통을 끊는 RPG 블러도리움에서도 일부 호환성이 있으므로, 사용할 수 있다면 사용하셔도 좋습니다. (이후에 나오는 프래그먼트 시스템도 포함)

  일부 세팅은 api를 이용한 것입니다. api를 사용하려면 프로 계정이 필요합니다. 이 점 유의해 주세요. 

  문제가 되거나, 오류 사항이 있거나, 제대로 작동하지 않는다면 배포계의 DM을 찾아와주세요! 

 

 

구성: 채팅창 / 인트로 매크로, 범용 세션카드, 스킬/독트린 css, 수치 변동 확인을 위한 attribute_tracker 수정 코드(양천일염님 api 수정), 다이스체크 api(몽그린 님 개발, 대리 배포), 다이스체크 api를 사용할 수 없을 때 대용 방법

 

 

 

 

 

1. 채팅창 매크로 / 인트로 매크로

채팅창 매크로와 인트로 매크로 둘 다 이미지 수정을 금지합니다. 

채팅창 매크로 코드 구성과, 인트로 매크로의 css 코드 부분은 편하신대로 자유롭게 수정해서 사용해 주세요. 

 

 

1-1. 채팅창 매크로

<!--진행-->
/desc https://imgur.com/?{진행|오프닝,nvUemZj|메인,fJEuo1k|파이널,WUemIsP|애프터,YjdasSD|사이드,pG2ZIDv}.png
<!--전투-->
/desc https://imgur.com/?{전투|----라운드----|1라운드,ahLj7PB|2라운드,rMTIHmF|3라운드,utOoI7E|4라운드,C2EHJQU|5라운드,udkkNaZ|6라운드,Ah2rVhL|----진행----|세트,744fAnt|전조,Hx6hARP|전정자의 턴,4K3Kpcb|서브 독트린,rIv1Scj|메인 독트린,Q5OPb7E|라운드 종료,UI4npmE}.png
<!--키워드 콜렉트-->
https://imgur.com/Q4i0uKN.png
<!--행동 완료-->
https://imgur.com/hVHD87I.png

 

<!-- --> 이 부분은 주석입니다.
구분하기 위해서 넣은거라 포함해서 넣으셔도 채팅창에 출력은 되지 않습니다. 

 

 

 

1-2. 인트로 매크로

/desc https://imgur.com/kjImDzh.png
/desc [?{시나리오 이름}](#" style=" color: #790302 !important; text-decoration:none; font-style: normal;)
/desc [?{부제 or 캐치프라이즈}](#" style=" color: #DE5320  !important; text-decoration:none; font-style: normal; font-weight: normal; font-size: 11px;)[ ?{줄바꿈 필요시 넣고 없으면 바로 엔터}](#" style=" color: #DE5320 !important; text-decoration:none; font-style:normal; font-weight: normal; font-size: 11px; display:block;)
/desc [World End Frontline Scenario](#" style=" color: #FD6385  !important; text-decoration:none; font-weight: normal; font-size: 10px;)[Written by. ?{라이터 이름}](#" style=" color: #FD6385 !important; text-decoration:none; font-weight: normal; font-size: 10px; display: block;)
/desc https://imgur.com/HHOoIhZ.png
/desc [PC](#"style="text-decoration: none; color: #790302 !important; font-size: 13px; font-style: normal;)[ ?{PC 이름}](#"style="text-decoration: none; font-size: 13px;  font-style: normal; color: #333;)
/desc [Date](#"style="text-decoration: none; color: #790302 !important; font-size: 13px; font-style: normal;)[ ?{날짜}](#"style="text-decoration: none; font-size: 13px;  font-style: normal; color: #333;)
/desc https://imgur.com/SWYkJWJ.png
/desc https://imgur.com/kjImDzh.png
/desc https://imgur.com/HHOoIhZ.png
/desc https://imgur.com/SWYkJWJ.png

 

 

 

 

 

2. 범용 세션카드

 

캐릭터 이름이나 PL의 이름, 날짜를 넣는 것 외에는 수정을 금지합니다. 

이미지 크기가 커서 이미지 압축(링크)을 해서 쓰시기 바랍니다. 

 

원본 링크: https://drive.google.com/file/d/1Jf2MN4JP03FQCNC44MCHLdsb3Qx_0i8R/view?usp=drive_link

 

 

 

 

 

3. 스킬/독트린 css

코드 정리가 다소 되어있지 않은 편입니다. 

css로 만든 코드인데, 줄바꿈을 가독성 있게 하려면 문단별로 코드를 새로 붙여야해서 코드가 지저분하고 길이가 깁니다. 

그래서 내용을 넣어야 할 곳에는 붉은색으로 표시를 해두었습니다. 

문단이 더 길어지거나 짧아지는 것 때문에 발생하는 수정은 자유롭게 해주세요. 

 

css 정리 링크: https://docs.google.com/document/d/1yCar_T7uU2g_Sh3jWoYqWWYlGKjrxvasqbsKvbd7b6g/edit?usp=sharing

 

적용해보면 다음과 같은 서식으로 출력됩니다.

왼쪽은 전정자용 스킬 출력 결과고, 오른쪽은 부조화용 독트린 출력 결과입니다. 

 

요정 소행은 서포트 페이지에 공개된 스킬이고, 독트린은 임의 작성입니다.

 

 

 

아래는 전정자 기준의 매크로 적용입니다. 

묵허님의 프래그먼트 시스템 바이오 시트(링크)를 사용합니다. 

 

저도 몇 년 전에 단편적으로 알았던 방법을 제대로 모아서 써봤는데 직관적이고 좋은 것 같다고 생각합니다. 

덧붙여 바이오 시트를 사용하지 않아도 Ability 탭에서 바로 매크로를 누르면 됩니다만... 바이오 시트에 연동을 해서 쓰는 것이 직관적이라 더 좋은 편이니까요. 

 

우선, 센터 스테이지와 스킬 6개를 모두 Abilities 탭에 등록시킵니다. 

 

그 다음, 바이오시트를 작성 후 Bio & Info 탭의 개요 및 정보 붙여넣습니다.

 

 

 

마지막으로 Abilities 탭에 등록해뒀던 매크로 이름을 토대로 주사위 토큰 부분(🎲)을 긁어 하이퍼링크 `%{저널의 캐릭터 명|매크로명}을 차례로 등록합니다. 

그 뒤, Bio & Info 탭에서 각 스킬의 🎲를 클릭하면 채팅창에 스킬 css가 정상 출력 됨을 확인할 수 있습니다. 

부조화도 같은 방법으로 서브 독트린과 메인 독트린을 등록시키고, 부조화용 바이오 시트를 이용하여 링크화 하면 되겠습니다. 

 

 

 

 

 

4. attribute_tracker

아래는 API 출처입니다. 일부 설명문도 차용했습니다. 

 

원본 코드 작성자의 이름: 양천일염
원본 코드의 출처 링크: https://github.com/kibkibe/roll20-api-scripts/blob/master/attribute_tracker/attribute_tracker.js
원본 코드의 이름: attribute_tracker/attribute_tracker.js
원본 코드의 CC 라이선스: CC BY-NC (저작자표시-비영리)

 

자세한 건 attribute_tracker 페이지를 참고해주세요. 

 

1. 세션방의 대문에 해당하는 페이지에서 [설정]->[API 스크립트]를 선택해 스크립트 수정 페이지로 들어갑니다. (PRO 계정에서만 이 메뉴가 보입니다.)
2. prior_list: "All Players, 부조화이름"에 부조화이름 대신 부조화의 캐릭터 저널로 해둔 이름을 넣습니다. 

    (우선순위를 All Players로 해두었기 때문에 전정자 저널은 권한 기준, All Players로 해두셔야 합니다)
3. New Script에 이 코드들을 복사해 붙여놓습니다. 

더보기
/* 원본 출처: https://github.com/kibkibe/roll20-api-scripts/tree/master/attribute_tracker */
/* (attribute_tracker.js) 210720 코드 시작 */

// define: option
const at_setting = {
	// option: 변경을 감지할 속성을 목록 형태로 지정합니다.
	// 룰별 check list코드 공유페이지 https://docs.google.com/spreadsheets/d/1_uTqPs6FQJfjzDotRWqtJn8U6cVw_lVycDRal8vxZb8/edit#gid=609977791
	check_list:
	/* 체크리스트 시작 */
	[	{attr: "HP", name: "HP"},
		{attr: "HP_max", name: "최대_HP"},
		{attr: "gauge", name: "게이지"},
		{attr: "fragment", name: "프래그먼트"},
		{attr: "fragment_max", name: "최대_프래그먼트"},
		{attr: "collect", name: "콜렉트_포인트"},
		{attr: "center", name: "센터_스테이지"}]
	/* 체크리스트 끝 */
	,
	// option: 필수적으로 변화를 체크할 캐릭터의 이름을 기입합니다.
	// 이 값은 ignore_list보다 우선됩니다. (복수입력시 콤마(,)로 구분)
	prior_list: "All Players, 부조화이름",
	// option: 로그 표시에서 제외할 캐릭터의 이름을 기입합니다. (복수입력시 콤마(,)로 구분)
	// "GM"을 넣으면 GM에게만 조작권한이 있는 모든 캐릭터를 일괄적으로 제외합니다.
	ignore_list: "GM",
	// option: !at 명령어를 이용한 숨김/표시 모드를 사용하지 않은 기본상태에서 스테이터스 변경 내역을 GM에게 귓말로만 보낼지(true) 모두에게 표시할지(false) 설정합니다.
	use_secret_mode: false
}
// /define: option
    
on('ready', function() {
	// on.ready
	if (state.hide_tracking != at_setting.use_secret_mode) {
		state.hide_tracking = at_setting.use_secret_mode;
		show_current_status();
	}
    state.new_character = [];
    on("add:character",function(obj) {
        state.new_character.push({id:obj._id, time: Date.now()});
    });
    on("add:attribute", function(obj) {
        const now = Date.now();
        const interval = 3000;
        let check = true;
        for (let index = 0; index < state.new_character.length; index++) {
            const element = state.new_character[index];
            if (obj._id == element.id) {
                if (now - element.time > interval) {
                    state.new_character.splice(index,1);
                } else {
                    check = false;
                }
                break;
            }
        }
        if (check) {
            check_attribute(obj, null);
        }
    });
	// /on.ready
});
    
on("change:attribute", function(obj, prev) {
	// on.change:attribute
    check_attribute(obj, prev);
	// /on.change:attribute
});

on("chat:message", function(msg)
{
if (msg.type == "api" ){
	// on.chat:message:api
    if (msg.content.indexOf("!at") === 0 && (msg.playerid == 'API' || playerIsGM(msg.playerid))) {
		if (msg.content == "!at") {
			sendChat("attribute_tracker.js","/w gm [ 명령어 ]<br>- **!at show**: 로그 표시하기 / **!at hide**: 로그 숨기기",null,{noarchive:true});
		} else if (msg.content.toLowerCase().includes('hide')) {
            state.hide_tracking = true;
        } else if (msg.content.toLowerCase().includes('show')) {
            state.hide_tracking = false;
        }
		show_current_status();
	}
	// /on.chat:message:api
}
});

// define: global function
function show_current_status() {

	sendChat("attribute_tracker.js","/w gm <br>- 코드상의 옵션: **" + (at_setting.use_secret_mode ? "숨김":"표시")
	+ (at_setting.use_secret_mode == state.hide_tracking ? "" : "** / 명령어로 지정된 모드: **" + (state.hide_tracking ? "숨김" : "표시"))
	+ "**<br>- 현재 스테이터스 변동내역이 " + (state.hide_tracking ? "GM에게만 귓속말로 전달되고 있습니다.":"모든 사용자에게 공개되고 있습니다."),null,{noarchive:true});
}

function check_attribute(obj,prev) {
    try {
        var check_pl = false;
        let cha = getObj('character',obj.get('_characterid'));
		const prior_list = at_setting.prior_list.split(/\s*,\s*/g);
		const ignore_list = at_setting.ignore_list.split(/\s*,\s*/g);
        if (prior_list.indexOf(cha.get('name')) > -1 || at_setting.ignore_list == 0) {
            check_pl = true;
        } else if (ignore_list.indexOf(cha.get('name')) > -1) {
            check_pl = false;
        } else if (ignore_list.indexOf('GM') > -1) {
            let controller = cha.get('controlledby').split(",");
            for (var i=0;i<controller.length;i++) {
                if (controller[i].length > 0 && !playerIsGM(controller[i])) {
                    check_pl = true;
                    break;
                }
            }
        }
        
        if (check_pl) {
            for (var i=0;i<at_setting.check_list.length;i++) {
                var check = false;
                var item = at_setting.check_list[i];
                let item_id = null;
                let attr_name = item.attr.replace('attr_');
                let check_max = attr_name.includes('_max');
                if (prev == null || check_max && obj.get('current') == prev.current || !check_max && obj.get('current') != prev.current) {
                    if (attr_name.replace('_max','') == obj.get('name')) {
                        check = true;
                    } else if (attr_name.indexOf('*id*') > -1) {
                        let split_attr = attr_name.split('*id*');
                        if (split_attr.length == 2 && obj.get('name').startsWith(split_attr[0]) && obj.get('name').endsWith(split_attr[1])) {
                            check = true;
                            item_id = obj.get('name').replace(split_attr[0],'').replace(split_attr[1],'');
                        }
                    }
                }
                if (check) {
                    var name = item.name;
                    if (name.indexOf('*id*') > -1) {
						name = item.name.replace('*id*',item_id);
                        var search_name = findObjs({type: "attribute", name: name, characterid: obj.get('_characterid')});
                        if (search_name.length > 0) {
                            name = search_name[0].get('current');
                        } else {
                            sendChat("error","/w gm 이름이 " + name + "인 값이 캐릭터시트에 없습니다.",null,{noarchive:true}); return;
                        }
                    }
                    sendChat("character|"+obj.get('_characterid'),
                    (state.hide_tracking ? "/w GM ":"") +
                        "**" + name + "** / <span style='color:#aaaaaa'>" + (prev == null?"":(check_max? prev.max:prev.current)) + "</span><span style='color:#777777'> → </span><b>" +
                        (check_max ? obj.get('max'):obj.get('current'))+"</b>",null,{noarchive:false});
                    break;
                }
            }
        }
    } catch (err) {
        sendChat("error","/w gm " + err,null,{noarchive:true});
    }
}
// /define: global function

 

4. 전정자 시트를 만들어줍니다. 그런 뒤, Attributes & Abilities에 가서 Attribute에 HP(최대 有), guage, fragement(최대 有), collect, center를 Name에 채우고 0을 넣어서 api 작동 확인 겸 초기화를 해줍니다. 

  최대 有라고 되어있는 시트는 현재치 / 최대치에서 최대치까지 초기화 해줍니다. 

5. 그런 뒤, 필요한 수치들을 기입해줍니다. 

 

 

6. 맵 위에 놓을 토큰에는 전투 시에 필요한 것들만 연동해줍니다. 토큰에는 최대 3개까지 밖에 연동할 수 없기 때문에, 콜렉트 포인트와 센터 스테이는 세션 도중에 Attribute 탭에서 직접 증감해주세요. 

   맵에 캐릭터 저널의 인장을 떨어뜨려 토큰을 만든 뒤, 더블 클릭해서 토큰바에 HP, gauge(게이지), fragment(프래그먼트)를 연동시켜줍니다. 

  이 때 현재치만 있는 경우, 토큰바가 표시가 안 되기 때문에 로스트 수치나 0을 기입해 주시길 바랍니다. 

  또한, 토큰바의 수치 연동이 끝난 뒤에는 옆의 점 3개에서 플레이어 권한에서 "보기"를 체크하고, 텍스트 오버레이를 "모두에게 표시"로 바꿔주세요. 

 

7. 부조화도 동일한 방법으로 하지만, 사용하는 수치가 다르기 때문에 HP(최대 有), fragment(최대 有), collect만 채워주세요. 맵의 토큰에도 HP와 fragment만 연동해주시면 됩니다. 또한, 월드 엔드 프론트라인은 에너미의 HP와 프래그먼트 수도 공개하고 전투를 시작하기 때문에 보기 권한은 전정자와 모두 동일하게 해주세요. 

 

 

 

 

 

5. 다이스체크 api

 

  해당 api 코드는 몽그린 님(@mongreen_tr)이 개발하고, Shen이 대리배포하는 코드입니다. 

  일부 수정해서 사용 가능하며, 30% 이상의 코드 수정을 거치지 않았을 경우 재배포 및 자작 발언이 불가능합니다. 수정 후 재배포 할 경우에도 출처를 웬만하면 명시해 주세요.

  해당 코드는 트윈룰로 나온 '세계의 숨통을 끊는 RPG 블러도리움'에서도 사용 가능합니다.

 

 

1. 세션방의 대문에 해당하는 페이지에서 [설정]->[API 스크립트]를 선택해 스크립트 수정 페이지로 들어갑니다. (PRO 계정에서만 이 메뉴가 보입니다.)
2. New Script에 이 코드들을 복사해 붙여놓습니다. 

더보기
on("chat:message", function(msg) {
  //This allows players to enter !dc <number> to roll a number of d6 dice with a target of 4.
  if(msg.type == "api" && msg.content.indexOf("!dc ") !== -1) {
    var diceStr = msg.content.replace("!dc ", "");   
    diceStr = diceStr.replace(/\+-/g, '-');
    var match = /^(\d+)(d)(\d+|)(\+|-)(\d+|)$/i.exec(diceStr);

    if (match === null) {
      log("Cannot parse dice string: "+diceStr);
      sendChat(msg.who, "/w gm Dice Roll API Error: Check API console log.");
      return;
    }
    var numdice = parseInt(match[1]); 
    var modiNum = parseInt(match[5])
    sendChat(msg.who, "/roll " + numdice + "d6s", function(qRollMsg){
        if(qRollMsg[0].type !== 'rollresult'){
            log('Not a rollresult');
            log(qRollMsg);
            return;
          }
        var qRollResult = extractExplodingRoll(JSON.parse(qRollMsg[0].content));
        const strDice = qRollResult.join(', ');
        var maxDice = max(qRollResult);
        var maxTime = compare(qRollResult);
        var damageTotal = maxDice * maxTime + modiNum;

        var printChat = "<div style='overflow-x:auto;'><table style='border:1px solid #ddd; border-collapse:collapse; table-layout:auto;'><tbody><tr><th style='padding: 5px; line-height: 22px; width:230px; border:1px solid #aaa; background-color: #292929; color: #f2f2f2' colspan=3>"+msg.who+" | "+diceStr+"</th></tr>"
        printChat += "<tr style='background-color: #ffffff; line-height: 13px;'><td style='width=20px;'><b>&nbsp;ROLL : </b></td>"
        printChat += "<td style='padding: 10px;text-align:right;' colspan=2>["+strDice+"]</td></tr>"
        printChat += "<tr style='background-color: #ffffff; line-height: 13px;'><td style='width=20px;'><b>&nbsp;TOTAL : </b></td>"
        printChat += "<td style='padding: 10px; text-align:right;'><mark>"+damageTotal+"</mark></td></tr></tbody></table><div>"
        sendChat(msg.who, printChat);
    })
  }
});

function extractExplodingRoll(content){
  var arr = new Array(0);
  var rolls = content.rolls[0].results;
  for(var i=0; i<rolls.length; i+=1){
    arr.push(rolls[i].v);
  }
  return arr;
}

function max(arr){
	var max = 0;
	for(var i=0; i< arr.length; i++){
		if(arr[i] > max) max = arr[i];
	}
	return max;
}

function compare(arr){
    const diceTime = [0,0,0,0,0,0,0];
    
	for (let i in arr) { 
    	let key = arr[i]; 
    	diceTime[key] = diceTime[key] === undefined ? 1 : diceTime[key] = diceTime[key] + 1;
    }
    var maxTime = max(diceTime);

    return maxTime;
}

 

3. 매크로에 !dc ?{굴릴 다이스 개수|0}d6+?{수정치|0}를 넣고 권한을 All Players로 해주세요. 수정치가 없어도 0을 넣어야지 정상 작동합니다. 

 

 

 

 

6. 다이스체크 api를 사용할 수 없을 때 대용 방법

 

  프로 계정이 아니라면 api를 사용할 수 없습니다. 일반적인 매크로로는 다이스체크는 구현이 불가능한 편입니다. 매크로는 말그대로 정해진 걸 순차적으로 수행하는 것에 가까워서 이전에 나온 다이스가 몇 개씩 나왔는지 그런 걸 구현하기는 쉽지 않아요. 

  그런 경우에는 수동이지만 그나마 들이는 시간을 줄이기 위해 다음과 같은 방법을 추천합니다. 

 

 

  다이스체크: n다이스인 경우에 /r nd6s로 판정을 해주세요. 

  d6 뒤에 s를 붙이는 것은 다이스의 판정 결과를 오름차순으로 정렬해서 출력해주기 때문에, 몇 개가 나왔는지 비교적 빨리 셀 수 있습니다. 

 

 

 

 


 本作は「著: 瀧里フユ・潮牡丹/どらこにあん、KADOKAWA」が権利を有する『ワールドエンドフロントライン』の二次創作作品です。
본작은 「저자: 타키자토 후유・다리아타이도/드라코니언, 카도카와」가 권리를 가진 『월드 엔드 프론트라인』의 2차 창작물입니다.

 

Copyright 2024 @Shen_TRPG019271, @mongreen_tr at twitter. All rights reserved.

관련글 더보기

댓글 영역