diff --git a/.gitignore b/.gitignore index b5bd41e21..6ad9adbd4 100644 --- a/.gitignore +++ b/.gitignore @@ -101,3 +101,8 @@ out.log /.idea/* /.continue/agents + +# Hand-written Oniimai regression harness (not a Unity-generated project). +!Tools/OniimaiTests/FrameMerge/FrameMerge.csproj +Tools/OniimaiTests/FrameMerge/bin/ +Tools/OniimaiTests/FrameMerge/obj/ diff --git a/Assets/Plugins/Android/AndroidManifest.xml b/Assets/Plugins/Android/AndroidManifest.xml index d0e05fd02..ab1637cb9 100644 --- a/Assets/Plugins/Android/AndroidManifest.xml +++ b/Assets/Plugins/Android/AndroidManifest.xml @@ -2,9 +2,11 @@ + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/MajdataPlayActivity.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/MajdataPlayActivity.java index d2f8388e2..eac42eebe 100644 --- a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/MajdataPlayActivity.java +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/MajdataPlayActivity.java @@ -5,6 +5,7 @@ import android.net.Uri; import android.os.Bundle; import android.view.KeyEvent; +import net.majdata.majdataplay.oniimai.OniimaiController; import com.unity3d.player.UnityPlayer; import com.unity3d.player.UnityPlayerActivity; @@ -21,8 +22,15 @@ public class MajdataPlayActivity extends UnityPlayerActivity protected void onCreate(Bundle savedInstanceState) { currentActivity = this; + // Unity's generated manifest can disable Android View acceleration. + // Enable the window before its views attach so dashboard updates do not + // rasterize the entire phone UI on the CPU during external gameplay. + getWindow().addFlags(android.view.WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED); super.onCreate(savedInstanceState); + getWindow().getDecorView().post(() -> android.util.Log.i("OniimaiDisplay", + "Phone UI hardware accelerated=" + getWindow().getDecorView().isHardwareAccelerated())); SystemCAImporter.tryInit(getApplicationContext()); + OniimaiController.attach(this); } @Override protected void onNewIntent(Intent intent) @@ -46,6 +54,7 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) @Override public boolean dispatchKeyEvent(KeyEvent event) { + if (OniimaiController.handleKey(event)) return true; if (onDispatchKeyEventCallbackProxy != null) { onDispatchKeyEventCallbackProxy.OnDispatchKeyEvent(event.getAction(), event.getKeyCode()); @@ -53,10 +62,33 @@ public boolean dispatchKeyEvent(KeyEvent event) return super.dispatchKeyEvent(event); } + @Override public void onBackPressed() { + if(!OniimaiController.dashboardBack())super.onBackPressed(); + } + @Override protected void onResume() { + super.onResume(); OniimaiController.resume(); + } + @Override protected void onPause() { + OniimaiController.pause(); super.onPause(); + } + @Override protected void onDestroy() { + OniimaiController.detach(); super.onDestroy(); currentActivity = null; + } + @Override public void onWindowFocusChanged(boolean focused) { + super.onWindowFocusChanged(focused); OniimaiController.focus(focused); + } + @Override public void onConfigurationChanged(android.content.res.Configuration configuration) { + super.onConfigurationChanged(configuration); OniimaiController.textConfigurationChanged(); + } public static Activity getCurrentActivity() { return currentActivity; } + /** Only called on the Android UI thread at external surface attach/detach. */ + public boolean oniimaiSurface(android.view.Surface surface) + { + return mUnityPlayer.displayChanged(0, surface); + } public static void registerOnNewIntentCallback(CSharpOnNewIntentCallback callback) { if (onNewIntentCallbackProxy == null) diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai.meta new file mode 100644 index 000000000..866a52f25 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai.meta @@ -0,0 +1,5 @@ +fileFormatVersion: 2 +guid: 6d589671f44e46fa84457c1a11f108a2 +folderAsset: yes +DefaultImporter: + externalObjects: {} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetMenu.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetMenu.java new file mode 100644 index 000000000..5baf04ecb --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetMenu.java @@ -0,0 +1,89 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.graphics.*; +import android.graphics.drawable.*; +import android.content.res.ColorStateList; +import android.util.TypedValue; +import android.view.*; +import android.widget.*; +import java.util.*; + +/** The game's circular settings composition, with real accessible Android controls. */ +final class CabinetMenu extends FrameLayout { + interface Host {void category(int index);void move(int direction);void adjust(int direction);void activate();void close();void overview();} + private final Host host; + private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.FILTER_BITMAP_FLAG); + private final Path circle=new Path(); + private final GameUi.Pattern pattern=new GameUi.Pattern(); + private final ArrayList items=new ArrayList<>(); + private final TextView[] tabs=new TextView[4]; + private final TextView title,value,hint,count,left,right; + private final View minus,plus,open; + private int previousCategory=-1,previousItem=-1; + private static final class Item {final View view;final float x,y,w,h,size;Item(View v,float x,float y,float w,float h,float s){view=v;this.x=x;this.y=y;this.w=w;this.h=h;size=s;}} + CabinetMenu(Context c,Host host) { + super(c);this.host=host;setWillNotDraw(false);setClipChildren(true); + pattern.setBounds(0,0,1080,1080); + String[] names={GameUi.tr("연결","连接"),GameUi.tr("입력","输入"),"LED",GameUi.tr("화면","屏幕")}; + for(int i=0;i<4;i++){final int n=i;tabs[i]=text(names[i],31,GameUi.INK);tabs[i].setSingleLine();tabs[i].setOnClickListener(v->host.category(n));GameUi.buttonRole(tabs[i]);place(tabs[i],166+i*187,254,178,78,31);} + title=text("",40,GameUi.GOLD);place(title,323,433,434,80,40); + value=text("",84,GameUi.GOLD);place(value,328,522,424,143,84); + open=new View(c);open.setBackground(new RippleDrawable(ColorStateList.valueOf(0x22ffffff),null,GameUi.shape(c,Color.WHITE,50)));GameUi.buttonRole(open);open.setOnClickListener(v->host.activate());place(open,305,390,470,312,0); + left=text("",24,GameUi.INK);place(left,116,460,179,171,24); + right=text("",24,GameUi.INK);place(right,785,460,179,171,24); + minus=artButton(18,GameUi.tr("값 줄이기","减小数值"),()->host.adjust(-1));place(minus,290,682,165,110,0); + plus=artButton(17,GameUi.tr("값 늘리기","增大数值"),()->host.adjust(1));place(plus,625,682,165,110,0); + hint=text("",27,GameUi.INK);place(hint,162,804,756,96,27); + count=text("",23,GameUi.INK);place(count,415,946,250,42,23); + edge("left",GameUi.tr("이전 분류","上一分类"),5,264,144,150,()->host.category(-1)); + edge("right",GameUi.tr("다음 분류","下一分类"),931,264,144,150,()->host.category(-2)); + edge("left",GameUi.tr("이전 항목","上一项目"),5,660,144,150,()->host.move(-1)); + edge("right",GameUi.tr("다음 항목","下一项目"),931,660,144,150,()->host.move(1)); + } + private TextView text(String value,int size,int color){TextView t=GameUi.text(getContext(),value,size,color,false);t.setGravity(Gravity.CENTER);t.setLineSpacing(0,1);return t;} + private View artButton(int id,String description,Runnable click){ + ImageView b=new ImageView(getContext());b.setImageBitmap(GameAssets.sprite(getContext(),id));b.setScaleType(ImageView.ScaleType.FIT_XY);b.setContentDescription(description);b.setFocusable(true);b.setOnClickListener(v->click.run()); + GameUi.buttonRole(b);b.setForeground(GameUi.ripple(getContext(),Color.TRANSPARENT,40));return b; + } + private void edge(String symbol,String description,float x,float y,float w,float h,Runnable action){ + View icon=new View(getContext()){ + final Paint p=new Paint(Paint.ANTI_ALIAS_FLAG); + @Override protected void onDraw(Canvas c){super.onDraw(c);c.save();float s=getWidth()/144f;c.translate(getWidth()/2f-29*s,getHeight()/2f-29*s);c.scale(s,s);p.setColor(GameUi.GOLD);p.setStrokeWidth(4);p.setStrokeCap(Paint.Cap.ROUND);p.setStrokeJoin(Paint.Join.ROUND);p.setStyle(Paint.Style.STROKE);Path path=new Path(); + if(symbol.equals("done")){path.moveTo(12,31);path.lineTo(24,43);path.lineTo(46,13);} + else if(symbol.equals("back")){path.moveTo(27,10);path.lineTo(12,21);path.lineTo(27,30);path.moveTo(13,21);path.cubicTo(58,14,56,56,23,44);} + else {if(symbol.equals("right")){c.translate(58,0);c.scale(-1,1);}path.moveTo(43,29);path.lineTo(13,29);path.moveTo(27,13);path.lineTo(11,29);path.lineTo(27,45);} + c.drawPath(path,p);c.restore();} + };icon.setContentDescription(description);GameUi.buttonRole(icon);icon.setOnClickListener(v->action.run());icon.setBackground(GameUi.ripple(getContext(),Color.TRANSPARENT,50));place(icon,x,y,w,h,0); + } + private void place(View v,float x,float y,float w,float h,float size){items.add(new Item(v,x,y,w,h,size));addView(v);} + void show(int category,int index,int total,String name,String current,String description,String previous,String next,boolean adjustable){ + for(int i=0;i<4;i++){tabs[i].setBackground(GameUi.ripple(getContext(),i==category?GameUi.TAB:0xe6e3e8e9,40));tabs[i].setTextColor(i==category?Color.WHITE:GameUi.INK);tabs[i].setSelected(i==category);} + GameUi.setText(title,name);GameUi.setText(value,current);GameUi.setText(hint,description);GameUi.setText(count,(index+1)+" / "+total+" · ONIIMAI"); + GameUi.setText(left,previous);GameUi.setText(right,next);open.setContentDescription(name+": "+current+". "+description); + minus.setVisibility(adjustable?VISIBLE:INVISIBLE);plus.setVisibility(adjustable?VISIBLE:INVISIBLE); + value.setTextSize(TypedValue.COMPLEX_UNIT_PX,getWidth()/1080f*fontScale()*(current.length()>18?28:current.length()>9?36:current.length()>5?48:72)); + if(previousCategory!=category||previousItem!=index){GameUi.appear(title);GameUi.appear(value);GameUi.appear(hint);}previousCategory=category;previousItem=index; + invalidate(); + } + void busy(boolean busy){GameUi.enabled(open,!busy);GameUi.enabled(minus,!busy);GameUi.enabled(plus,!busy);} + private float fontScale(){return Math.max(1f,Math.min(1.5f,getResources().getConfiguration().fontScale));} + @Override protected void onMeasure(int ws,int hs){int size=MeasureSpec.getSize(ws);setMeasuredDimension(size,size);float s=size/1080f; + for(Item i:items){int min=i.view.isClickable()?GameUi.dp(getContext(),GameUi.TOUCH_DP):0; + if(i.view instanceof TextView&&i.view!=value){TextView label=(TextView)i.view;int max=Math.max(2,Math.round(i.size*s*fontScale()));label.setAutoSizeTextTypeUniformWithConfiguration(Math.max(1,Math.round(i.size*s*.8f)),max,1,TypedValue.COMPLEX_UNIT_PX);} + i.view.measure(MeasureSpec.makeMeasureSpec(Math.max(min,Math.round(i.w*s)),MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(Math.max(min,Math.round(i.h*s)),MeasureSpec.EXACTLY));} + String current=value.getText().toString();value.setTextSize(TypedValue.COMPLEX_UNIT_PX,s*fontScale()*(current.length()>18?28:current.length()>9?36:current.length()>5?48:72)); + } + @Override protected void onLayout(boolean changed,int l,int t,int r,int b){float s=getWidth()/1080f;for(Item i:items){int w=i.view.getMeasuredWidth(),h=i.view.getMeasuredHeight();int x=Math.max(0,Math.min(getWidth()-w,Math.round((i.x+i.w/2)*s-w/2f))),y=Math.max(0,Math.min(getHeight()-h,Math.round((i.y+i.h/2)*s-h/2f)));i.view.layout(x,y,x+w,y+h);}} + @Override protected void onDraw(Canvas c){super.onDraw(c);c.save();c.scale(getWidth()/1080f,getHeight()/1080f);circle.reset();circle.addCircle(540,540,539,Path.Direction.CW);c.clipPath(circle); + GameAssets.draw(c,getContext(),22,92,245,988,824,paint); + paint.setColor(0x99fffdf4);c.drawRect(115,250,965,820,paint); + paint.setColor(0xffffe6ca);paint.setStrokeWidth(3);paint.setPathEffect(new DashPathEffect(new float[]{11,9},0));c.drawLine(111,350,969,350,paint);c.drawLine(111,751,969,751,paint);paint.setPathEffect(null); + GameAssets.draw(c,getContext(),10,146,146,934,934,paint); + GameAssets.draw(c,getContext(),16,295,376,785,723,paint); + pad(c,0,340);pad(c,1080,340);pad(c,0,740);pad(c,1080,740); + c.restore(); + } + private void pad(Canvas c,float x,float y){GameAssets.draw(c,getContext(),20,x-132,y-132,x+132,y+132,paint);} + @Override protected void dispatchDraw(Canvas c){c.save();Path p=new Path();p.addCircle(getWidth()/2f,getHeight()/2f,getWidth()/2f,Path.Direction.CW);c.clipPath(p);super.dispatchDraw(c);c.restore();} +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetMenu.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetMenu.java.meta new file mode 100644 index 000000000..0412c966e --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetMenu.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: cc6ed3d65e6a400c9b1b906618bb0382 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetPage.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetPage.java new file mode 100644 index 000000000..8c859f773 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetPage.java @@ -0,0 +1,25 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.view.*; + +/** Cabinet layout with the game pattern filling the entire phone display. */ +final class CabinetPage extends ViewGroup { + private final View header,menu; + CabinetPage(Context context,View header,View menu){super(context);this.header=header;this.menu=menu;setBackgroundColor(GameUi.PAGE);addView(header);addView(menu); + if(android.os.Build.VERSION.SDK_INT>=29)setForceDarkAllowed(false); + if(android.os.Build.VERSION.SDK_INT>=30)setOnApplyWindowInsetsListener((v,insets)->{android.graphics.Insets safe=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());setPadding(safe.left,safe.top,safe.right,safe.bottom);return insets;}); + } + @Override protected void onMeasure(int ws,int hs){ + int totalW=MeasureSpec.getSize(ws),totalH=MeasureSpec.getSize(hs),w=totalW-getPaddingLeft()-getPaddingRight(),h=totalH-getPaddingTop()-getPaddingBottom(),margin=GameUi.dp(getContext(),8); + header.measure(MeasureSpec.makeMeasureSpec(w-2*margin,MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(Math.round(h*.44f),MeasureSpec.AT_MOST)); + int side=Math.min(w-2*margin,Math.max(1,h-header.getMeasuredHeight()-2*margin)); + menu.measure(MeasureSpec.makeMeasureSpec(side,MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(side,MeasureSpec.EXACTLY));setMeasuredDimension(totalW,totalH); + } + @Override protected void onLayout(boolean changed,int l,int t,int r,int b){ + int x=getPaddingLeft(),y=getPaddingTop(),w=getWidth()-x-getPaddingRight(),h=getHeight()-y-getPaddingBottom(),margin=GameUi.dp(getContext(),8),head=header.getMeasuredHeight(),side=menu.getMeasuredWidth(); + header.layout(x+margin,y+margin,x+w-margin,y+margin+head); + int top=Math.min(h-side-margin,Math.max(head+2*margin,Math.round(h*.56f-side/2f))); + menu.layout(x+(w-side)/2,y+top,x+(w+side)/2,y+top+side); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetPage.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetPage.java.meta new file mode 100644 index 000000000..753867bac --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CabinetPage.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: cdff877c825347ae97c6a9294a8ac8c6 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CeilingOutput.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CeilingOutput.java new file mode 100644 index 000000000..917a84485 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CeilingOutput.java @@ -0,0 +1,77 @@ +package net.majdata.majdataplay.oniimai; + +import android.os.SystemClock; +import java.util.concurrent.*; +import java.util.function.Supplier; + +/** Shares the IO4 input handle; USB output never runs on Unity or the UI thread. */ +final class CeilingOutput { + private final Supplier transport; + private final ScheduledExecutorService worker = Executors.newSingleThreadScheduledExecutor(); + private volatile boolean enabled = true, foreground = true, destroyed; + private volatile int brightness = 100, testColor = -1, state; + private volatile long testUntil, sent; + private volatile Frame latest; + private UsbIo.Hid current; + private int last = -1; + private long retryAt; + private static final class Frame { + final int rgb; final long time; + Frame(int rgb) { this.rgb = rgb; time = SystemClock.uptimeMillis(); } + } + CeilingOutput(Supplier transport) { + this.transport = transport; + worker.scheduleWithFixedDelay(this::poll, 0, 33, TimeUnit.MILLISECONDS); + } + void submit(int[] colors) { + if (colors != null && colors.length == 9) latest = new Frame(LedFrames.ceiling(colors)); + } + void settings(boolean enabled, int brightness) { + this.enabled = enabled; this.brightness = Math.max(0, Math.min(100, brightness)); + if (!enabled) testColor = -1; + } + void foreground(boolean value) { foreground = value; if (!value) testColor = -1; } + void test(int rgb) { testColor = rgb & 0xffffff; testUntil = SystemClock.uptimeMillis() + 2000; } + private static String tr(String ko, String zh) { return "zh-Hans".equals(UiText.language()) ? zh : ko; } + String summary() { + if (!enabled) return tr("스피커 RGB OFF", "扬声器 RGB 已关闭"); + if (state == 0) return tr("스피커 RGB · IO4 연결 대기", "扬声器 RGB · 等待 IO4 连接"); + if (state == 3) return tr("스피커 RGB · 출력 확인 필요", "扬声器 RGB · 请检查输出"); + if (!foreground) return tr("스피커 RGB · 백그라운드 소등", "扬声器 RGB · 后台熄灯"); + return state == 2 ? tr("스피커 RGB · 게임 연동 중", "扬声器 RGB · 游戏联动中") + : tr("스피커 RGB · 게임 조명 대기", "扬声器 RGB · 等待游戏灯光"); + } + String diagnostic() { return summary() + " / HID sent: " + sent; } + private void poll() { + if (destroyed) return; + UsbIo.Hid port = transport.get(); + if (port != current) { current = port; last = -1; retryAt = 0; } + if (port == null) { state = 0; return; } + long now = SystemClock.uptimeMillis(); + if (now < retryAt) return; + int rgb = 0; boolean seen = false; + if (enabled && foreground) { + Frame frame = latest; + if (frame != null && now - frame.time < 1000) { rgb = frame.rgb; seen = true; } + if (testColor >= 0 && now < testUntil) { rgb = testColor; seen = true; } + else testColor = -1; + rgb = LedFrames.scale(rgb, brightness); + } + try { + if (last != rgb) { port.writeCeiling(rgb); last = rgb; sent++; } + state = seen ? 2 : 1; + } catch (Exception error) { + // Output failures must not disconnect buttons/touch or queue old colours. + state = 3; retryAt = now + 2000; last = -1; + } + } + void destroy() { + if (destroyed) return; + destroyed = true; + worker.execute(() -> { + UsbIo.Hid port = transport.get(); + if (port != null) try { port.writeCeiling(0); } catch (Exception ignored) {} + }); + worker.shutdown(); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CeilingOutput.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CeilingOutput.java.meta new file mode 100644 index 000000000..f3fbdc292 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CeilingOutput.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 17089bb85d5b4681a693ddf20a786940 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CommandChannel.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CommandChannel.java new file mode 100644 index 000000000..f4e440f6e --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CommandChannel.java @@ -0,0 +1,37 @@ +package net.majdata.majdataplay.oniimai; + +import java.io.IOException; +import java.util.concurrent.*; + +/** One outstanding request; debug messages are independent. Timeout poisons the channel to prevent stale ACK reuse. */ +public final class CommandChannel implements AutoCloseable { + public interface Logger {void log(String message);} + private final UsbIo.Cdc port;private volatile CompletableFuture waiting;private volatile int expected=-1; + private volatile boolean closed;private final Logger logger; + public CommandChannel(UsbIo.Cdc port,UsbIo.Bytes debug,Logger logger,UsbIo.Failure failure) { + this.port=port;this.logger=logger; + Protocol.CommandParser parser=new Protocol.CommandParser((cmd,status,data)->{ + if(cmd==Protocol.DEBUG_DATA){if(status==0)debug.accept(data,data.length);return;} + logger.log("RX cmd="+String.format("%02X",cmd)+" status="+status+" bytes="+data.length); + CompletableFuture pending=waiting; + if(pending!=null && cmd==expected){if(status==0)pending.complete(data);else pending.completeExceptionally(new IOException(UiText.t("장치 오류 상태: ")+status));} + }); + port.start(parser::feed,message->{closed=true;fail(new IOException(message));port.close();failure.accept(message);}); + } + public synchronized byte[] request(int cmd,byte[] data) throws IOException { + if(closed)throw new IOException(UiText.t("명령 연결이 닫혔습니다. 다시 연결하세요.")); + CompletableFuture future=new CompletableFuture<>();expected=cmd;waiting=future; + try { + byte[] packet=Protocol.command(cmd,data);logger.log("TX cmd="+String.format("%02X",cmd)+" bytes="+data.length);port.write(packet); + return future.get(4,TimeUnit.SECONDS); + } catch(TimeoutException e) {closed=true;port.close();throw new IOException(UiText.t("응답 시간 초과. 포트 선택을 확인한 뒤 다시 연결하세요."));} + catch(ExecutionException e){throw new IOException(e.getCause().getMessage(),e.getCause());} + catch(InterruptedException e){Thread.currentThread().interrupt();throw new IOException(UiText.t("요청 취소"),e);} + catch(IOException e){closed=true;port.close();throw e;} + finally {waiting=null;expected=-1;} + } + public byte[] request(int cmd) throws IOException {return request(cmd,new byte[0]);} + public boolean isClosed(){return closed;} + private void fail(IOException error){CompletableFuture future=waiting;if(future!=null)future.completeExceptionally(error);} + public void close(){closed=true;fail(new IOException(UiText.t("연결 해제")));port.close();} +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CommandChannel.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CommandChannel.java.meta new file mode 100644 index 000000000..61a99bcee --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CommandChannel.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: f48f8aaeeed943fdb56822b648aea174 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardLayout.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardLayout.java new file mode 100644 index 000000000..b8941988c --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardLayout.java @@ -0,0 +1,297 @@ +package net.majdata.majdataplay.oniimai; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.Collections; +import java.util.List; + +/** Persisted logical cells, independent of pixels, orientation, and the Android view lifecycle. */ +final class DashboardLayout { + static final int COLS = 4; + static final int MAX_ROWS = 64; + static final int MAX_ITEMS = 20; + static final String SONG = "song", SCORE = "score", JUDGMENTS = "judgments", + TIMING = "timing", GRAPH = "graph", SENSORS = "sensors", + CONNECTION = "connection", CLOCK = "clock"; + private static final String HEADER = "oniimai-dashboard-v2"; + private static final String LEGACY_HEADER = "oniimai-dashboard-v1"; + private static final List TYPES = Collections.unmodifiableList(Arrays.asList( + SONG, SCORE, JUDGMENTS, TIMING, GRAPH, SENSORS, CONNECTION, CLOCK)); + + static final class Item { + final int id, x, y, w, h; + final String type; + + Item(int id, String type, int x, int y, int w, int h) { + this.id = id; + this.type = type; + this.x = x; + this.y = y; + this.w = w; + this.h = h; + } + } + + private final ArrayList entries = new ArrayList<>(); + // A long prevents overflow when reading an otherwise valid highest possible integer ID. + private long nextId = 1; + + private DashboardLayout() {} + + static DashboardLayout empty() { return new DashboardLayout(); } + + static DashboardLayout defaults() { + DashboardLayout layout = empty(); + layout.entries.add(new Item(1, SONG, 0, 0, 2, 2)); + layout.entries.add(new Item(2, SCORE, 2, 0, 2, 2)); + layout.entries.add(new Item(3, GRAPH, 0, 2, 2, 2)); + layout.entries.add(new Item(4, JUDGMENTS, 2, 2, 2, 2)); + layout.entries.add(new Item(5, SENSORS, 0, 4, 2, 2)); + layout.entries.add(new Item(6, TIMING, 2, 4, 2, 2)); + layout.entries.add(new Item(7, CONNECTION, 0, 6, 4, 2)); + layout.entries.add(new Item(8, CLOCK, 0, 8, 4, 2)); + layout.nextId = 9; + return layout; + } + + static List types() { return TYPES; } + static boolean knownType(String type) { return TYPES.contains(type); } + /** Fresh arrays keep callers from changing the supported widget sizes. */ + static int[][] sizes(String type) { + if (!knownType(type)) return new int[0][]; + if (SENSORS.equals(type)) return new int[][] {{2, 2}, {4, 4}}; + if (JUDGMENTS.equals(type)) return new int[][] {{2, 2}, {4, 2}, {4, 4}}; + return new int[][] {{2, 2}, {4, 2}}; + } + + static boolean validSize(String type, int width, int height) { + if (!knownType(type)) return false; + if (SENSORS.equals(type)) return (width == 2 && height == 2) || (width == 4 && height == 4); + return (width == 2 && height == 2) || (width == 4 && height == 2) + || (JUDGMENTS.equals(type) && width == 4 && height == 4); + } + + /** Snap a gesture or legacy rectangle to the closest supported size, preferring smaller ties. */ + static int[] nearestSize(String type, float width, float height) { + int[][] available = sizes(type); + if (available.length == 0) return null; + if (Float.isNaN(width) || Float.isInfinite(width) || Float.isNaN(height) || Float.isInfinite(height)) + return new int[] {defaultWidth(type), defaultHeight(type)}; + int[] nearest = available[0]; + double distance = Double.POSITIVE_INFINITY; + for (int[] size : available) { + double dx = (double)width - size[0], dy = (double)height - size[1]; + double candidate = dx * dx + dy * dy; + if (candidate < distance) { + nearest = size; + distance = candidate; + } + } + return nearest; + } + + static int minWidth(String type) { return !knownType(type) ? 0 : 2; } + static int minHeight(String type) { return !knownType(type) ? 0 : 2; } + static int defaultWidth(String type) { return minWidth(type); } + static int defaultHeight(String type) { return minHeight(type); } + + /** Invalid records are skipped without losing good widgets. A wholly corrupt layout resets. */ + static DashboardLayout parse(String saved) { + if (saved == null || saved.length() > 16384) return defaults(); + String[] lines = saved.trim().split("\\r?\\n", -1); + String[] header = lines[0].split("\\|", -1); + if (header.length != 2 || (!HEADER.equals(header[0]) && !LEGACY_HEADER.equals(header[0]))) return defaults(); + boolean legacy = LEGACY_HEADER.equals(header[0]); + DashboardLayout layout = empty(); + try { + layout.nextId = Long.parseLong(header[1]); + if (layout.nextId < 1 || layout.nextId > (long)Integer.MAX_VALUE + 1) return defaults(); + } catch (NumberFormatException error) { + return defaults(); + } + boolean hadRecords = false; + for (int line = 1; line < lines.length; line++) { + if (lines[line].trim().isEmpty()) continue; + hadRecords = true; + String[] fields = lines[line].split("\\|", -1); + if (fields.length != 6 || layout.entries.size() >= MAX_ITEMS) continue; + try { + Item item = new Item(Integer.parseInt(fields[0]), fields[1], + Integer.parseInt(fields[2]), Integer.parseInt(fields[3]), + Integer.parseInt(fields[4]), Integer.parseInt(fields[5])); + if (item.id <= 0 || layout.get(item.id) != null + || !(legacy ? fitsLegacy(item, layout.entries) : fits(item, layout.entries, -1))) continue; + layout.entries.add(item); + layout.nextId = Math.max(layout.nextId, (long)item.id + 1); + } catch (NumberFormatException ignored) { + // Keep the remaining good records after a damaged preference value. + } + } + if (hadRecords && layout.entries.isEmpty()) return defaults(); + if (legacy) layout.migrateSizes(); + return layout; + } + + /** Preserve valid saved positions; only a collision or new bounds violation requires packing. */ + private void migrateSizes() { + ArrayList migrated = new ArrayList<>(); + boolean needsPacking = false; + for (Item item : entries) { + int[] size = nearestSize(item.type, item.w, item.h); + Item next = new Item(item.id, item.type, item.x, item.y, size[0], size[1]); + if (!fits(next, migrated, -1)) needsPacking = true; + migrated.add(next); + } + if (needsPacking && packInOrder(migrated)) return; + entries.clear(); + entries.addAll(migrated); + } + + String encode() { + StringBuilder output = new StringBuilder(HEADER).append('|').append(nextId); + for (Item item : entries) { + output.append('\n').append(item.id).append('|').append(item.type).append('|') + .append(item.x).append('|').append(item.y).append('|') + .append(item.w).append('|').append(item.h); + } + return output.toString(); + } + + DashboardLayout copy() { + DashboardLayout copy = empty(); + copy.entries.addAll(entries); // Items are immutable, so editors cannot mutate the saved layout. + copy.nextId = nextId; + return copy; + } + + List items() { return Collections.unmodifiableList(new ArrayList<>(entries)); } + + Item get(int id) { + for (Item item : entries) if (item.id == id) return item; + return null; + } + + Item add(String type) { + if (!knownType(type) || entries.size() >= MAX_ITEMS || nextId > Integer.MAX_VALUE) return null; + Item item = firstFree((int)nextId, type, defaultWidth(type), defaultHeight(type), entries); + if (item == null) return null; + entries.add(item); + nextId++; + return item; + } + + boolean remove(int id) { + Item item = get(id); + return item != null && entries.remove(item); + } + + boolean move(int id, int x, int y) { + Item previous = get(id); + return previous != null && replace(previous, new Item(id, previous.type, x, y, previous.w, previous.h)); + } + + boolean resize(int id, int width, int height) { + Item previous = get(id); + return previous != null && replace(previous, new Item(id, previous.type, previous.x, previous.y, width, height)); + } + + /** Resize in place when possible, otherwise atomically pack the desired rectangles in order. */ + boolean resizeAndPack(int id, int width, int height) { + Item previous = get(id); + if (previous == null || !fits(new Item(id, previous.type, 0, 0, width, height), Collections.emptyList(), -1)) return false; + if (resize(id, width, height)) return true; + ArrayList ordered = new ArrayList<>(entries); + ordered.set(entries.indexOf(previous), new Item(id, previous.type, previous.x, previous.y, width, height)); + return packInOrder(ordered); + } + + private boolean replace(Item previous, Item next) { + if (!fits(next, entries, previous.id)) return false; + entries.set(entries.indexOf(previous), next); + return true; + } + + /** Pack in stored order; commit only if all existing widgets fit with their current sizes. */ + boolean pack() { return packInOrder(entries); } + + /** Explicit compact preset; existing saved sizes change only when the user requests it. */ + boolean compact() { + ArrayList compacted = new ArrayList<>(); + for (Item item : entries) + compacted.add(new Item(item.id, item.type, item.x, item.y, 2, 2)); + return packInOrder(compacted); + } + + /** Accessible one-step reorder; drag uses move() and preserves the stored order. */ + boolean moveBy(int id, int direction) { + Item item = get(id); + if (item == null || direction == 0) return false; + int index = entries.indexOf(item); + int destination = index + (direction < 0 ? -1 : 1); + if (destination < 0 || destination >= entries.size()) return false; + ArrayList ordered = new ArrayList<>(entries); + Collections.swap(ordered, index, destination); + return packInOrder(ordered); + } + + private boolean packInOrder(List ordered) { + ArrayList packed = new ArrayList<>(); + for (Item item : ordered) { + Item next = firstFree(item.id, item.type, item.w, item.h, packed); + if (next == null) return false; + packed.add(next); + } + entries.clear(); + entries.addAll(packed); + return true; + } + + void clear() { entries.clear(); } + + void reset() { + DashboardLayout initial = defaults(); + entries.clear(); + entries.addAll(initial.entries); + nextId = initial.nextId; + } + + int rows() { + int rows = 0; + for (Item item : entries) rows = Math.max(rows, item.y + item.h); + return rows; + } + + private static Item firstFree(int id, String type, int width, int height, List occupied) { + if (!validSize(type, width, height)) return null; + for (int y = 0; y <= MAX_ROWS - height; y++) { + for (int x = 0; x <= COLS - width; x++) { + Item item = new Item(id, type, x, y, width, height); + if (fits(item, occupied, -1)) return item; + } + } + return null; + } + + private static boolean fits(Item item, List occupied, int ignoredId) { + if (!validSize(item.type, item.w, item.h) + || item.x < 0 || item.x > COLS - item.w || item.y < 0 || item.y > MAX_ROWS - item.h) return false; + return doesNotOverlap(item, occupied, ignoredId); + } + + /** Validate old data before resizing it, so corruption cannot turn into phantom widgets. */ + private static boolean fitsLegacy(Item item, List occupied) { + int minW = SENSORS.equals(item.type) ? 3 : 2; + int minH = SENSORS.equals(item.type) ? 4 : JUDGMENTS.equals(item.type) ? 3 : 2; + if (!knownType(item.type) || item.w < minW || item.w > COLS || item.h < minH || item.h > MAX_ROWS + || item.x < 0 || item.x > COLS - item.w || item.y < 0 || item.y > MAX_ROWS - item.h) return false; + return doesNotOverlap(item, occupied, -1); + } + + private static boolean doesNotOverlap(Item item, List occupied, int ignoredId) { + for (Item other : occupied) { + if (other.id != ignoredId && item.x < other.x + other.w && item.x + item.w > other.x + && item.y < other.y + other.h && item.y + item.h > other.y) return false; + } + return true; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardLayout.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardLayout.java.meta new file mode 100644 index 000000000..49bab8b6a --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardLayout.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 7ed9a6a7c313442e834cfc385890b835 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardView.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardView.java new file mode 100644 index 000000000..3476d6f81 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardView.java @@ -0,0 +1,314 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.SharedPreferences; +import android.graphics.*; +import android.graphics.drawable.Drawable; +import android.view.*; +import android.widget.*; +import java.util.*; + +/** In-app widget board. Editing uses a private draft; only Save changes the stored board. */ +final class DashboardView extends LinearLayout implements SharedPreferences.OnSharedPreferenceChangeListener { + static final String KEY = "dashboardLayoutV1"; + private static final String DRAFT_KEY = KEY + "Draft"; + private static final String[] TYPES = {"song","score","judgments","timing","graph","sensors","connection","clock"}; + private final OniimaiController owner; + private final boolean preview; + private final Runnable close; + private final LinearLayout header, toolbar; + private final TextView hint; + private final ScrollView scroll; + private final Board board; + private DashboardLayout layout; + private boolean editing; + private int viewportHeight; + private int selected = -1; + private String previousJson = ""; + private org.json.JSONObject statsFrame = new org.json.JSONObject(); + private final Runnable refresh = new Runnable() { + public void run() { owner.display.updateDashboard(DashboardView.this); postOnAnimationDelayed(this, 150); } + }; + + DashboardView(OniimaiController owner, boolean preview, Runnable close) { + super(owner.activity); this.owner=owner; this.preview=preview; this.close=close; + editing=owner.prefs.contains(DRAFT_KEY); + layout=DashboardLayout.parse(owner.prefs.getString(editing?DRAFT_KEY:KEY,"")); + setOrientation(VERTICAL); setBackground(new GameUi.Pattern()); setClickable(true); + if(android.os.Build.VERSION.SDK_INT>=29)setForceDarkAllowed(false); + if(android.os.Build.VERSION.SDK_INT>=30)setOnApplyWindowInsetsListener((v,insets)->{ + Insets safe=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()); + setPadding(safe.left,safe.top,safe.right,safe.bottom); return insets; + }); + header=new LinearLayout(getContext());header.setGravity(Gravity.CENTER_VERTICAL); + header.setPadding(dp(18),dp(14),dp(18),dp(10));addView(header); + hint=GameUi.text(getContext(),"",12,GameUi.MUTED,false); + hint.setPadding(dp(20),0,dp(20),dp(12));addView(hint); + scroll=new ScrollView(getContext()){ + @Override protected void onMeasure(int ws,int hs){ + viewportHeight=Math.max(0,MeasureSpec.getSize(hs)-getPaddingTop()-getPaddingBottom()); + super.onMeasure(ws,hs); + } + };scroll.setFillViewport(true);scroll.setClipToPadding(true); + scroll.setPadding(dp(16),dp(6),dp(16),dp(22));scroll.setVerticalScrollBarEnabled(true); + scroll.setScrollBarStyle(View.SCROLLBARS_OUTSIDE_OVERLAY); + scroll.setVerticalFadingEdgeEnabled(true);scroll.setFadingEdgeLength(dp(18)); + board=new Board();scroll.addView(board,new ScrollView.LayoutParams(-1,-2)); + addView(scroll,new LinearLayout.LayoutParams(-1,0,1)); + toolbar=new LinearLayout(getContext());toolbar.setGravity(Gravity.CENTER_VERTICAL); + toolbar.setPadding(dp(16),dp(10),dp(16),dp(12));toolbar.setBackgroundColor(0xfafffaf0);addView(toolbar); + chrome();board.rebuild(); + } + private int dp(float n){return GameUi.dp(getContext(),n);} + static String title(String type){switch(type){ + case "song":return GameUi.tr("곡 정보","歌曲信息"); + case "score":return GameUi.tr("점수 · 콤보","成绩 · 连击"); + case "judgments":return GameUi.tr("판정 통계","判定统计"); + case "timing":return "FAST / LATE"; + case "graph":return GameUi.tr("곡 진행 · 밀도","进度 · 密度"); + case "sensors":return GameUi.tr("터치 · 버튼","触摸 · 按钮"); + case "connection":return GameUi.tr("장치 상태","设备状态"); + default:return GameUi.tr("시계","时钟"); + }} + private static String description(String type){switch(type){ + case "song":return GameUi.tr("재킷, 곡명, 아티스트와 난이도","封面、曲名、艺术家和难度"); + case "score":return GameUi.tr("달성률, 콤보와 잔여 DX 점수","达成率、连击和剩余 DX 分数"); + case "judgments":return GameUi.tr("Critical부터 Miss까지 실시간 판정","实时显示 Critical 至 Miss 判定"); + case "timing":return GameUi.tr("빠른 판정과 늦은 판정 비교","比较偏快和偏慢的判定"); + case "graph":return GameUi.tr("재생 시간과 채보 밀도 그래프","播放时间和谱面密度图"); + case "sensors":return GameUi.tr("실제 34개 터치 영역과 버튼 입력","34 个真实触摸区和按钮状态"); + case "connection":return GameUi.tr("USB 입력, LED와 외부 화면 상태","USB 输入、LED 和外接屏幕状态"); + default:return GameUi.tr("현재 시간과 날짜","当前时间和日期"); + }} + private void chrome(){ + header.removeAllViews();toolbar.removeAllViews(); + LinearLayout words=GameUi.column(getContext()); + TextView caption=GameUi.text(getContext(),"MajdataPlay / Oniimai",12,GameUi.BLUE,true);caption.setPadding(0,0,0,dp(5));words.addView(caption); + words.addView(GameUi.text(getContext(),editing?GameUi.tr("위젯 편집","编辑小组件"):GameUi.tr("내 대시보드","我的仪表盘"),26,GameUi.SLATE,true)); + header.addView(words,new LinearLayout.LayoutParams(0,-2,1)); + TextView action=GameUi.action(getContext(),editing?GameUi.tr("저장","保存"):GameUi.tr("편집","编辑"),true,()->{if(editing)save();else edit(-1);}); + action.setContentDescription(editing?GameUi.tr("위젯 배치 저장","保存小组件布局"):GameUi.tr("위젯 편집 시작","开始编辑小组件"));header.addView(action); + hint.setText(editing?GameUi.tr("2×2 · 4×2 · 4×4 / 끌어서 이동 · 모서리로 크기 변경","2×2 · 4×2 · 4×4 / 拖动移动 · 拖角调整大小"): + preview?GameUi.tr("미리보기 · 저장한 배치는 외부 모니터 연결 시에도 사용됩니다","预览 · 保存的布局也用于外接显示器模式"): + GameUi.tr("길게 눌러 원하는 위젯을 배치하세요","长按即可自定义小组件布局")); + hint.setVisibility(editing||preview?View.VISIBLE:View.GONE); + if(editing){ + footer(GameUi.tr("+ 위젯","+ 小组件"),this::catalog,true); + footer(GameUi.tr("배치","布局"),this::arrange,false); + footer(GameUi.tr("취소","取消"),this::finish,false); + }else{ + footer(preview?GameUi.tr("‹ 돌아가기","‹ 返回"):GameUi.tr("폰으로 전환","切换到手机"),close,false); + if(!preview)footer(GameUi.tr("설정","设置"),owner.panel::open,false); + } + } + private void footer(String label,Runnable action,boolean primary){ + TextView v=GameUi.action(getContext(),label,primary,action);v.setTextSize(14);v.setPadding(dp(8),dp(12),dp(8),dp(12)); + LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(0,-2,1);if(toolbar.getChildCount()>0)lp.leftMargin=dp(8);toolbar.addView(v,lp); + } + private void edit(int id){if(!editing){layout=layout.copy();editing=true;}selected=id;chrome();board.rebuild();} + private void save(){owner.prefs.edit().putString(KEY,layout.encode()).apply();finish();toast(GameUi.tr("위젯 배치를 저장했습니다","已保存小组件布局"));} + private void finish(){editing=false;owner.prefs.edit().remove(DRAFT_KEY).apply();selected=-1;layout=DashboardLayout.parse(owner.prefs.getString(KEY,""));chrome();board.rebuild();} + boolean back(){ + if(!editing)return false; + owner.panel.choose(GameUi.tr("편집을 마칠까요?","结束编辑?"),new String[]{GameUi.tr("저장하고 마치기","保存并结束"),GameUi.tr("변경 취소","放弃更改"),GameUi.tr("계속 편집","继续编辑")},-1,i->{if(i==0)save();else if(i==1)finish();});return true; + } + private void catalog(){ + String[] options=new String[TYPES.length]; + for(int i=0;i0)dimensions.append(" · ");dimensions.append(size[0]).append("×").append(size[1]);} + options[i]=title(TYPES[i])+" · "+dimensions+"\n"+description(TYPES[i]); + } + owner.panel.choose(GameUi.tr("위젯 추가","添加小组件"),options,-1,i->{ + DashboardLayout.Item item=layout.add(TYPES[i]); + if(item==null){toast(GameUi.tr("위젯은 최대 20개까지 배치할 수 있습니다","最多可放置 20 个小组件"));return;} + selected=item.id;board.rebuild();board.post(()->scroll.smoothScrollTo(0,board.rowTop(item.y))); + }); + } + private void arrange(){ + owner.panel.choose(GameUi.tr("배치 도구","布局工具"),new String[]{GameUi.tr("전체 2×2 배치","全部设为 2×2"),GameUi.tr("빈 공간 정리","整理空白"),GameUi.tr("기본 배치","默认布局"),GameUi.tr("플레이 집중 배치","游玩专注布局"),GameUi.tr("입력 테스트 배치","输入测试布局"),GameUi.tr("모든 위젯 비우기","清空所有小组件")},-1,i->{ + if(i==0)layout.compact(); + else if(i==1)layout.pack(); + else if(i==2)layout=DashboardLayout.defaults(); + else{ + for(DashboardLayout.Item item:new ArrayList<>(layout.items()))layout.remove(item.id); + String[] types=i==3?new String[]{"song","score","timing","judgments","graph"}:i==4?new String[]{"connection","sensors","clock"}:new String[0]; + for(String type:types)layout.add(type); + } + selected=-1;board.rebuild();scroll.smoothScrollTo(0,0); + }); + } + private void options(int id){ + DashboardLayout.Item item=layout.get(id);if(item==null)return;select(id); + owner.panel.choose(title(item.type),new String[]{GameUi.tr("크기 변경","调整大小")+" · "+item.w+" × "+item.h,GameUi.tr("앞으로 이동","向前移动"),GameUi.tr("뒤로 이동","向后移动"),GameUi.tr("하나 더 추가","再添加一个"),GameUi.tr("위젯 삭제","删除小组件")},-1,i->{ + if(i==0){sizes(id);return;} + if(i==1||i==2){if(!layout.moveBy(id,i==1?-1:1))toast(GameUi.tr("더 이동할 수 없습니다","无法继续移动"));} + else if(i==3){DashboardLayout.Item added=layout.add(item.type);if(added!=null)selected=added.id;else toast(GameUi.tr("위젯 개수 또는 공간이 부족합니다","小组件数量已达上限或空间不足"));} + else layout.remove(id); + board.rebuild(); + }); + } + private void sizes(int id){ + DashboardLayout.Item item=layout.get(id);if(item==null)return; + int[][] sizes=DashboardLayout.sizes(item.type);String[] names=new String[sizes.length];int checked=-1; + for(int n=0;n{ + int[] size=sizes[i];if(!resizeAndPack(id,size[0],size[1]))toast(GameUi.tr("공간이 부족합니다. 다른 위젯을 이동해 주세요","空间不足,请移动其他小组件"));board.rebuild(); + }); + } + private boolean resizeAndPack(int id,int w,int h){ + return layout.resizeAndPack(id,w,h); + } + private void toast(String message){Toast.makeText(getContext(),message,Toast.LENGTH_SHORT).show();} + private void select(int id){selected=id;for(int n=0;n{edit(-1);return true;});} + int rowTop(int row){return Math.round(row*(cellHeight+gap));} + private boolean compactBoard(){ + java.util.List items=layout.items(); + if(items.isEmpty()||layout.rows()!=2*((items.size()+1)/2))return false; + for(DashboardLayout.Item item:items)if(item.w!=2||item.h!=2||(item.x!=0&&item.x!=2)||item.y%2!=0)return false; + return true; + } + void rebuild(){removeAllViews();ghost=false;for(DashboardLayout.Item item:layout.items())addView(new Tile(item));requestLayout();invalidate();owner.display.updateDashboard(DashboardView.this);} + @Override protected void onMeasure(int ws,int hs){ + int width=MeasureSpec.getSize(ws);cellWidth=Math.max(1,(width-gap*3)/4f); + gridLeft=0; + // Recover the last whole row when only a small width reduction is needed. + // Editing and deliberately spaced layouts retain their normal cell geometry. + if(!editing&&compactBoard()&&viewportHeight>0){ + float fitCell=(viewportHeight-2-(layout.rows()-1)*gap)/(float)layout.rows(); + float fitWidth=fitCell*4+gap*3; + if(fitCell=width*.88f&&fitCell*2+gap>=dp(148)){ + cellWidth=fitCell;gridLeft=(width-fitWidth)/2; + } + } + cellHeight=cellWidth; + for(int i=0;igetWidth()-dp(16))paint.setTextSize(paint.getTextSize()*(getWidth()-dp(16))/width);canvas.drawText(text,getWidth()/2f,dp(95),paint);} + } + @Override protected void dispatchDraw(Canvas canvas){ + super.dispatchDraw(canvas);if(!ghost)return; + RectF target=new RectF(gridLeft+ghostX*(cellWidth+gap),rowTop(ghostY),gridLeft+ghostX*(cellWidth+gap)+ghostW*cellWidth+(ghostW-1)*gap,rowTop(ghostY)+ghostH*cellHeight+(ghostH-1)*gap); + paint.setStyle(Paint.Style.FILL);paint.setColor(valid?0x30466f97:0x30b53d49);canvas.drawRoundRect(target,dp(22),dp(22),paint); + paint.setStyle(Paint.Style.STROKE);paint.setStrokeWidth(dp(2));paint.setColor(valid?GameUi.BLUE:GameUi.ERROR);canvas.drawRoundRect(target,dp(22),dp(22),paint);paint.setStyle(Paint.Style.FILL); + } + void propose(int id,int x,int y,int w,int h,boolean resizing){ + ghost=true;ghostX=x;ghostY=y;ghostW=w;ghostH=h; + DashboardLayout test=layout.copy();valid=resizing?test.resizeAndPack(id,w,h):test.move(id,x,y); + if(valid&&resizing){DashboardLayout.Item target=test.get(id);ghostX=target.x;ghostY=target.y;}invalidate(); + } + } + private final class Tile extends FrameLayout { + final int id; + final DashboardWidget content; + private final Paint pen=new Paint(Paint.ANTI_ALIAS_FLAG); + private float downX,downY,rawX,rawY; + private int initialScroll; + private boolean dragging,resizing; + private final int[] scrollLocation=new int[2]; + private final Runnable edgeScroll=new Runnable(){public void run(){ + if(!dragging||!isAttachedToWindow())return; + scroll.getLocationOnScreen(scrollLocation);float y=rawY-scrollLocation[1]; + int before=scroll.getScrollY();if(yscroll.getHeight()-dp(70))scroll.scrollBy(0,dp(12)); + if(before!=scroll.getScrollY())updateGesture();postDelayed(this,32); + }}; + private void updateGesture(){ + DashboardLayout.Item item=layout.get(id);if(item==null)return; + float dx=rawX-downX,dy=rawY-downY+scroll.getScrollY()-initialScroll; + setAlpha(.65f);setElevation(dp(6)); + if(resizing){int[] size=DashboardLayout.nearestSize(item.type,item.w+dx/(board.cellWidth+board.gap),item.h+dy/(board.cellHeight+board.gap));board.propose(id,item.x,item.y,size[0],size[1],true);} + else{int x=Math.max(0,Math.min(4-item.w,item.x+Math.round(dx/(board.cellWidth+board.gap))));int y=Math.max(0,Math.min(DashboardLayout.MAX_ROWS-item.h,item.y+Math.round(dy/(board.cellHeight+board.gap))));board.propose(id,x,y,item.w,item.h,false);setTranslationX(dx);setTranslationY(dy);} + } + Tile(DashboardLayout.Item item){ + super(DashboardView.this.getContext());id=item.id;setWillNotDraw(false);GameUi.surface(this); + setBackground(new CardSkin(id)); + setElevation(dp(editing&&selected==id?3:1)); + TextView title=GameUi.text(getContext(),DashboardView.title(item.type),13,GameUi.TAB,true);title.setSingleLine();title.setEllipsize(android.text.TextUtils.TruncateAt.END); + FrameLayout.LayoutParams titleLp=new FrameLayout.LayoutParams(-1,dp(38));titleLp.leftMargin=dp(14);titleLp.rightMargin=dp(editing?68:14);title.setGravity(Gravity.CENTER_VERTICAL);addView(title,titleLp); + content=new DashboardWidget(getContext(),item.type,owner);content.setGridSize(item.w,item.h);FrameLayout.LayoutParams body=new FrameLayout.LayoutParams(-1,-1);body.setMargins(dp(14),dp(38),dp(14),dp(14));addView(content,body); + setFocusable(true);setClickable(true);setContentDescription(DashboardView.title(item.type)+" · "+item.w+" × "+item.h); + setOnLongClickListener(v->{edit(id);performHapticFeedback(HapticFeedbackConstants.LONG_PRESS);return true;}); + setOnClickListener(v->{if(editing)options(id);}); + setAccessibilityDelegate(new View.AccessibilityDelegate(){ + @Override public void onInitializeAccessibilityNodeInfo(View host,android.view.accessibility.AccessibilityNodeInfo info){super.onInitializeAccessibilityNodeInfo(host,info);info.addAction(new android.view.accessibility.AccessibilityNodeInfo.AccessibilityAction(android.view.accessibility.AccessibilityNodeInfo.ACTION_LONG_CLICK,GameUi.tr("위젯 편집","编辑小组件")));} + }); + } + @Override public boolean onInterceptTouchEvent(android.view.MotionEvent event){return editing||super.onInterceptTouchEvent(event);} + @Override public boolean onTouchEvent(android.view.MotionEvent event){ + if(!editing)return super.onTouchEvent(event); + DashboardLayout.Item item=layout.get(id);if(item==null)return true; + switch(event.getActionMasked()){ + case MotionEvent.ACTION_DOWN: + removeCallbacks(edgeScroll);select(id);rawX=downX=event.getRawX();rawY=downY=event.getRawY();initialScroll=scroll.getScrollY();dragging=false; + resizing=event.getX()>getWidth()-dp(40)&&event.getY()>getHeight()-dp(40); + getParent().requestDisallowInterceptTouchEvent(true);return true; + case MotionEvent.ACTION_MOVE: + rawX=event.getRawX();rawY=event.getRawY();float dx=rawX-downX,dy=rawY-downY+scroll.getScrollY()-initialScroll; + if(!dragging&&Math.hypot(dx,dy)512)value=value.substring(0,512); + textPaint.setTypeface(regular);textPaint.setColor(color);textPaint.setTextSize(size); + if(value.indexOf('\n')<0&&textPaint.measureText(value)<=width) { + line(c,value,x,y,width,height,size,color,center?Paint.Align.CENTER:Paint.Align.LEFT,false);return; + } + StaticLayout layout=fittedParagraph(value,width,height,size,color,center,lines); + c.save();c.clipRect(x,y,x+width,y+height);c.translate(x,y+Math.max(0,(height-layout.getHeight())/2));layout.draw(c);c.restore(); + } + /** Measure the actual wrapped lines. Use a private paint so drawing another label cannot change this layout. */ + private StaticLayout fittedParagraph(String value,float width,float height,float size,int color,boolean center,int lines) { + value=value==null?"":value;if(value.length()>512)value=value.substring(0,512); + TextPaint paragraphPaint=new TextPaint(textPaint);paragraphPaint.setTypeface(regular); + paragraphPaint.setColor(color);paragraphPaint.setTextSize(size); + StaticLayout layout=null; + for(int pass=0;pass<3;pass++) { + layout=StaticLayout.Builder.obtain(value,0,value.length(),paragraphPaint,Math.max(1,(int)width)) + .setAlignment(center?Layout.Alignment.ALIGN_CENTER:Layout.Alignment.ALIGN_NORMAL).setIncludePad(false) + .setMaxLines(lines).setEllipsize(TextUtils.TruncateAt.END).setLineSpacing(dp(1),1).build(); + if(layout.getHeight()<=height||pass==2)break; + paragraphPaint.setTextSize(Math.max(1,paragraphPaint.getTextSize()*Math.max(1,height-1)/layout.getHeight())); + } + return layout; + } + private void round(Canvas c,float x,float y,float width,float height,float radius,int color) { + if(width<=0||height<=0)return; + paint.setColor(color);paint.setStyle(Paint.Style.FILL);rect.set(x,y,x+width,y+height);c.drawRoundRect(rect,radius,radius,paint); + } + private boolean wide(){return gridWidth>gridHeight;} + private void centered(Canvas c,String value,float x,float y,float width,float height,float size,int color) { + line(c,value,x,y,width,height,size,color,Paint.Align.CENTER,true); + } + private void centeredLine(Canvas c,String value,float x,float y,float width,float height,float size,int color) { + line(c,value,x,y,width,height,size,color,Paint.Align.CENTER,false); + } + private void stitched(Canvas c,float x,float y,float width,float height) { + round(c,x,y,width,height,Math.min(dp(24),height*.28f),GameUi.SLATE); + float inset=Math.min(dp(6),height*.10f); + rect.set(x+inset,y+inset,x+width-inset,y+height-inset); + paint.setColor(GameUi.GOLD);paint.setStyle(Paint.Style.STROKE);paint.setStrokeWidth(dp(1));paint.setPathEffect(stitch); + c.drawRoundRect(rect,Math.min(dp(19),height*.22f),Math.min(dp(19),height*.22f),paint); + paint.setPathEffect(null);paint.setStyle(Paint.Style.FILL); + } + private void artwork(Canvas c,Bitmap bitmap,float x,float y,float size,boolean crop) { + if(size<1)return; + paint.setStyle(Paint.Style.FILL);paint.setColor(Color.WHITE);c.drawCircle(x+size/2,y+size/2,size/2,paint); + float inset=Math.min(dp(5),size*.06f),inner=size-2*inset; + paint.setColor(live()?GameUi.GOLD:pale);paint.setStyle(Paint.Style.STROKE);paint.setStrokeWidth(inset); + c.drawCircle(x+size/2,y+size/2,(size-inset)/2,paint);paint.setStyle(Paint.Style.FILL); + if(bitmap==null||bitmap.isRecycled())return; + float bw=bitmap.getWidth(),bh=bitmap.getHeight();if(bw<1||bh<1)return; + float scale=crop?Math.max(inner/bw,inner/bh):Math.min(inner/bw,inner/bh); + clip.reset();clip.addCircle(x+size/2,y+size/2,inner/2,Path.Direction.CW); + c.save();c.clipPath(clip);paint.setColor(Color.WHITE);c.drawBitmap(bitmap,null, + new RectF(x+(size-bw*scale)/2,y+(size-bh*scale)/2,x+(size+bw*scale)/2,y+(size+bh*scale)/2),paint);c.restore(); + } + private void song(Canvas c,float w,float h) { + Bitmap art=live()?cover:GameAssets.mascot(getContext()); + if(wide()) { + float edge=Math.min(h*.91f,w*.32f),x=edge+dp(14),ww=w-x; + artwork(c,art,0,(h-edge)/2,edge,live()); + float gap=Math.min(dp(8),h*.045f),labelHeight=Math.min(dp(26),h*.19f); + float artistHeight=Math.min(dp(22),h*.17f); + StaticLayout titleLayout=fittedParagraph(title(),ww,h*.42f,Math.min(sp(27),h*.19f),GameUi.INK,false,2); + float titleHeight=titleLayout.getHeight(); + float blockHeight=labelHeight+titleHeight+artistHeight+gap*2,y=(h-blockHeight)/2; + float labelWidth=Math.min(ww,dp(128)); + round(c,x,y,labelWidth,labelHeight,labelHeight/2,pale); + centered(c,live()?"Lv. "+level():"MAJDATA PLAY",x+dp(8),y,labelWidth-dp(16),labelHeight,Math.min(sp(12),h*.10f),GameUi.TAB); + // A one-line title reserves one line, while a wrapped title reserves its actual two lines. + float titleY=y+labelHeight+gap; + c.save();c.clipRect(x,titleY,x+ww,titleY+titleHeight);c.translate(x,titleY);titleLayout.draw(c);c.restore(); + line(c,artist(),x,titleY+titleHeight+gap,ww,artistHeight,Math.min(sp(14),h*.11f),GameUi.MUTED,Paint.Align.LEFT,false); + } else { + float top=h*.45f,gap=Math.min(dp(10),w*.06f),edge=Math.min(w*.46f,top),badgeX=edge+gap,badgeW=w-badgeX; + artwork(c,art,0,(top-edge)/2,edge,live()); + float badgeH=top*.82f,badgeY=(top-badgeH)/2; + stitched(c,badgeX,badgeY,badgeW,badgeH); + centered(c,"LEVEL",badgeX+dp(5),badgeY+badgeH*.20f,badgeW-dp(10),badgeH*.20f,Math.min(sp(11),badgeH*.21f),GameUi.GOLD); + centered(c,level(),badgeX+dp(5),badgeY+badgeH*.46f,badgeW-dp(10),badgeH*.38f,Math.min(sp(30),badgeH*.40f),GameUi.GOLD); + paragraph(c,title(),0,h*.49f,w,h*.34f,Math.min(sp(21),h*.135f),GameUi.INK,true,2); + centeredLine(c,artist(),0,h*.87f,w,h*.13f,Math.min(sp(12),h*.095f),GameUi.MUTED); + } + } + private void metric(Canvas c,String label,String value,float x,float y,float width,float height,int color) { + round(c,x,y,width,height,Math.min(dp(17),height*.25f),pale); + boolean shortRow=height0) { + float fraction=(float)fast/(fast+late),split=fast>0&&late>0?dp(2):0; + round(c,0,y,Math.max(0,w*fraction-split/2),bar,bar/2,blue); + round(c,w*fraction+split/2,y,Math.max(0,w*(1-fraction)-split/2),bar,bar/2,pink); + } + } + private void graph(Canvas c,float w,float h) { + float footer=Math.min(dp(FOOTER_HEIGHT_DP),h*.19f),gap=Math.min(dp(6),h*.045f); + float barH=Math.min(dp(4),h*.035f),barY=h-footer-gap-barH; + float bottom=barY-gap,pad=Math.min(dp(9),h*.055f); + round(c,0,0,w,bottom,dp(15),Color.WHITE); + if(live()&&graph!=null&&!graph.isRecycled()) { + rect.set(pad,pad,w-pad,bottom-pad);clip.reset();clip.addRoundRect(rect,dp(9),dp(9),Path.Direction.CW); + c.save();c.clipPath(clip);paint.setColor(Color.WHITE);c.drawBitmap(graph,null,rect,paint);c.restore(); + } else { + centered(c,live()?GameUi.tr("밀도 그래프 없음","暂无密度图"):waiting(),pad,bottom*.30f,w-2*pad,bottom*.27f,Math.min(sp(15),h*.11f),GameUi.MUTED); + round(c,pad,bottom*.82f,w-2*pad,dp(2),dp(1),pale); + } + double length=frame.optDouble("length",0),seconds=frame.optDouble("seconds",0); + round(c,0,barY,w,barH,barH/2,pale); + if(live()&&length>0&&Double.isFinite(length)&&Double.isFinite(seconds)) { + float progress=(float)Math.max(0,Math.min(1,seconds/length)); + paint.setColor(pink);paint.setStrokeWidth(dp(2));c.drawLine(pad+progress*(w-2*pad),pad,pad+progress*(w-2*pad),bottom-pad,paint); + round(c,0,barY,w*progress,barH,barH/2,pink); + } + centered(c,times(),0,h-footer,w,footer,Math.min(sp(12),footer*.68f),GameUi.TAB); + } + private void connection(Canvas c,float w,float h) { + String[] names={GameUi.tr("입력","输入"),"LED",GameUi.tr("화면","屏幕")}; + String[] values={inputStatus,ledStatus,outputStatus}; + if(!wide()){ + if(values[0].equals(GameUi.tr("터치와 버튼 준비 완료","触摸和按钮已就绪")))values[0]=GameUi.tr("준비 완료","已就绪"); + if(values[1].startsWith("LED "))values[1]=values[1].substring(4); + int arrow=values[2].indexOf(" → "); + if(arrow>=0){ + values[2]=values[2].substring(arrow+3);int divider=values[2].indexOf(" · "); + if(divider>=0)values[2]=values[2].substring(0,divider)+"\n"+values[2].substring(divider+3); + } + } + int ready=0xff257965; + int[] colors={(inputFlags&3)==3?ready:GameUi.MUTED,owner.prefs.getBoolean("led",true)?blue:GameUi.MUTED,owner.display.active()?ready:GameUi.MUTED}; + float gap=Math.min(dp(6),h*.035f),rh=(h-2*gap)/3; + for(int i=0;i<3;i++) { + float y=i*(rh+gap),tagWidth=Math.min(dp(61),w*.31f); + round(c,0,y,w,rh,Math.min(dp(13),rh*.30f),pale); + float inset=Math.min(dp(10),w*.065f),rowText=Math.min(sp(13),rh*.38f); + line(c,names[i],inset,y,tagWidth-inset,rh,rowText,colors[i],Paint.Align.LEFT,false); + float left=tagWidth+dp(4),available=w-left-dp(6); + if(wide()) { + line(c,values[i],left,y,available,rh,rowText,GameUi.INK,Paint.Align.LEFT,false); + } else { + paragraph(c,values[i],left,y+rh*.08f,available,rh*.84f,Math.min(rh*.34f,Math.min(sp(12),Math.max(sp(10),rh*.29f))),GameUi.INK,false,2); + } + } + } + private void clock(Canvas c,float w,float h) { + float timeWidth=wide()?w*.62f:w,timeHeight=wide()?h:h*.69f; + stitched(c,0,0,timeWidth,timeHeight); + centered(c,localTime(),dp(10),0,timeWidth-dp(20),timeHeight,Math.min(sp(72),h*.40f),GameUi.GOLD); + String period=android.text.format.DateFormat.is24HourFormat(getContext())?"":new SimpleDateFormat("a",locale()).format(new Date()); + if(wide()) { + float x=timeWidth+dp(10),ww=w-x,dateHeight=h*.23f,periodHeight=period.isEmpty()?0:h*.15f; + float gap=period.isEmpty()?0:Math.min(dp(8),h*.06f),y=(h-dateHeight-periodHeight-gap)/2; + centered(c,localDate(),x,y,ww,dateHeight,Math.min(sp(22),h*.17f),GameUi.INK); + if(!period.isEmpty())centered(c,period,x,y+dateHeight+gap,ww,periodHeight,Math.min(sp(14),h*.12f),blue); + } else { + float y=timeHeight+dp(6); + centered(c,localDate()+(period.isEmpty()?"":" · "+period),0,y,w,h-y,Math.min(sp(16),h*.13f),GameUi.INK); + } + } + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardWidget.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardWidget.java.meta new file mode 100644 index 000000000..a39281b5a --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DashboardWidget.java.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 731df5756f234473ad6b9a0a69e6ded7 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayGeometry.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayGeometry.java new file mode 100644 index 000000000..948e67549 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayGeometry.java @@ -0,0 +1,26 @@ +package net.majdata.majdataplay.oniimai; + +/** Portrait render buffer is rotated into the landscape output, without crop or letterbox. */ +final class DisplayGeometry { + static final int BUFFER_WIDTH = 1080, BUFFER_HEIGHT = 1920; + + // TextureView first maps the buffer onto its bounds. Map those bounds to the + // rotated output explicitly, rather than rotating an oversized/clipped View. + static float[] textureMatrix(int outputWidth, int outputHeight, boolean reverse) { + if (outputWidth <= 0 || outputHeight <= 0) throw new IllegalArgumentException("Display size"); + float w = outputWidth, h = outputHeight; + return reverse + ? new float[]{0, w/h, 0, -h/w, 0, h, 0, 0, 1} + : new float[]{0, -w/h, w, h/w, 0, 0, 0, 0, 1}; + } + // SurfaceControl receives the original portrait pixels, before TextureView's + // implicit stretch. Keep the existing full-output mapping in both routes. + static float[] surfaceMatrix(int outputWidth, int outputHeight, boolean reverse) { + if (outputWidth <= 0 || outputHeight <= 0) throw new IllegalArgumentException("Display size"); + float w = outputWidth, h = outputHeight; + return reverse + ? new float[]{0, w/BUFFER_HEIGHT, 0, -h/BUFFER_WIDTH, 0, h, 0, 0, 1} + : new float[]{0, -w/BUFFER_HEIGHT, w, h/BUFFER_WIDTH, 0, 0, 0, 0, 1}; + } + static int angle(boolean reverse) { return reverse ? 270 : 90; } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayGeometry.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayGeometry.java.meta new file mode 100644 index 000000000..7b4d22235 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayGeometry.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: cccd957a9fd24bc49ce4bb5ddda5203c +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayPreview.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayPreview.java new file mode 100644 index 000000000..e649e224c --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayPreview.java @@ -0,0 +1,27 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.graphics.*; +import android.view.View; +import java.util.function.BooleanSupplier; + +/** Read-only orientation preview; the real output is handled by the Presentation. */ +final class DisplayPreview extends View { + private final BooleanSupplier reverse; + private final Paint p=new Paint(Paint.ANTI_ALIAS_FLAG); + DisplayPreview(Context c,BooleanSupplier reverse){super(c);this.reverse=reverse;} + @Override protected void onMeasure(int width,int height){int w=MeasureSpec.getSize(width);setMeasuredDimension(w,resolveSize(Math.round(w*.57f),height));} + @Override protected void onDraw(Canvas c){ + float w=getWidth(),h=getHeight();float mw=w*.85f,mh=mw*9/16,cx=w/2,cy=h/2-GameUi.dp(getContext(),8); + p.setColor(GameUi.LINE);c.drawRoundRect(cx-mw/2-5,cy-mh/2-5,cx+mw/2+5,cy+mh/2+5,14,14,p); + c.save();c.translate(cx,cy);c.rotate(reverse.getAsBoolean()?270:90);float gw=mh,gh=mw; + p.setColor(0xffeaf3fc);c.drawRect(-gw/2,-gh/2,gw/2,gh/2,p); + p.setColor(Color.WHITE);c.drawRoundRect(-gw*.45f,-gh*.45f,gw*.45f,-gh*.25f,7,7,p); + p.setColor(GameUi.PINK);c.drawRoundRect(-gw*.37f,-gh*.39f,gw*.2f,-gh*.36f,3,3,p); + p.setColor(Color.WHITE);c.drawCircle(0,gh*.19f,gw*.43f,p);p.setStyle(Paint.Style.STROKE);p.setStrokeWidth(4);p.setColor(GameUi.BLUE);c.drawCircle(0,gh*.19f,gw*.35f,p);p.setStyle(Paint.Style.FILL); + p.setColor(GameUi.ORANGE);c.drawCircle(0,gh*.19f-gw*.35f,6,p); + c.restore();p.setTextAlign(Paint.Align.CENTER);p.setTypeface(Typeface.create("sans-serif-medium",0));p.setTextSize(GameUi.dp(getContext(),11));p.setColor(GameUi.MUTED); + c.drawText(GameUi.tr("가로 모니터 16:9","横向显示器 16:9"),cx,h-3,p); + setContentDescription(GameUi.tr("게임 화면 회전 미리보기 ","游戏旋转预览 ")+(reverse.getAsBoolean()?"270°":"90°")); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayPreview.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayPreview.java.meta new file mode 100644 index 000000000..f48e2047f --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/DisplayPreview.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: e250379ec6f64459a00e9c20c5139b4a +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/FloatingShortcut.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/FloatingShortcut.java new file mode 100644 index 000000000..c6a1a8939 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/FloatingShortcut.java @@ -0,0 +1,228 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.SharedPreferences; +import android.graphics.Insets; +import android.os.Build; +import android.text.Layout; +import android.view.Gravity; +import android.view.MotionEvent; +import android.view.View; +import android.view.ViewConfiguration; +import android.view.ViewGroup; +import android.view.ViewParent; +import android.view.WindowInsets; +import android.widget.FrameLayout; +import android.widget.TextView; + +/** Draggable positioning for one small Activity child, without a touch-intercepting overlay. */ +final class FloatingShortcut implements View.OnTouchListener, View.OnAttachStateChangeListener { + private static final String X_KEY="displayShortcutX", Y_KEY="displayShortcutY"; + private final TextView view; + private final SharedPreferences prefs; + private final int slop; + private final int[] parentLocation=new int[2], rootLocation=new int[2]; + private ViewGroup parent; + private int insetLeft,insetTop,insetRight,insetBottom; + private int lastWidth,lastHeight; + private float minX,minY,maxX,maxY,contentTop; + private float savedX=1,savedY; + private boolean saved,dragged,cancelled; + private int pointer=-1; + private float downRawX,downRawY,startX,startY,targetX,targetY; + private boolean positioned; + private final Runnable settle=this::place; + private final View.OnLayoutChangeListener layoutListener=(v,l,t,r,b,ol,ot,or,ob)->{ + // Moving our margins changes l/t/r/b; it must not cancel its own drag. + boolean changed=v==parent?(l!=ol||t!=ot||r!=or||b!=ob):(r-l!=or-ol||b-t!=ob-ot); + if(changed) { + if(pointer!=-1)cancelGesture(); + // Reposition after this traversal so a label-width change cannot leave stale margins. + schedulePlace(); + } + }; + + /** Call once after adding the existing button; its OnClickListener remains unchanged. */ + static void attach(TextView view,SharedPreferences prefs) { + new FloatingShortcut(view,prefs); + } + + private FloatingShortcut(TextView view,SharedPreferences prefs) { + this.view=view;this.prefs=prefs; + slop=ViewConfiguration.get(view.getContext()).getScaledTouchSlop(); + try { + saved=prefs.contains(X_KEY)&&prefs.contains(Y_KEY); + if(saved) { + savedX=prefs.getFloat(X_KEY,1);savedY=prefs.getFloat(Y_KEY,0); + saved=Float.isFinite(savedX)&&Float.isFinite(savedY); + savedX=unit(savedX);savedY=unit(savedY); + } + } catch(ClassCastException ignored) { saved=false; } + if(!saved) { savedX=1;savedY=0; } + view.setOnTouchListener(this); + view.addOnAttachStateChangeListener(this); + if(view.isAttachedToWindow())onViewAttachedToWindow(view); + } + + private int dp(float value) { return GameUi.dp(view.getContext(),value); } + private void schedulePlace() { view.removeCallbacks(settle);view.post(settle); } + private static float unit(float value) { return Math.max(0,Math.min(1,value)); } + private static float clamp(float value,float min,float max) { return Math.max(min,Math.min(max,value)); } + + @Override public void onViewAttachedToWindow(View attached) { + if(!(view.getParent() instanceof ViewGroup))return; + if(parent!=null)parent.removeOnLayoutChangeListener(layoutListener); + parent=(ViewGroup)view.getParent(); + parent.addOnLayoutChangeListener(layoutListener); + view.removeOnLayoutChangeListener(layoutListener); + view.addOnLayoutChangeListener(layoutListener); + view.setOnApplyWindowInsetsListener((v,insets)->{ + if(readInsets(insets)) { + if(pointer!=-1)cancelGesture(); + place(); + } + return insets; + }); + readInsets(view.getRootWindowInsets()); + view.requestApplyInsets(); + view.removeCallbacks(settle);view.post(settle); + } + + @Override public void onViewDetachedFromWindow(View detached) { + cancelGesture();endGesture(); + if(parent!=null)parent.removeOnLayoutChangeListener(layoutListener); + view.removeOnLayoutChangeListener(layoutListener); + view.setOnApplyWindowInsetsListener(null); + view.removeCallbacks(settle); + parent=null; + } + + private boolean readInsets(WindowInsets insets) { + int left=insetLeft,top=insetTop,right=insetRight,bottom=insetBottom; + if(insets==null)insetLeft=insetTop=insetRight=insetBottom=0; + else if(Build.VERSION.SDK_INT>=30) { + Insets safe=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout()|WindowInsets.Type.ime()); + insetLeft=safe.left;insetTop=safe.top;insetRight=safe.right;insetBottom=safe.bottom; + } else { + insetLeft=insets.getSystemWindowInsetLeft();insetTop=insets.getSystemWindowInsetTop(); + insetRight=insets.getSystemWindowInsetRight();insetBottom=insets.getSystemWindowInsetBottom(); + if(Build.VERSION.SDK_INT>=28&&insets.getDisplayCutout()!=null) { + insetLeft=Math.max(insetLeft,insets.getDisplayCutout().getSafeInsetLeft()); + insetTop=Math.max(insetTop,insets.getDisplayCutout().getSafeInsetTop()); + insetRight=Math.max(insetRight,insets.getDisplayCutout().getSafeInsetRight()); + insetBottom=Math.max(insetBottom,insets.getDisplayCutout().getSafeInsetBottom()); + } + } + return left!=insetLeft||top!=insetTop||right!=insetRight||bottom!=insetBottom; + } + + /** Convert window insets to parent-local bounds, avoiding double-insetting an inset content view. */ + private boolean bounds() { + if(parent==null||parent.getWidth()<=0||parent.getHeight()<=0)return false; + View root=view.getRootView(); + parent.getLocationInWindow(parentLocation);root.getLocationInWindow(rootLocation); + float rootX=rootLocation[0]-parentLocation[0],rootY=rootLocation[1]-parentLocation[1]; + float left=Math.max(parent.getPaddingLeft(),rootX+insetLeft); + contentTop=Math.max(parent.getPaddingTop(),rootY+insetTop); + float right=Math.min(parent.getWidth()-parent.getPaddingRight(),rootX+root.getWidth()-insetRight); + float bottom=Math.min(parent.getHeight()-parent.getPaddingBottom(),rootY+root.getHeight()-insetBottom); + int gap=dp(12),availableWidth=Math.max(1,Math.round(right-left)-2*gap); + if(view.getMaxWidth()!=availableWidth)view.setMaxWidth(availableWidth); + if(view.getWidth()>0)lastWidth=view.getWidth(); + if(view.getHeight()>0)lastHeight=view.getHeight(); + // After a viewport shrink, old margins can temporarily measure WRAP_CONTENT as zero. + // Keep enough geometry to move back inside the parent so the next layout can recover. + int width=lastWidth>0?lastWidth:Math.max(view.getMinimumWidth(),Math.round( + Layout.getDesiredWidth(view.getText(),view.getPaint()))+view.getCompoundPaddingLeft()+view.getCompoundPaddingRight()); + int height=lastHeight>0?lastHeight:Math.max(view.getMinimumHeight(), + view.getPaint().getFontMetricsInt(null)+view.getCompoundPaddingTop()+view.getCompoundPaddingBottom()); + minX=left+gap;minY=contentTop+gap; + maxX=Math.max(minX,right-gap-Math.max(1,width)); + maxY=Math.max(minY,bottom-gap-Math.max(1,height)); + return true; + } + + private void place() { + if(pointer!=-1&&!cancelled)return; + if(!bounds())return; + float x=saved?minX+savedX*(maxX-minX):maxX; + float y=saved?minY+savedY*(maxY-minY):contentTop+dp(80); + moveTo(clamp(x,minX,maxX),clamp(y,minY,maxY)); + } + + /** Actual layout moves keep SurfaceView transparent regions in sync on software-rendered Unity. */ + private void moveTo(float x,float y) { + if(parent==null||!(view.getLayoutParams() instanceof FrameLayout.LayoutParams))return; + // Layout is asynchronous. Keep the requested position for the next motion and preference save. + targetX=Math.round(x);targetY=Math.round(y);positioned=true; + FrameLayout.LayoutParams params=(FrameLayout.LayoutParams)view.getLayoutParams(); + int left=Math.round(targetX)-parent.getPaddingLeft(),top=Math.round(targetY)-parent.getPaddingTop(); + int gravity=Gravity.TOP|Gravity.LEFT; + if(params.gravity!=gravity||params.leftMargin!=left||params.topMargin!=top + ||params.rightMargin!=0||params.bottomMargin!=0) { + params.gravity=gravity;params.leftMargin=left;params.topMargin=top; + params.rightMargin=0;params.bottomMargin=0; + view.setLayoutParams(params); + } + if(view.getTranslationX()!=0)view.setTranslationX(0); + if(view.getTranslationY()!=0)view.setTranslationY(0); + } + + private static float rawX(MotionEvent event,int index) { + return Build.VERSION.SDK_INT>=29?event.getRawX(index):event.getRawX()+event.getX(index)-event.getX(); + } + private static float rawY(MotionEvent event,int index) { + return Build.VERSION.SDK_INT>=29?event.getRawY(index):event.getRawY()+event.getY(index)-event.getY(); + } + private void allowParent(boolean allow) { + ViewParent target=view.getParent();if(target!=null)target.requestDisallowInterceptTouchEvent(!allow); + } + private void cancelGesture() { + if(pointer!=-1) { + if(bounds())moveTo(clamp(startX,minX,maxX),clamp(startY,minY,maxY)); + cancelled=true; + } + view.setPressed(false);allowParent(true); + } + private void endGesture() { pointer=-1;dragged=false;cancelled=false;view.setPressed(false);allowParent(true); } + + @Override public boolean onTouch(View target,MotionEvent event) { + int action=event.getActionMasked(); + if(action==MotionEvent.ACTION_DOWN) { + if(!view.isEnabled()||!bounds())return false; + if(!positioned)place(); + pointer=event.getPointerId(0);dragged=false;cancelled=false; + downRawX=rawX(event,0);downRawY=rawY(event,0);startX=view.getLeft();startY=view.getTop(); + moveTo(startX,startY); + view.setPressed(true);view.drawableHotspotChanged(event.getX(),event.getY());allowParent(false); + return true; + } + if(pointer==-1)return false; + if(!view.isEnabled()||view.getVisibility()!=View.VISIBLE)cancelGesture(); + if(action==MotionEvent.ACTION_CANCEL) { cancelGesture();endGesture();return true; } + if(action==MotionEvent.ACTION_POINTER_DOWN||action==MotionEvent.ACTION_POINTER_UP) { + // Never turn a two-finger sequence or a switched pointer into a click or saved drag. + cancelGesture();return true; + } + if(cancelled) { if(action==MotionEvent.ACTION_UP)endGesture();return true; } + int index=event.findPointerIndex(pointer); + if(index<0) { cancelGesture();if(action==MotionEvent.ACTION_UP)endGesture();return true; } + float dx=rawX(event,index)-downRawX,dy=rawY(event,index)-downRawY; + if(action==MotionEvent.ACTION_MOVE||action==MotionEvent.ACTION_UP) { + if(!dragged&&Math.hypot(dx,dy)>slop) { dragged=true;view.setPressed(false); } + if(dragged&&bounds()) { + moveTo(clamp(startX+dx,minX,maxX),clamp(startY+dy,minY,maxY)); + } + } + if(action==MotionEvent.ACTION_UP) { + boolean click=!dragged; + if(dragged&&bounds()) { + if(maxX>minX)savedX=unit((targetX-minX)/(maxX-minX)); + if(maxY>minY)savedY=unit((targetY-minY)/(maxY-minY)); + saved=true;prefs.edit().putFloat(X_KEY,savedX).putFloat(Y_KEY,savedY).apply(); + } + endGesture(); + if(click)view.performClick(); + } + return true; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/FloatingShortcut.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/FloatingShortcut.java.meta new file mode 100644 index 000000000..fc366f31f --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/FloatingShortcut.java.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: a48cbb6a1b7448769c349512cfd8e9f9 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameAssets.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameAssets.java new file mode 100644 index 000000000..4cb05d08c --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameAssets.java @@ -0,0 +1,40 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.graphics.*; +import android.graphics.drawable.BitmapDrawable; +import android.util.SparseArray; +import java.io.InputStream; + +/** Original MajdataPlay sprites, losslessly extracted from the source atlas by Tools/Export-OniimaiUI.ps1. */ +final class GameAssets { + private static final SparseArray sprites=new SparseArray<>(); + private static Typeface font,chineseFont; + private static Bitmap mascot; + static Typeface font(Context c) { + if("zh-Hans".equals(UiText.language())){ + if(chineseFont==null)try{chineseFont=Typeface.createFromAsset(c.getAssets(),"OniimaiUI/AlimamaFangYuanTiVF-Thin-2.ttf");} + catch(RuntimeException ex){chineseFont=Typeface.create("sans-serif",Typeface.NORMAL);} + return chineseFont; + } + if(font==null)try{font=Typeface.createFromAsset(c.getAssets(),"OniimaiUI/Jua-Regular.ttf");} + catch(RuntimeException ex){font=Typeface.create("sans-serif",Typeface.NORMAL);} + return font; + } + static Bitmap mascot(Context c) { + if(mascot==null)try(InputStream in=c.getAssets().open("OniimaiUI/xxlb.png")){mascot=BitmapFactory.decodeStream(in);}catch(Exception ignored){} + return mascot; + } + static Bitmap sprite(Context c,int id) { + Bitmap cached=sprites.get(id);if(cached!=null)return cached; + try(InputStream in=c.getAssets().open("OniimaiUI/ui-"+id+".png")) { + BitmapFactory.Options options=new BitmapFactory.Options();options.inSampleSize=2; + cached=BitmapFactory.decodeStream(in,null,options); + if(cached!=null)sprites.put(id,cached);return cached; + }catch(Exception ex){android.util.Log.e("Oniimai","Unable to load UI sprite "+id,ex);return null;} + } + static BitmapDrawable drawable(Context c,int id){return new BitmapDrawable(c.getResources(),sprite(c,id));} + static void draw(Canvas canvas,Context c,int id,float l,float t,float r,float b,Paint paint){ + Bitmap bitmap=sprite(c,id);if(bitmap!=null)canvas.drawBitmap(bitmap,null,new RectF(l,t,r,b),paint); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameAssets.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameAssets.java.meta new file mode 100644 index 000000000..542163a72 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameAssets.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: dbb29b19c3994a75bb776c3f5bf34655 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameStatsView.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameStatsView.java new file mode 100644 index 000000000..746e61ec2 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameStatsView.java @@ -0,0 +1,85 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.graphics.*; +import android.view.*; +import android.widget.*; +import org.json.JSONObject; +import java.util.Locale; + +/** Native, font-scaled companion stats. Empty states never show invented zero scores. */ +final class GameStatsView extends LinearLayout { + private final TextView state,title,subtitle,achievement,combo,time; + private final TextView[] judgments=new TextView[7]; + private final LinearLayout playing; + private final ImageView coverView; + private final Graph graphView; + private JSONObject frame=new JSONObject(); + private String previous=""; + private Bitmap cover,graph; + private boolean stale; + private static final String[] KEYS={"critical","perfect","great","good","miss","fast","late"}; + private static final String[] LABELS={"Critical","Perfect","Great","Good","Miss","Fast","Late"}; + GameStatsView(Context c) { + super(c);setOrientation(VERTICAL);setPadding(dp(20),dp(20),dp(20),dp(20));GameUi.surface(this); + LinearLayout heading=new LinearLayout(c);heading.setGravity(Gravity.CENTER_VERTICAL); + LinearLayout words=GameUi.column(c); + state=GameUi.text(c,"",12,GameUi.BLUE,true);words.addView(state);GameUi.space(words,7); + title=GameUi.text(c,"",21,GameUi.INK,true);title.setMaxLines(2);title.setEllipsize(android.text.TextUtils.TruncateAt.END);words.addView(title); + subtitle=GameUi.text(c,"",13,GameUi.MUTED,false);subtitle.setPadding(0,dp(7),dp(12),0);words.addView(subtitle); + heading.addView(words,new LayoutParams(0,-2,1)); + coverView=new ImageView(c);coverView.setScaleType(ImageView.ScaleType.CENTER_CROP);coverView.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);heading.addView(coverView,new LayoutParams(dp(64),dp(64)));addView(heading); + playing=GameUi.column(c);GameUi.space(playing,18); + achievement=GameUi.text(c,"",34,GameUi.BLUE,true);playing.addView(achievement); + combo=GameUi.text(c,"",13,GameUi.MUTED,false);combo.setPadding(0,dp(4),0,dp(16));playing.addView(combo);GameUi.divider(playing);GameUi.space(playing,14); + for(int row=0;row<2;row++){ + LinearLayout line=new LinearLayout(c);int start=row==0?0:4,end=row==0?4:7; + for(int i=start;i0?frame.optDouble("seconds",0)/length:0)); + p.setColor(GameUi.PINK);p.setStrokeWidth(dp(2));c.drawLine(progress*getWidth(),0,progress*getWidth(),getHeight(),p); + } + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameStatsView.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameStatsView.java.meta new file mode 100644 index 000000000..c865296eb --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameStatsView.java.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 72b37a6f145f46f99d730af585d06aa8 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameUi.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameUi.java new file mode 100644 index 000000000..ffcaf6782 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/GameUi.java @@ -0,0 +1,109 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.content.res.ColorStateList; +import android.graphics.*; +import android.graphics.drawable.*; +import android.text.TextUtils; +import android.view.*; +import android.widget.*; + +/** Shared game-inspired components for the companion screen and in-game settings. */ +final class GameUi { + static final int BLUE=0xff466f97, INK=0xff5c4f49, MUTED=0xff58626c, PAPER=0xffffffff, + LINE=0xffe4e8ea, PINK=0xffdf5799, GREEN=0xff49a994, ORANGE=0xffe99b45, + SLATE=0xff4e5663, GOLD=0xffe1cea3, TAB=0xff5e7380, ERROR=0xffb53d49; + static final int[] JUDGMENT={0xffb45309,0xff8b6900,0xffa93680,0xff427c1b,0xff55545b,0xff286caa,0xffb84650}; + static final int PAGE=0xfff3f5f7, TOUCH_DP=48, RADIUS_DP=24, MOTION_MS=140; + private GameUi() {} + static String tr(String ko,String zh) { return "zh-Hans".equals(UiText.language())?zh:ko; } + static int dp(Context c,float value) { return Math.round(value*c.getResources().getDisplayMetrics().density); } + static void buttonRole(View view){view.setFocusable(true);view.setAccessibilityDelegate(new View.AccessibilityDelegate(){@Override public void onInitializeAccessibilityNodeInfo(View host,android.view.accessibility.AccessibilityNodeInfo info){super.onInitializeAccessibilityNodeInfo(host,info);info.setClassName("android.widget.Button");}});} + static void appear(View view){if(!android.animation.ValueAnimator.areAnimatorsEnabled())return;view.animate().cancel();view.setAlpha(.55f);view.setTranslationY(dp(view.getContext(),4));view.animate().alpha(1).translationY(0).setDuration(MOTION_MS).start();} + static void enabled(View view,boolean enabled){view.setEnabled(enabled);view.setAlpha(enabled?1f:.38f);} + static void surface(View view){view.setBackground(shape(view.getContext(),PAPER,RADIUS_DP));view.setClipToOutline(true);view.setOutlineProvider(new ViewOutlineProvider(){@Override public void getOutline(View v,Outline outline){outline.setRoundRect(0,0,v.getWidth(),v.getHeight(),dp(v.getContext(),RADIUS_DP));}});} + static GradientDrawable shape(Context c,int color,float radius) { + GradientDrawable d=new GradientDrawable(); d.setColor(color); d.setCornerRadius(dp(c,radius)); return d; + } + static Drawable ripple(Context c,int color,float radius) { + return new RippleDrawable(ColorStateList.valueOf(0x22669edb),shape(c,color,radius),shape(c,Color.WHITE,radius)); + } + static LinearLayout column(Context c) { LinearLayout v=new LinearLayout(c); v.setOrientation(1); if(android.os.Build.VERSION.SDK_INT>=29)v.setForceDarkAllowed(false);return v; } + static TextView text(Context c,String value,int size,int color,boolean bold) { + TextView v=new TextView(c); v.setText(value); v.setTextSize(size); v.setTextColor(color); + v.setTypeface(bold?GameAssets.font(c):Typeface.create("sans-serif",Typeface.NORMAL)); + v.setIncludeFontPadding(false); v.setLineSpacing(dp(c,3),1); return v; + } + static void setText(TextView view,String text) { if(view!=null&&!TextUtils.equals(view.getText(),text))view.setText(text); } + static void space(LinearLayout parent,int height) { View v=new View(parent.getContext()); parent.addView(v,new LinearLayout.LayoutParams(1,dp(parent.getContext(),height))); } + static LinearLayout card(LinearLayout parent,String title,String subtitle) { + Context c=parent.getContext(); LinearLayout card=column(c); + card.setBackground(shape(c,Color.WHITE,22)); card.setPadding(dp(c,18),dp(c,16),dp(c,18),dp(c,16)); + LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(-1,-2); lp.bottomMargin=dp(c,14); parent.addView(card,lp); + if(title!=null&&!title.isEmpty()) { + TextView t=text(c,title,18,INK,true); card.addView(t); space(card,subtitle==null?10:5); + } + if(subtitle!=null&&!subtitle.isEmpty()) { card.addView(text(c,subtitle,12,MUTED,false)); space(card,12); } + return card; + } + static TextView action(Context c,String label,boolean primary,Runnable action) { + TextView b=text(c,label,15,primary?GOLD:INK,true); b.setGravity(Gravity.CENTER); + b.setMinHeight(dp(c,48)); b.setPadding(dp(c,14),dp(c,12),dp(c,14),dp(c,12)); + b.setBackground(ripple(c,primary?SLATE:0xffe8ecec,28)); b.setClickable(true); b.setFocusable(true); + buttonRole(b); + b.setOnClickListener(v->action.run()); return b; + } + static TextView row(LinearLayout parent,String title,String value,Runnable action) { + Context c=parent.getContext(); LinearLayout row=new LinearLayout(c); row.setGravity(Gravity.CENTER_VERTICAL); + row.setPadding(dp(c,2),dp(c,12),dp(c,2),dp(c,12)); row.setMinimumHeight(dp(c,60)); + LinearLayout words=column(c); TextView label=text(c,title,15,INK,true); words.addView(label); + TextView detail=text(c,value,12,MUTED,false); detail.setPadding(0,dp(c,5),0,0); words.addView(detail); + row.addView(words,new LinearLayout.LayoutParams(0,-2,1)); + TextView arrow=text(c,"›",25,BLUE,false); arrow.setGravity(Gravity.CENTER); row.addView(arrow,new LinearLayout.LayoutParams(dp(c,28),dp(c,40))); + row.setBackground(ripple(c,Color.WHITE,12)); row.setClickable(true); row.setFocusable(true); + row.setOnClickListener(v->action.run()); + parent.addView(row,new LinearLayout.LayoutParams(-1,-2)); return detail; + } + static void divider(LinearLayout parent) { View v=new View(parent.getContext()); v.setBackgroundColor(LINE); parent.addView(v,new LinearLayout.LayoutParams(-1,dp(parent.getContext(),1))); } + static Switch toggle(LinearLayout parent,String title,String hint,boolean value,android.widget.CompoundButton.OnCheckedChangeListener action) { + Context c=parent.getContext(); LinearLayout row=new LinearLayout(c); row.setGravity(Gravity.CENTER_VERTICAL); row.setPadding(0,dp(c,12),0,dp(c,12)); + LinearLayout words=column(c); words.addView(text(c,title,15,INK,true)); + if(hint!=null&&!hint.isEmpty()){TextView h=text(c,hint,12,MUTED,false); h.setPadding(0,dp(c,5),dp(c,10),0); words.addView(h);} + row.addView(words,new LinearLayout.LayoutParams(0,-2,1)); + Switch s=new Switch(new ContextThemeWrapper(c,android.R.style.Theme_Material_Light_NoActionBar)); s.setShowText(false); + s.setThumbTintList(new ColorStateList(new int[][]{{android.R.attr.state_checked},{}},new int[]{BLUE,0xffa8b5c9})); + s.setTrackTintList(new ColorStateList(new int[][]{{android.R.attr.state_checked},{}},new int[]{0xffc2dcf6,LINE})); + s.setMinHeight(dp(c,48)); s.setContentDescription(title); s.setChecked(value); s.setOnCheckedChangeListener(action); + row.addView(s); row.setOnClickListener(v->s.setChecked(!s.isChecked())); parent.addView(row,new LinearLayout.LayoutParams(-1,-2)); return s; + } + static EditText number(LinearLayout parent,String label,int value,int min,int max) { + Context c=parent.getContext(); TextView t=text(c,label+" · "+min+"–"+max,13,MUTED,true); parent.addView(t); space(parent,7); + EditText input=new EditText(new ContextThemeWrapper(c,android.R.style.Theme_Material_Light_NoActionBar)); input.setSingleLine(); input.setInputType(2); + input.setTextColor(INK); input.setTypeface(GameAssets.font(c)); input.setTextSize(18); input.setText(String.valueOf(value)); input.setSelectAllOnFocus(true); + input.setPadding(dp(c,14),dp(c,12),dp(c,14),dp(c,12)); + StateListDrawable fields=new StateListDrawable();GradientDrawable focus=shape(c,0xfff4f7f9,12);focus.setStroke(dp(c,2),BLUE);GradientDrawable normal=shape(c,0xffedf0f2,12);normal.setStroke(dp(c,1),LINE);fields.addState(new int[]{android.R.attr.state_focused},focus);fields.addState(new int[]{},normal);input.setBackground(fields);input.setContentDescription(label+" · "+min+"–"+max); + input.setId(View.generateViewId());t.setLabelFor(input.getId());input.setImeOptions(android.view.inputmethod.EditorInfo.IME_ACTION_NEXT); + input.setMinHeight(dp(c,52));parent.addView(input,new LinearLayout.LayoutParams(-1,-2)); space(parent,16); return input; + } + static int readNumber(EditText field,int min,int max) { + try { int v=Integer.parseInt(field.getText().toString().trim()); if(vmax)throw new NumberFormatException(); field.setError(null); return v; } + catch(NumberFormatException e){field.setError(tr("입력 범위: ","输入范围:")+min+"–"+max); field.requestFocus(); throw e;} + } + static final class Pattern extends Drawable { + private final Paint p=new Paint(Paint.ANTI_ALIAS_FLAG); + @Override public void draw(Canvas c) { + Rect b=getBounds();c.drawColor(PAPER);float unit=Math.max(50,b.width()/6f);p.setColor(0xfff4f6fa); + for(int y=-1;y bindings=new ArrayList<>(); + private int step; + private boolean closed; + private final Runnable tick=new Runnable(){public void run(){if(closed)return;for(Runnable update:new ArrayList<>(bindings))update.run();owner.main.postDelayed(this,200);}}; + InitialSetup(OniimaiController owner,OniimaiPanel ui){ + this.owner=owner;this.ui=ui;prefs=owner.prefs;step=Math.max(0,Math.min(3,prefs.getInt("setupStep",0))); + LinearLayout root=GameUi.column(owner.activity);root.setBackgroundColor(GameUi.PAGE); + if(android.os.Build.VERSION.SDK_INT>=30)root.setOnApplyWindowInsetsListener((v,insets)->{android.graphics.Insets safe=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());root.setPadding(safe.left,safe.top,safe.right,safe.bottom);return insets;}); + LinearLayout header=GameUi.column(owner.activity);header.setPadding(dp(20),dp(16),dp(20),dp(16)); + LinearLayout nav=new LinearLayout(owner.activity);nav.setGravity(Gravity.CENTER_VERTICAL); + progress=GameUi.text(owner.activity,"",13,GameUi.BLUE,true);nav.addView(progress,new LinearLayout.LayoutParams(0,-2,1));nav.addView(GameUi.action(owner.activity,"한국어 / 中文",false,ui::language));header.addView(nav);GameUi.space(header,10); + title=GameUi.text(owner.activity,"",25,GameUi.INK,true);header.addView(title);root.addView(header); + scroll=new ScrollView(owner.activity);scroll.setFillViewport(false);body=GameUi.column(owner.activity);body.setPadding(dp(20),0,dp(20),dp(12));scroll.addView(body);root.addView(scroll,new LinearLayout.LayoutParams(-1,0,1)); + LinearLayout footer=new LinearLayout(owner.activity);footer.setPadding(dp(20),dp(12),dp(20),dp(16));footer.setBackgroundColor(GameUi.PAPER); + back=GameUi.action(owner.activity,"",false,this::back);footer.addView(back,new LinearLayout.LayoutParams(0,-2,1)); + next=GameUi.action(owner.activity,"",true,this::next);LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(0,-2,1);lp.leftMargin=dp(10);footer.addView(next,lp);root.addView(footer); + dialog=new Dialog(owner.activity,android.R.style.Theme_Material_Light_NoActionBar){@Override public void onBackPressed(){back();}};dialog.setContentView(root); + ui.protect(dialog,()->{closed=true;owner.main.removeCallbacks(tick);ui.setupClosed(this);}); + } + void show(){ui.show(dialog,true);dialog.getWindow().getDecorView().requestApplyInsets();render();owner.main.post(tick);} + private int dp(int n){return GameUi.dp(owner.activity,n);} + private String tr(String ko,String zh){return GameUi.tr(ko,zh);} + private void back(){if(step>0){step--;render();}else dialog.dismiss();} + private void next(){if(step<3){step++;render();}else{prefs.edit().putBoolean("setupComplete",true).putInt("setupStep",0).apply();dialog.dismiss();Toast.makeText(owner.activity,tr("초기 설정을 마쳤습니다","初始设置已完成"),Toast.LENGTH_SHORT).show();}} + private void render(){ + bindings.clear();body.removeAllViews();prefs.edit().putInt("setupStep",step).apply(); + progress.setText(tr("초기 설정 ","初始设置 ")+(step+1)+" / 4"); + title.setText(step==0?"언어 선택 / 选择语言":step==1?tr("컨트롤러 연결","连接控制器"):step==2?tr("모니터 방향 맞추기","调整显示方向"):tr("LED와 부가 설정","LED 与更多设置")); + back.setText(step==0?tr("나중에","稍后"):tr("‹ 이전","‹ 上一步"));next.setText(step==3?tr("설정 완료","完成设置"):tr("다음 ›","下一步 ›")); + if(step==0)language();else if(step==1)connection();else if(step==2)monitor();else extras();scroll.post(()->scroll.scrollTo(0,0));GameUi.appear(body); + } + private void language(){ + LinearLayout c=card("한국어 · 简体中文",tr("설정과 폰 스탯창에서 사용할 언어를 선택하세요.","请选择设置与手机状态界面使用的语言。")); + c.addView(GameUi.action(owner.activity,"한국어","ko".equals(UiText.language()),()->language("ko")));GameUi.space(c,12); + c.addView(GameUi.action(owner.activity,"简体中文","zh-Hans".equals(UiText.language()),()->language("zh-Hans"))); + ui.text(body,tr("언어를 고른 후 다음을 누르면 컨트롤러 연결을 확인합니다.","选择语言后,点击下一步检查控制器连接。")); + } + private void language(String value){UiText.language(value);prefs.edit().putString("language",value).apply();owner.display.refreshLanguage();render();} + private LinearLayout card(String title,String hint){return GameUi.card(body,title,hint);} + private void live(LinearLayout parent,Supplier value){TextView label=GameUi.text(owner.activity,value.get(),14,GameUi.MUTED,false);parent.addView(label);bindings.add(()->GameUi.setText(label,value.get()));} + private void row(LinearLayout parent,String name,Supplier value,Runnable action){TextView label=GameUi.row(parent,name,value.get(),action);bindings.add(()->GameUi.setText(label,value.get()));} + private void flag(LinearLayout parent,String name,String hint,String key,boolean fallback,OniimaiPanel.Toggle action){ + GameUi.toggle(parent,name,hint,prefs.getBoolean(key,fallback),(v,on)->{prefs.edit().putBoolean(key,on).apply();action.accept(on);}); + } + private void connection(){ + LinearLayout c=card(tr("연결 상태","连接状态"),tr("Touch · IO4 HID · LED 포트를 이름으로 찾습니다.","按名称查找 Touch、IO4 HID 与 LED 端口。"));live(c,owner::connectionDescription);GameUi.space(c,12); + TextView connect=GameUi.action(owner.activity,tr("포트 자동 선택 · 다시 연결","自动选择端口并重连"),true,()->{if(!owner.uiBusy()){owner.enableInput(true);owner.automaticPorts();}});c.addView(connect);bindings.add(()->GameUi.enabled(connect,!owner.uiBusy())); + row(c,tr("USB 권한","USB 权限"),()->tr("장치 확인 · 연결 허용","查看设备并允许连接"),ui::permissions); + c=card(tr("입력과 포트","输入与端口"),tr("감도 설정용 Command 포트는 터치 입력과 별도로 사용합니다.","灵敏度设置单独使用 Command 端口。")); + row(c,tr("터치 포트","触摸端口"),()->ui.portName("touchPort"),()->ui.ports("touchPort",false)); + live(c,()->tr("터치 프로토콜: ","触摸协议:")+(prefs.getInt("touchMode",1)==1?"Mai2 Touch · 9600":"Command · 115200")); + row(c,tr("LED 포트","LED 端口"),()->ui.portName("ledPort"),()->ui.ports("ledPort",false)); + String[] modes={tr("사용 안 함","关闭"),"USB Keyboard","IO4 HID"}; + row(c,tr("버튼 입력 방식","按钮输入方式"),()->modes[Math.max(0,Math.min(2,prefs.getInt("buttonMode",2)))],()->ui.choose(tr("버튼 입력 방식","按钮输入方式"),modes,prefs.getInt("buttonMode",2),i->{prefs.edit().putInt("buttonMode",i).apply();owner.configurationChanged();render();})); + if(prefs.getInt("buttonMode",2)==2)row(c,"IO4 HID",()->ui.portName("hidPort"),()->ui.ports("hidPort",true)); + flag(c,tr("컨트롤러 입력 사용","启用控制器输入"),null,"input",false,owner::enableInput); + ui.text(body,tr("설정창에서는 컨트롤러 입력이 게임에 전달되지 않습니다. 다음 단계에서 모니터 방향을 맞춰주세요.","设置期间不会向游戏传递控制器输入。下一步调整显示器方向。")); + } + private void monitor(){ + LinearLayout c=card(tr("외부 화면","外接屏幕"),tr("모니터는 가로 16:9, 게임은 회전된 세로 9:16으로 표시합니다.","显示器横向 16:9,游戏旋转后以竖向 9:16 显示。")); + flag(c,tr("외부 모니터로 게임 출력","在外接显示器上显示游戏"),null,"external",true,on->owner.display.reconcile()); + row(c,tr("출력 장치","输出设备"),owner.display::deviceName,()->owner.display.chooseMonitor(ui)); + if(!owner.display.hasMonitor())ui.text(c,tr("USB-C/HDMI 모니터를 연결하면 여기서 확인할 수 있습니다. 모니터 없이도 다음 단계로 진행할 수 있습니다.","连接 USB-C/HDMI 显示器后可在此查看。也可以稍后再连接。")); + c=card(tr("게임 방향","游戏方向"),tr("실제 모니터를 보면서 올바른 방향을 선택하세요.","查看实际显示器并选择正确方向。")); + c.addView(new DisplayPreview(owner.activity,()->prefs.getBoolean("displayReverse",false)),new LinearLayout.LayoutParams(-1,-2)); + boolean reverse=prefs.getBoolean("displayReverse",false);LinearLayout choices=new LinearLayout(owner.activity); + choices.addView(GameUi.action(owner.activity,tr("90° · 기본","90° · 默认"),!reverse,()->direction(false)),new LinearLayout.LayoutParams(0,-2,1));LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(0,-2,1);lp.leftMargin=dp(8);choices.addView(GameUi.action(owner.activity,tr("270° · 반대","270° · 反向"),reverse,()->direction(true)),lp);c.addView(choices);GameUi.space(c,12);live(c,owner.display::outputDescription); + } + private void direction(boolean reverse){prefs.edit().putBoolean("displayReverse",reverse).apply();owner.display.reconcile();render();} + private void extras(){ + LinearLayout c=card(tr("조명","灯光"),tr("LED 연동은 처음 설치할 때 기본으로 켜집니다.","首次安装默认启用 LED 联动。")); + flag(c,tr("LED 연동","LED 联动"),null,"led",true,on->{if(on)owner.startLeds();else owner.leds.stop();});live(c,owner.leds::brief); + ui.slider(c,tr("밝기","亮度"),"brightness",100,0,100,owner::updateLedSettings); + flag(c,tr("캐비닛 조명","机柜灯光"),tr("컨트롤러의 보조 조명 출력을 사용합니다","使用控制器的辅助灯光输出"),"ring",false,on->owner.updateLedSettings()); + flag(c,tr("LED 순서 반전","反转 LED 顺序"),null,"ledReverse",false,on->owner.updateLedSettings()); + c=card(tr("다음 실행과 P1","下次启动与 P1"),null); + flag(c,tr("다음에도 자동 연결","下次自动连接"),null,"autoConnect",true,on->owner.refresh()); + String[] actions={tr("정렬 · 검색","排序 · 搜索"),tr("시작 · 확인","开始 · 确认")}; + row(c,tr("P1 버튼 동작","P1 按钮功能"),()->actions[prefs.getBoolean("p1Start",false)?1:0],()->ui.choose("P1 / START",actions,prefs.getBoolean("p1Start",false)?1:0,i->prefs.edit().putBoolean("p1Start",i==1).apply())); + ui.text(body,tr("설정 완료를 누르면 다음 실행부터 이 창은 자동으로 뜨지 않습니다. 설정 → 연결 → 초기 설정에서 다시 열 수 있습니다.","完成后下次启动不会自动弹出。可在设置 → 连接 → 初始设置中重新打开。")); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InitialSetup.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InitialSetup.java.meta new file mode 100644 index 000000000..441a35dd6 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InitialSetup.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: 665257dc60564bf38f07b66f21702a55 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InputSnapshot.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InputSnapshot.java new file mode 100644 index 000000000..6cfa8779b --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InputSnapshot.java @@ -0,0 +1,73 @@ +package net.majdata.majdataplay.oniimai; + +/** USB/UI producers and Unity consumer share one atomic snapshot. No Android dependencies. */ +final class InputSnapshot { + static final long TOUCH_MASK = (1L << 34) - 1; + private int keyboard, hid, pressedButtons, pressedHid; + private long touch, pressedTouch, touchAt = -1, hidAt = -1; + private boolean enabled, foreground = true, panel, awaitNeutral = true, keyboardConnected; + private boolean waitingTouch, waitingHid; + + synchronized void enabled(boolean value) { enabled = value; resetGate(); } + synchronized void foreground(boolean value) { foreground = value; resetGate(); } + synchronized void panel(boolean value) { panel = value; resetGate(); } + synchronized void awaitStreams(boolean touch, boolean hid) { waitingTouch = touch; waitingHid = hid; resetGate(); } + private void resetGate() { pressedButtons = pressedHid = 0; pressedTouch = 0; awaitNeutral = true; } + private boolean active() { return enabled && foreground && !panel; } + private void changed(int oldButtons, long oldTouch, boolean fromHid) { + int buttons = keyboard | hid; + if (!active()) { resetGate(); return; } + if (awaitNeutral) { + if (!waitingTouch && !waitingHid && buttons == 0 && touch == 0) awaitNeutral = false; + return; + } + if (fromHid) pressedHid |= buttons & ~oldButtons; + else pressedButtons |= buttons & ~oldButtons; + long risingTouch = touch & ~oldTouch; + // The game has one center zone: moving a finger from C1 to C2 is not another click. + if ((oldTouch & (3L << 16)) != 0) risingTouch &= ~(3L << 16); + pressedTouch |= risingTouch; + } + synchronized void keyboard(int mask, boolean connected) { + int before = keyboard | hid; + keyboard = mask & 511; keyboardConnected = connected; + changed(before, touch, false); + } + synchronized void hid(int mask, long now) { + int before = keyboard | hid; + hid = mask & 511; hidAt = now; waitingHid = false; changed(before, touch, true); + } + synchronized void touch(long mask, long now) { + long before = touch; + touch = mask & TOUCH_MASK; touchAt = now; waitingTouch = false; changed(keyboard | hid, before, false); + } + synchronized void disconnect() { + touch = 0; hid = 0; keyboard = 0; touchAt = hidAt = -1; waitingTouch = waitingHid = false; resetGate(); + } + private void expire(long now) { + if (touchAt >= 0 && now - touchAt > 500) { + if (touch != 0) { resetGate(); waitingTouch = true; } + touch = 0; pressedTouch = 0; touchAt = -1; + } + if (hidAt >= 0 && now - hidAt > 500) { + if (hid != 0) { resetGate(); waitingHid = true; } + hid = 0; pressedHid = 0; hidAt = -1; + } + } + /** held-or-pulse buttons, held-or-pulse raw touch, button edges, touch edges, connection flags. */ + synchronized long[] read(long now) { + expire(now); + if (awaitNeutral && active() && !waitingTouch && !waitingHid && (keyboard | hid) == 0 && touch == 0) awaitNeutral = false; + boolean send = active() && !awaitNeutral; + long status = (touchAt >= 0 ? 1 : 0) | (hidAt >= 0 || keyboardConnected ? 2 : 0); + long[] result = {send ? (keyboard | hid | pressedButtons | pressedHid) : 0, + send ? touch | pressedTouch : 0, send ? pressedButtons | pressedHid : 0, send ? pressedTouch : 0, status}; + pressedButtons = pressedHid = 0; pressedTouch = 0; + return result; + } + synchronized long[] diagnostic(long now) { + expire(now); + return new long[]{keyboard | hid, touch, awaitNeutral ? 1 : 0, + (touchAt >= 0 ? 1 : 0) | (hidAt >= 0 || keyboardConnected ? 2 : 0)}; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InputSnapshot.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InputSnapshot.java.meta new file mode 100644 index 000000000..a5fe19e50 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InputSnapshot.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 329c3220ae944df8b55f28d33bcbcef8 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Io4Output.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Io4Output.java new file mode 100644 index 000000000..455dd8e18 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Io4Output.java @@ -0,0 +1,16 @@ +package net.majdata.majdataplay.oniimai; + +/** IO4 HID output report, following whowechina/mai_pico firmware/src/hid.c. */ +public final class Io4Output { + public static final int REPORT_ID=0x10, REPORT_BYTES=64; + private Io4Output(){} + /** Both player channels receive the same colour; the controller selects its player. */ + public static byte[] ceiling(int rgb){ + byte[] report=new byte[REPORT_BYTES]; + report[0]=REPORT_ID;report[1]=0x41;report[2]=(byte)0xfc; + report[4]=report[5]=(byte)(rgb>>16); + report[6]=report[7]=(byte)(rgb>>8); + report[8]=report[9]=(byte)rgb; + return report; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Io4Output.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Io4Output.java.meta new file mode 100644 index 000000000..b73f138c9 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Io4Output.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: a4a9118048d94a3fbb29f9e479a2acca +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/KeyboardState.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/KeyboardState.java new file mode 100644 index 000000000..0c9064125 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/KeyboardState.java @@ -0,0 +1,19 @@ +package net.majdata.majdataplay.oniimai; +import java.util.HashSet; +import java.util.Set; + +final class KeyboardState { + final int[] map={51,33,32,31,52,54,29,45,155}; // ring A1-A8, P1 (numpad multiply) + private final Set held=new HashSet<>(); + boolean event(int device,int code,boolean down,int repeat){ + long key=((long)device<<32)|(code&0xffffffffL); + return down ? repeat==0&&held.add(key) : held.remove(key); + } + int mask(){ + int result=0; + for(long key:held)for(int i=0;i=0&&map[i]==(int)key)result|=1< pending; + private volatile int expected=-1; + private volatile boolean closed; + LedChannel(UsbIo.Cdc port,int address){this(port,address,700);} + LedChannel(UsbIo.Cdc port,int address,int timeout){ + this.port=port;this.address=address;this.timeout=timeout; + Protocol.LedParser parser=new Protocol.LedParser((source,cmd,status,report,data)->{ + CompletableFuture p=pending; + if(p!=null&&source==address&&cmd==expected){ + if(status==1&&report==1)p.complete(data); + else p.completeExceptionally(new IOException(UiText.t("LED 응답 오류 ")+status+" / "+report)); + } + }); + port.start(parser::feed,error->{close();}); + } + synchronized void enableReplies() throws IOException {port.write(Protocol.led(address,0x7d,new byte[0]));} + synchronized byte[] request(int command,byte[] payload) throws IOException { + if(closed)throw new IOException(UiText.t("LED 포트가 닫혔습니다. 다시 연결하세요.")); + CompletableFuture p=new CompletableFuture<>();expected=command;pending=p; + try{ + port.write(Protocol.led(address,command,payload));return p.get(timeout,TimeUnit.MILLISECONDS); + }catch(TimeoutException e){close();throw new IOException(UiText.t("LED 응답 없음 — 포트·노드 주소를 확인하세요."));} + catch(InterruptedException e){Thread.currentThread().interrupt();close();throw new IOException(UiText.t("LED 요청 취소"),e);} + catch(ExecutionException e){close();throw new IOException(e.getCause().getMessage(),e.getCause());} + catch(IOException e){close();throw e;} + finally{pending=null;expected=-1;} + } + byte[] request(int command) throws IOException {return request(command,new byte[0]);} + boolean isClosed(){return closed;} + public void close(){ + if(closed)return;closed=true; + CompletableFuture p=pending;if(p!=null)p.completeExceptionally(new IOException(UiText.t("LED 연결 종료")));port.close(); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedChannel.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedChannel.java.meta new file mode 100644 index 000000000..f5b829059 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedChannel.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 4757d868ed824917a7a3108392a31554 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedFrames.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedFrames.java new file mode 100644 index 000000000..247d007a6 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedFrames.java @@ -0,0 +1,27 @@ +package net.majdata.majdataplay.oniimai; + +/** Physical output conversion, independent of USB and Android. RGB packed as 0xRRGGBB. */ +final class LedFrames { + // MajdataPlay has no separate RGB ceiling channel. Mix its eight game + // button colours; cabinet brightness remains the independent white/FET output. + static int ceiling(int[] colors) { + if (colors == null || colors.length != 9) throw new IllegalArgumentException("9 LED colors required"); + int r = 0, g = 0, b = 0; + for (int i = 0; i < 8; i++) { r += colors[i] >>> 16 & 255; g += colors[i] >>> 8 & 255; b += colors[i] & 255; } + return ((r + 4) / 8 << 16) | ((g + 4) / 8 << 8) | (b + 4) / 8; + } + static int scale(int rgb,int percent){ + percent=Math.max(0,Math.min(100,percent)); + return (((rgb>>>16&255)*percent+50)/100<<16)|(((rgb>>>8&255)*percent+50)/100<<8)|((rgb&255)*percent+50)/100; + } + static int[] physical(int[] colors,int brightness,int rotation,boolean reverse){ + if(colors==null||colors.length!=9)throw new IllegalArgumentException("9 LED colors required"); + int[] result=new int[9];rotation=Math.floorMod(rotation,8); + for(int i=0;i<8;i++)result[Math.floorMod(rotation+(reverse?-i:i),8)]=scale(colors[i],brightness); + int ring=scale(colors[8],brightness);result[8]=Math.max(ring>>>16&255,Math.max(ring>>>8&255,ring&255));return result; + } + static byte[] color(int index,int rgb){ + if(index<0||index>31)throw new IllegalArgumentException("LED index 0..31"); + return new byte[]{(byte)index,(byte)(rgb>>16),(byte)(rgb>>8),(byte)rgb}; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedFrames.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedFrames.java.meta new file mode 100644 index 000000000..e2ed41d9b --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedFrames.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 3a3579b26fe544fe9e6e3d1483c77a17 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedOutput.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedOutput.java new file mode 100644 index 000000000..69ce25263 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedOutput.java @@ -0,0 +1,127 @@ +package net.majdata.majdataplay.oniimai; + +import android.hardware.usb.UsbManager; +import android.os.SystemClock; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Arrays; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** USB LED traffic never runs on the Unity, UI, or sensor-reader threads. */ +final class LedOutput { + private final UsbManager usb; + private volatile int[] latestFrame = new int[9]; + private volatile long frameAt; + private final ScheduledExecutorService io=Executors.newSingleThreadScheduledExecutor(); + private final AtomicInteger generation=new AtomicInteger(); + private volatile boolean running,destroyed,foreground=true; + private volatile java.util.function.Supplier message=()->UiText.t("LED 연결 대기"); + private volatile long sent,events; + private volatile int seen,nativeStatus; + private volatile int brightness=100,rotation,testColor=-1; + private volatile long testUntil; + private volatile boolean reverse,ring; + // Owned exclusively by the LED worker. + private LedChannel channel; + private ScheduledFuture pump,retry; + private UsbIo.Port selectedPort; + private int selectedAddress,failures; + private int base; + private int[] last; + private boolean lastRing,blank; + private long lastPing; + LedOutput(UsbManager usb){this.usb=usb;} + void submit(int[] frame){ + if(frame==null||frame.length!=9)return; + latestFrame=frame.clone();frameAt=SystemClock.uptimeMillis(); + } + boolean running(){return running;} + String brief(){return message.get();} + String summary(){return message.get()+UiText.t("\n게임 LED ")+(nativeStatus==255?UiText.t("준비 8/8"):UiText.t("상태 ")+nativeStatus)+UiText.t(" · 버튼 신호 ")+Integer.bitCount(seen&255)+UiText.t("/8 · 송신 ")+sent;} + String diagnostic(){return summary()+" / Events: "+events+" / Ring seen: "+((seen&256)!=0);} + void settings(int brightness,int rotation,boolean reverse,boolean ring){ + this.brightness=Math.max(0,Math.min(100,brightness));this.rotation=rotation;this.reverse=reverse;this.ring=ring; + } + void foreground(boolean value){foreground=value;if(!value)testColor=-1;} + void test(int rgb){if(running){testColor=rgb&0xffffff;testUntil=SystemClock.uptimeMillis()+2000;}} + void start(UsbIo.Port port,int address,int base){ + if(destroyed||running)return; + if(address<1||address>255||base<0||base>24)throw new IllegalArgumentException(UiText.t("LED 범위 오류")); + int gen=generation.incrementAndGet();running=true;sent=0;message=()->UiText.t("LED 보드 확인 중…"); + io.execute(()->{ + closeCurrent();if(gen!=generation.get()||destroyed)return; + selectedPort=port;selectedAddress=address;this.base=base;failures=0; + open(gen); + }); + } + private void open(int gen){ + if(gen!=generation.get()||destroyed)return; + // Keep the enabled session alive without opening USB handles in the background. + if(!foreground){retry=io.schedule(()->open(gen),1000,TimeUnit.MILLISECONDS);return;} + try{ + channel=new LedChannel(new UsbIo.Cdc(usb,selectedPort,115200),selectedAddress); + channel.enableReplies();byte[] info=channel.request(0xf0); + if(info.length<8||!new String(info,0,8,StandardCharsets.US_ASCII).equals("15070-04"))throw new IOException(UiText.t("지원하는 LED 보드 응답이 아닙니다.")); + if(gen!=generation.get()||destroyed){closeCurrent();return;} + last=null;lastRing=false;blank=false;lastPing=0; + pump=io.scheduleWithFixedDelay(()->poll(gen),0,33,TimeUnit.MILLISECONDS); + }catch(Exception e){fail(gen,e);} + } + private void poll(int gen){ + if(gen!=generation.get()||destroyed)return; + try{ + if(!foreground){if(!blank){blackout();blank=true;}message=()->UiText.t("앱 백그라운드 · LED 꺼짐");return;} + if(blank){last=null;blank=false;} + long now=SystemClock.uptimeMillis();int[] colors=new int[9]; + if(now-frameAt<1000){colors=latestFrame.clone();nativeStatus=255;seen=511;events++;} + else {nativeStatus=0;seen=0;} + boolean test=testColor>=0&&now=2000){channel.request(0xf0);lastPing=now;} + failures=0; + message=()->test?UiText.t("색상 테스트 · 2초 후 게임 연동"):UiText.t("LED 연동 중"); + if(!test&&(nativeStatus!=255||(seen&255)==0))message=()->UiText.t("연결됨 · 게임 LED 신호 대기 (게임 조명 명령 대기)"); + }catch(Exception e){fail(gen,e);} + } + private void send(int[] frame,boolean useRing)throws IOException{ + boolean changed=false; + for(int i=0;i<8;i++)if(last==null||last[i]!=frame[i]){channel.request(0x31,LedFrames.color(base+i,frame[i]));changed=true;} + if(changed){channel.request(0x3c);sent++;} + if(useRing&&(last==null||!lastRing||last[8]!=frame[8]))channel.request(0x39,new byte[]{(byte)frame[8],(byte)frame[8],(byte)frame[8]}); + else if(!useRing&&lastRing)channel.request(0x39,new byte[3]); + last=frame;lastRing=useRing; + } + private void blackout()throws IOException{ + if(channel==null||channel.isClosed())return; + for(int i=0;i<8;i++)channel.request(0x31,LedFrames.color(base+i,0)); + channel.request(0x3c);if(lastRing)channel.request(0x39,new byte[3]);last=null;lastRing=false; + } + private void closeCurrent(){ + closeCurrent(true); + } + private void closeCurrent(boolean clear){ + if(pump!=null){pump.cancel(false);pump=null;} + if(retry!=null){retry.cancel(false);retry=null;} + if(clear)try{blackout();}catch(Exception ignored){} + if(channel!=null){channel.close();channel=null;} + } + private void fail(int gen,Exception error){ + closeCurrent(false); + if(gen!=generation.get()||destroyed)return; + int seconds=1<UiText.t("LED 재연결 대기: ")+seconds+UiText.t("초 · ")+error.getMessage(); + System.err.println("Oniimai LED retry in "+seconds+"s: "+error.getMessage()); + // running remains true while enabled, preventing UI refresh from starting a second session. + retry=io.schedule(()->open(gen),seconds,TimeUnit.SECONDS); + } + void stop(){ + if(destroyed)return;generation.incrementAndGet();running=false;testColor=-1;message=()->UiText.t("LED 연동 OFF");io.execute(this::closeCurrent); + } + void destroy(){ + if(destroyed)return;destroyed=true;generation.incrementAndGet();running=false;io.execute(this::closeCurrent);io.shutdown(); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedOutput.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedOutput.java.meta new file mode 100644 index 000000000..81528064f --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedOutput.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: f689f05922a947bfbe0ebd2526238d4e +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiController.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiController.java new file mode 100644 index 000000000..206b77d37 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiController.java @@ -0,0 +1,461 @@ +package net.majdata.majdataplay.oniimai; + +import android.app.Activity; +import android.app.PendingIntent; +import android.content.*; +import android.hardware.input.InputManager; +import android.hardware.usb.*; +import android.os.*; +import android.view.InputDevice; +import android.view.KeyEvent; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +/** Activity-owned USB host; Unity only reads snapshots and submits RGB frames. No root or hooks. */ +public final class OniimaiController implements InputManager.InputDeviceListener { + private static volatile OniimaiController instance; + final Activity activity; + final SharedPreferences prefs; + final Handler main = new Handler(Looper.getMainLooper()); + final InputSnapshot state = new InputSnapshot(); + final KeyboardState keyboard = new KeyboardState(); + final LedOutput leds; + final CeilingOutput ceiling; + final OniimaiPanel panel; + final OniimaiDisplay display; + final UsbManager usb; + private final ExecutorService io = Executors.newSingleThreadExecutor(); + private final AtomicInteger generation = new AtomicInteger(); + private final InputManager inputManager; + private final String permissionAction; + volatile List ports = Collections.emptyList(); + volatile String status = "USB 연결 대기"; + volatile int learning = -1; + private volatile boolean destroyed, connecting, connected; + private boolean manuallyDisconnected, resumed = true, permissionPending; + private float uiFontScale; + private int uiDensity; + private final Set permissionAsked = new HashSet<>(); + private volatile CommandChannel command,configCommand; + private boolean setupDeferred; + private UsbIo.Cdc serial; + private volatile UsbIo.Hid hid; + private final BroadcastReceiver receiver = new BroadcastReceiver() { + @Override public void onReceive(Context context, Intent intent) { + String action = intent.getAction(); + if (UsbManager.ACTION_USB_DEVICE_DETACHED.equals(action)) { + // Release first. The neutral-input gate also applies to automatic reconnects. + boolean manual = manuallyDisconnected; + disconnect(); manuallyDisconnected = manual; permissionAsked.clear(); permissionPending = false; refresh(); + } else if (permissionAction.equals(action)) { + UsbDevice device = intent.getParcelableExtra(UsbManager.EXTRA_DEVICE); + permissionPending = false; + status = device != null && usb.hasPermission(device) ? "USB 권한 허용됨 · 저장된 포트 연결 중" : "USB 권한이 없습니다"; + refresh(); + } else if (UsbManager.ACTION_USB_DEVICE_ATTACHED.equals(action)) refresh(); + } + }; + + private OniimaiController(Activity activity) { + this.activity = activity; + uiFontScale=activity.getResources().getConfiguration().fontScale;uiDensity=activity.getResources().getConfiguration().densityDpi; + prefs = activity.getSharedPreferences("oniimai", Context.MODE_PRIVATE); + SharedPreferences.Editor defaults=prefs.edit(); + for(Map.Entry e:SetupDefaults.missing(prefs.getAll()).entrySet()){ + if(e.getValue() instanceof Boolean)defaults.putBoolean(e.getKey(),(Boolean)e.getValue()); + else defaults.putInt(e.getKey(),(Integer)e.getValue()); + } + defaults.apply(); + UiText.language(prefs.getString("language", "ko")); + usb = (UsbManager) activity.getSystemService(Context.USB_SERVICE); + inputManager = (InputManager) activity.getSystemService(Context.INPUT_SERVICE); + permissionAction = activity.getPackageName() + ".ONIIMAI_USB_PERMISSION"; + synchronized (keyboard) { + for (int i = 0; i < keyboard.map.length; i++) keyboard.map[i] = prefs.getInt("key" + i, keyboard.map[i]); + } + state.enabled(prefs.getBoolean("input", false)); + leds = new LedOutput(usb); + ceiling = new CeilingOutput(() -> connected ? hid : null); + updateLedSettings(); + panel = new OniimaiPanel(this); + display = new OniimaiDisplay(this); + IntentFilter filter = new IntentFilter(permissionAction); + filter.addAction(UsbManager.ACTION_USB_DEVICE_ATTACHED); + filter.addAction(UsbManager.ACTION_USB_DEVICE_DETACHED); + if (Build.VERSION.SDK_INT >= 33) activity.registerReceiver(receiver, filter, Context.RECEIVER_NOT_EXPORTED); + else activity.registerReceiver(receiver, filter); + inputManager.registerInputDeviceListener(this, main); + updateKeyboardConnection(); refresh(); + } + public static void attach(Activity activity) { detach(); instance = new OniimaiController(activity); } + public static void textConfigurationChanged(){ + OniimaiController c=instance;if(c==null||c.destroyed)return; + android.content.res.Configuration config=c.activity.getResources().getConfiguration(); + if(c.uiFontScale==config.fontScale&&c.uiDensity==config.densityDpi)return; + c.uiFontScale=config.fontScale;c.uiDensity=config.densityDpi; + c.main.post(()->{if(!c.destroyed){c.display.refreshLanguage();c.panel.textConfigurationChanged();}}); + } + public static void detach() { + OniimaiController c = instance; instance = null; + if (c == null) return; + c.destroyed = true; c.generation.incrementAndGet(); c.state.disconnect(); + c.activity.unregisterReceiver(c.receiver); c.inputManager.unregisterInputDeviceListener(c); + c.main.removeCallbacksAndMessages(null); c.panel.destroy(); c.display.destroy(); c.leds.destroy(); c.ceiling.destroy(); + c.io.execute(c::closeInputs); c.io.shutdown(); + } + public static long[] poll() { + OniimaiController c = instance; + if (c == null) return new long[5]; + long[] frame = c.state.read(SystemClock.uptimeMillis()); + if (c.display.active()) frame[4] |= 4; + if (c.prefs.getBoolean("p1Start", false)) frame[4] |= 8; + if (OutputMode.frameRate(c.prefs.getInt("frameRate",60)) == 120) frame[4] |= 16; + return frame; + } + public static void stats(String text) { OniimaiController c = instance; if (c != null) c.display.stats(text); } + public static void artwork(int kind, byte[] png) { + OniimaiController c=instance; + if(c!=null) c.main.post(() -> { if(!c.destroyed) c.display.artwork(kind,png); }); + } + public static void lights(int[] rgb) { OniimaiController c = instance; if (c != null) { c.leds.submit(rgb); c.ceiling.submit(rgb); } } + public static void settingsVisible(boolean value) { + OniimaiController c = instance; + if (c != null) c.main.post(() -> { if (!c.destroyed) c.panel.entry(value); }); + } + public static void resume() { + OniimaiController c = instance; + if (c != null) { c.resumed = true; c.state.foreground(true); c.updateKeyboardConnection(); c.leds.foreground(true); c.ceiling.foreground(true); c.display.resume(true); c.refresh(); c.main.post(c::maybeInitialSetup); } + } + public static void pause() { + OniimaiController c = instance; + if (c != null) { c.resumed = false; c.clearKeys(); c.state.foreground(false); c.leds.foreground(false); c.ceiling.foreground(false); c.display.resume(false); } + } + public static void focus(boolean value) { + OniimaiController c = instance; + if (c != null) { if (!value) c.clearKeys(); c.state.foreground(value);if(value)c.main.post(c::maybeInitialSetup); } + } + public static boolean dashboardBack() { OniimaiController c=instance; return c!=null && c.display!=null && c.display.dashboardBack(); } + public static boolean handleKey(KeyEvent event) { + OniimaiController c = instance; + return c != null && c.key(event); + } + boolean key(KeyEvent event) { + InputDevice device = event.getDevice(); + int code = event.getKeyCode(); + if (device == null || device.isVirtual() || event.getDeviceId() <= 0 + || (event.getFlags() & KeyEvent.FLAG_SOFT_KEYBOARD) != 0) return false; + if (code == KeyEvent.KEYCODE_POWER || code == KeyEvent.KEYCODE_VOLUME_UP + || code == KeyEvent.KEYCODE_VOLUME_DOWN || code == KeyEvent.KEYCODE_VOLUME_MUTE) return false; + boolean external = Build.VERSION.SDK_INT >= 29 ? device.isExternal() + : device.getVendorId() != 0 || device.getProductId() != 0; + if (!external) return false; + if (learning >= 0) { + if (event.getAction() == KeyEvent.ACTION_DOWN && event.getRepeatCount() == 0) { + synchronized (keyboard) { + keyboard.learn(learning, code); keyboard.clear(); + SharedPreferences.Editor e = prefs.edit().putString("keyboardDevice", device.getDescriptor()).putInt("buttonMode", 1); + for (int i = 0; i < keyboard.map.length; i++) e.putInt("key" + i, keyboard.map[i]); + e.apply(); status = (learning == 8 ? "P1 / START" : "A" + (learning + 1)) + " = " + KeyEvent.keyCodeToString(code); + learning = -1; state.keyboard(0, true); + } + } + return true; + } + boolean selected = prefs.getInt("buttonMode", 2) == 1 + && device.getDescriptor().equals(prefs.getString("keyboardDevice", "")); + // While our settings are open, external keyboards cannot drive Android's menu focus. + if (!selected) return panel.isOpen(); + synchronized (keyboard) { + if (event.getAction() == KeyEvent.ACTION_DOWN || event.getAction() == KeyEvent.ACTION_UP) { + keyboard.event(event.getDeviceId(), code, event.getAction() == KeyEvent.ACTION_DOWN, event.getRepeatCount()); + state.keyboard(keyboard.mask(), true); + } + } + return true; + } + void clearKeys() { synchronized (keyboard) { keyboard.clear(); state.keyboard(0, false); } } + void enableInput(boolean value) { prefs.edit().putBoolean("input", value).apply(); state.enabled(value); if (value) { manuallyDisconnected = false; refresh(); } } + String connectionDescription() { + if (permissionPending) return GameUi.tr("Android의 USB 권한 창에서 허용해 주세요.","请在 Android 的 USB 授权窗口中允许访问。"); + if (connecting) return GameUi.tr("컨트롤러 연결 중…","正在连接控制器…"); + if (status.contains("실패") || status.contains("오류") || status.startsWith("LED:") || status.contains("끊김")) return UiText.message(status); + if (!prefs.getBoolean("input",false)) return GameUi.tr("입력 꺼짐 · 켜면 컨트롤러로 게임을 조작합니다.","输入已关闭 · 启用后可用控制器操作游戏。"); + if (manuallyDisconnected) return GameUi.tr("연결을 해제했습니다. 다시 연결을 누르세요.","连接已断开,请点击重新连接。"); + long[] raw=state.diagnostic(SystemClock.uptimeMillis()); + if ((raw[3]&3)==3) return GameUi.tr("터치와 버튼 준비 완료","触摸和按钮已就绪"); + if ((raw[3]&1)!=0) return GameUi.tr("터치 연결됨 · 입력 탭에서 버튼을 등록하세요.","触摸已连接 · 请在输入页映射按钮。"); + if ((raw[3]&2)!=0) return GameUi.tr("버튼 연결됨 · 터치 포트를 확인하세요.","按钮已连接 · 请检查触摸端口。"); + return GameUi.tr("USB 연결 대기 · 포트와 권한을 확인하세요.","等待 USB 连接 · 请检查端口和权限。"); + } + void updateLedSettings() { + leds.settings(prefs.getInt("brightness", 100), prefs.getInt("ledRotation", 0), + prefs.getBoolean("ledReverse", false), prefs.getBoolean("ring", false)); + ceiling.settings(prefs.getBoolean("led", true) && prefs.getBoolean("ceiling", true), prefs.getInt("brightness", 100)); + } + // A USB address can change on every plug-in. Match VID/PID/interface/serial instead. + static String portId(UsbIo.Port p) { + String serial = ""; + try { serial = p.device.getSerialNumber(); } catch (SecurityException ignored) {} + return p.device.getVendorId() + ":" + p.device.getProductId() + ":" + p.controlId + ":" + (serial == null ? "" : serial); + } + UsbIo.Port selected(String key) throws IOException { + String id = prefs.getString(key, ""); + if (id.isEmpty()) return null; + List snapshot = ports; + String[] ids = new String[snapshot.size()]; + for (int i=0;i snapshot=ports; + String[] names=new String[snapshot.size()]; boolean[] hidFlags=new boolean[names.length]; + for(int i=0;i=0) { + UsbIo.Port p=snapshot.get(index); + edit.putString(key,portId(p)).putString(key+"Name",p.name); + android.util.Log.i("OniimaiUsb", "Auto selected " + key + " = " + p.name + " IF" + p.controlId); + if(key.equals("touchPort")) edit.putInt("touchMode",1); + } + } + edit.apply(); + autoSelectKeyboard(); + } + private void autoSelectKeyboard(){ + if(prefs.contains("keyboardDevice")||prefs.getInt("buttonMode",2)!=1)return; + InputDevice found=null; + for(int id:InputDevice.getDeviceIds()){ + InputDevice d=InputDevice.getDevice(id);if(d==null||d.isVirtual()||d.getKeyboardType()==InputDevice.KEYBOARD_TYPE_NONE)continue; + boolean ours=false;for(UsbIo.Port p:ports)if(PortSelection.role(p.name,p.hid)!=PortSelection.NONE&&d.getVendorId()==p.device.getVendorId()&&d.getProductId()==p.device.getProductId())ours=true; + if(ours){if(found!=null)return;found=d;} + } + if(found!=null){prefs.edit().putString("keyboardDevice",found.getDescriptor()).apply();updateKeyboardConnection();} + } + private void maybeInitialSetup(){ + if(destroyed||!resumed||permissionPending||setupDeferred||prefs.getBoolean("setupComplete",true)||panel.isOpen()||!activity.hasWindowFocus())return; + // Onboarding belongs to the first app launch, even with no USB device or auto-connect disabled. + setupDeferred=true;panel.openSetup(); + } + private void reconnectSaved() { + if(destroyed || !resumed || manuallyDisconnected || connecting || !prefs.getBoolean("autoConnect",true)) return; + autoSelectPorts(); + main.post(this::maybeInitialSetup); + if(ports.isEmpty())return; + boolean input=prefs.getBoolean("input",false), led=prefs.getBoolean("led",true); + if(!input && !led) return; + List keys=new ArrayList<>(); + if(input) { keys.add("touchPort"); if(prefs.getInt("buttonMode",2)==2) keys.add("hidPort"); } + if(led) keys.add("ledPort"); + for(String key:keys) { + String id=prefs.getString(key,""); if(id.isEmpty()) continue; + for(UsbIo.Port p:ports) { + if(!usb.hasPermission(p.device) && PortSelection.base(id).equals(PortSelection.base(portId(p)))) { + if(!permissionPending && !permissionAsked.contains(p.device.getDeviceName())) permission(p.device); + return; + } + } + try { selected(key); } catch(IOException ex) { status=ex.getMessage(); return; } + } + if(input && !connected) connect(); + else if(led && !leds.running()) startLeds(); + } + void refresh() { + if (destroyed) return; + io.execute(() -> { + try { ports = Collections.unmodifiableList(UsbIo.ports(usb)); main.post(this::reconnectSaved); } + catch (Exception ex) { status = ex.getMessage(); } + }); + } + void permission(UsbDevice device) { + permissionAsked.add(device.getDeviceName()); permissionPending = true; + int flags = PendingIntent.FLAG_UPDATE_CURRENT; + if (Build.VERSION.SDK_INT >= 31) flags |= PendingIntent.FLAG_MUTABLE; + Intent reply = new Intent(permissionAction).setPackage(activity.getPackageName()); + usb.requestPermission(device, PendingIntent.getBroadcast(activity, device.getDeviceId(), reply, flags)); + } + void connect() { + if (destroyed || connecting) return; + manuallyDisconnected = false; connected = false; + int gen = generation.incrementAndGet(); connecting = true; clearKeys(); state.disconnect(); + state.awaitStreams(!prefs.getString("touchPort", "").isEmpty(), prefs.getInt("buttonMode", 2) == 2); + status = "연결 중…"; + io.execute(() -> { + try { + closeInputs(); if (gen != generation.get() || destroyed) return; + ports = Collections.unmodifiableList(UsbIo.ports(usb)); + UsbIo.Port touch = selected("touchPort"); + if (touch != null) { + if (touch.hid) throw new IOException("터치에는 CDC 포트를 선택하세요."); + if (prefs.getInt("touchMode", 1) == 0) { + command = new CommandChannel(new UsbIo.Cdc(usb, touch, 115200), (data, n) -> { + if (gen != generation.get()) return; + try { acceptTouch(new Protocol.TouchDebug(data).pressed); } + catch (IllegalArgumentException ex) { status = ex.getMessage(); } + }, message -> {}, message -> inputFailure(gen, message)); + new Protocol.Config(command.request(Protocol.CONFIG_GET)); + command.request(Protocol.DEBUG_START); + } else { + serial = new UsbIo.Cdc(usb, touch, 9600); + Protocol.TouchParser parser = new Protocol.TouchParser(pressed -> { + if (gen == generation.get()) acceptTouch(pressed); + }); + serial.start(parser::feed, message -> inputFailure(gen, message)); + serial.write("{HALT}{RSET}{STAT}".getBytes(StandardCharsets.US_ASCII)); + } + } + if (prefs.getInt("buttonMode", 2) == 2) { + UsbIo.Port button = selected("hidPort"); + if (button == null || !button.hid) throw new IOException("IO4 HID 포트를 선택하세요."); + final int bank = prefs.getInt("bank", 0); + hid = new UsbIo.Hid(usb, button, (data, n) -> { + if (gen != generation.get()) return; + try { + boolean[] bits = Protocol.io4(data, bank); int mask = 0; + for (int i = 0; i < 8; i++) if (bits[i]) mask |= 1 << i; + if (Protocol.io4P1(data)) mask |= 256; + state.hid(mask, SystemClock.uptimeMillis()); + } catch (IllegalArgumentException ex) { inputFailure(gen, "IO4 형식 오류: " + n + " bytes"); } + }, message -> inputFailure(gen, message)); + } + if (gen != generation.get() || destroyed) { closeInputs(); return; } + connected = true; status = "연결됨 · 모든 버튼과 터치에서 손을 떼세요"; + android.util.Log.i("OniimaiUsb", "Saved inputs connected; release gate armed"); + main.post(this::updateKeyboardConnection); + } catch (Exception ex) { + closeInputs(); if (gen == generation.get()) { state.disconnect(); status = "연결 실패: " + ex.getMessage(); } + } finally { connecting = false; } + }); + startLeds(); + } + private void acceptTouch(boolean[] bits) { + long mask = 0; for (int i = 0; i < 34; i++) if (bits[i]) mask |= 1L << i; + state.touch(mask, SystemClock.uptimeMillis()); + } + private void inputFailure(int gen, String message) { + if (generation.compareAndSet(gen, gen + 1)) { + connected = false; + state.disconnect(); status = "입력 연결 끊김: " + message; + if (!destroyed) io.execute(this::closeInputs); + } + } + void startLeds() { + updateLedSettings(); + if (!prefs.getBoolean("led", true)) { leds.stop(); return; } + try { + UsbIo.Port led = selected("ledPort"); + if (led == null) throw new IOException("LED 포트를 선택하세요."); + if (portId(led).equals(prefs.getString("touchPort", ""))) throw new IOException("터치와 LED는 서로 다른 포트여야 합니다."); + updateLedSettings(); leds.start(led, prefs.getInt("address", 17), prefs.getInt("base", 0)); + } catch (Exception ex) { status = "LED: " + ex.getMessage(); } + } + void disconnect() { + manuallyDisconnected = true; connected = false; + generation.incrementAndGet(); state.disconnect(); clearKeys(); leds.stop(); + status = "연결 해제됨"; if (!destroyed) io.execute(this::closeInputs); + } + private final java.util.concurrent.atomic.AtomicBoolean sensitivityBusy=new java.util.concurrent.atomic.AtomicBoolean(); + boolean uiBusy(){return connecting||sensitivityBusy.get();} + boolean sensitivityBusy(){return sensitivityBusy.get();} + void sensitivity(int zone, java.util.function.Consumer callback) { + if (destroyed || !sensitivityBusy.compareAndSet(false,true)) return; + status="감도 읽는 중…"; + io.execute(() -> { + try { + CommandChannel c = configurationChannel(); + Protocol.Config config = new Protocol.Config(c.request(Protocol.CONFIG_GET)); + status="감도를 읽었습니다"; + main.post(() -> { if (!destroyed && panel.isOpen()) callback.accept(config); }); + } catch (Exception ex) { status = "감도 읽기 실패: " + ex.getMessage(); } + finally {sensitivityBusy.set(false);} + }); + } + void saveSensitivity(int zone, int finger, int noise, int hysteresis, Runnable onSaved) { + if (destroyed || !sensitivityBusy.compareAndSet(false,true)) return; + status="감도 저장 중…"; + io.execute(() -> { + try { + CommandChannel c = configurationChannel(); + Protocol.Config config = new Protocol.Config(c.request(Protocol.CONFIG_GET)); + for (int i = 0; i < 34; i++) if (zone == 34 || zone == i) config.sensitivity(i, finger, noise, hysteresis); + c.request(Protocol.CONFIG_SET, config.bytes()); + Protocol.Config verified = new Protocol.Config(c.request(Protocol.CONFIG_GET)); + for (int i = 0; i < 34; i++) if (zone == 34 || zone == i) + if (verified.finger(i) != finger || verified.noise(i) != noise || verified.hysteresis(i) != hysteresis) + throw new IOException("장치의 감도 읽기 결과가 다릅니다. 저장을 중단합니다."); + c.request(Protocol.CONFIG_SAVE); status = "컨트롤러에 감도를 저장했습니다"; + main.post(()->{if(!destroyed)onSaved.run();}); + } catch (Exception ex) { status = "감도 저장 실패: " + ex.getMessage(); } + finally {sensitivityBusy.set(false);} + }); + } + private CommandChannel configurationChannel() throws IOException { + if(command!=null&&!command.isClosed())return command; + if(configCommand!=null&&!configCommand.isClosed())return configCommand; + if(configCommand!=null){configCommand.close();configCommand=null;} + UsbIo.Port touch=selected("touchPort"),found=null; + for(UsbIo.Port p:ports)if(PortSelection.role(p.name,p.hid)==PortSelection.COMMAND&&(touch==null||p.device.getDeviceName().equals(touch.device.getDeviceName()))){ + if(found!=null)throw new IOException("Command 포트가 여러 개입니다. 컨트롤러 하나만 연결하세요.");found=p; + } + if(found==null)throw new IOException("감도 설정용 Command 포트를 찾지 못했습니다."); + // Configuration uses its own interface. Never start debug input on the Touch stream. + configCommand=new CommandChannel(new UsbIo.Cdc(usb,found,115200),(data,n)->{},message->{},message->{}); + return configCommand; + } + private void closeInputs() { + if(configCommand!=null){configCommand.close();configCommand=null;} + if (command != null) { + try { if (!command.isClosed()) command.request(Protocol.DEBUG_STOP); } catch (Exception ignored) {} + command.close(); command = null; + } + if (serial != null) { + try { serial.write("{HALT}".getBytes(StandardCharsets.US_ASCII)); } catch (Exception ignored) {} + serial.close(); serial = null; + } + UsbIo.Hid oldHid = hid; hid = null; + if (oldHid != null) { + try { oldHid.writeCeiling(0); } catch (IOException ignored) {} + oldHid.close(); + } + } + private void updateKeyboardConnection() { + boolean found = false; + String selected = prefs.getString("keyboardDevice", ""); + if (prefs.getInt("buttonMode", 2) == 1) for (int id : InputDevice.getDeviceIds()) { + InputDevice d = InputDevice.getDevice(id); + if (d != null && selected.equals(d.getDescriptor())) found = true; + } + synchronized (keyboard) { if (!found) keyboard.clear(); state.keyboard(keyboard.mask(), found); } + } + @Override public void onInputDeviceAdded(int id) { updateKeyboardConnection(); } + @Override public void onInputDeviceChanged(int id) { clearKeys(); updateKeyboardConnection(); } + @Override public void onInputDeviceRemoved(int id) { clearKeys(); updateKeyboardConnection(); } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiController.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiController.java.meta new file mode 100644 index 000000000..25ddda52a --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiController.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 09515a72aaec4b028f38edd6e3651446 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiDisplay.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiDisplay.java new file mode 100644 index 000000000..ae7cf2919 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiDisplay.java @@ -0,0 +1,466 @@ +package net.majdata.majdataplay.oniimai; + +import android.app.Presentation; +import android.content.Context; +import android.graphics.Color; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; +import android.graphics.Canvas; +import android.graphics.Matrix; +import android.graphics.SurfaceTexture; +import android.graphics.drawable.GradientDrawable; +import android.hardware.display.DisplayManager; +import android.os.Bundle; +import android.os.Build; +import android.os.SystemClock; +import android.util.Log; +import android.view.*; +import android.widget.*; +import net.majdata.majdataplay.MajdataPlayActivity; +import java.lang.reflect.Method; + +/** Routes Unity display 0 directly to a monitor surface; no PixelCopy, projection, or Vulkan hooks. */ +final class OniimaiDisplay implements DisplayManager.DisplayListener { + private static final String TAG = "OniimaiDisplay"; + private final OniimaiController owner; + private final DisplayManager displays; + private Monitor monitor; + private DashboardView dashboard, previewDashboard; + private android.app.Dialog previewDialog; + private TextView outputShortcut; + private Toast outputHint; + private Bitmap coverArt, graphArt; + private volatile String latestStats = "{}"; + private volatile long statsAt; + private volatile boolean active; + private boolean resumed = true, destroyed, overrideRequested; + private int rejectedHighRateDisplay = -1; + private String outputStatus = "외부 출력 꺼짐 / 外接输出关闭"; + OniimaiDisplay(OniimaiController owner) { + this.owner = owner; + displays = (DisplayManager)owner.activity.getSystemService(Context.DISPLAY_SERVICE); + displays.registerDisplayListener(this, owner.main); + owner.main.post(this::reconcile); + } + boolean active() { return active; } + boolean hasMonitor(){return selectedDisplay()!=null;} + int selectedFrameRate(){return OutputMode.frameRate(owner.prefs.getInt("frameRate",60));} + String frameRateDescription(){ + Display d=selectedDisplay(); + return selectedFrameRate()+"fps"+(d==null?"":GameUi.tr(" · 실제 "," · 实际 ")+Math.round(d.getRefreshRate())+"Hz"); + } + void chooseFrameRate(OniimaiPanel ui){ + ui.choose(GameUi.tr("프레임 고정","固定帧率"),new String[]{GameUi.tr("60fps · 기본","60fps · 默认"),"120fps"},selectedFrameRate()==120?1:0,i->{ + owner.prefs.edit().putInt("frameRate",i==1?120:60).apply(); + rejectedHighRateDisplay=-1; + if(monitor!=null)monitor.applyMode(true); + }); + } + void refreshLanguage() { + if(dashboard!=null) { ((ViewGroup)dashboard.getParent()).removeView(dashboard);dashboard=null;showDashboard(); } + removeOutputShortcut(); + refreshOutputShortcut(); + } + String outputDescription() { + if(active)return outputStatus; + return owner.prefs.getBoolean("external",true)?GameUi.tr("외부 모니터 연결 대기","等待外接显示器"):GameUi.tr("휴대폰에서 게임 출력","在手机上显示游戏"); + } + void stats(String text) { latestStats = text; statsAt = SystemClock.uptimeMillis(); } + void artwork(int kind, byte[] png) { + Bitmap image = png == null || png.length == 0 || png.length > 2_000_000 ? null : BitmapFactory.decodeByteArray(png,0,png.length); + if(kind==0) coverArt=image; else if(kind==1) graphArt=image; + if(dashboard!=null)updateDashboard(dashboard); + if(previewDashboard!=null)updateDashboard(previewDashboard); + } + void resume(boolean value) { resumed = value; reconcile(); } + private Display selectedDisplay() { + Display[] available = displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + String selected = owner.prefs.getString("displayName", ""); + for (Display d : available) { + if (d.getDisplayId() != Display.DEFAULT_DISPLAY && (selected.isEmpty() || selected.equals(d.getName()))) return d; + } + return null; + } + void reconcile() { + if (destroyed) return; + Display d = selectedDisplay(); + boolean enabled = owner.prefs.getBoolean("external", true) && resumed; + if (!enabled || d == null) { + closeMonitor(); + outputStatus = enabled ? "외부 출력 대기 · USB-C/HDMI 디스플레이 연결을 확인하세요" : "외부 출력 꺼짐 / 外接输出关闭"; + refreshOutputShortcut(); + return; + } + if (monitor != null && monitor.getDisplay().getDisplayId() == d.getDisplayId()) { monitor.rotate(); refreshOutputShortcut(); return; } + closeMonitor(); + try { + monitor = new Monitor(d); + monitor.setOnDismissListener(dialog -> { + if (monitor == dialog) { closeMonitor(); owner.main.post(this::reconcile); } + }); + monitor.show(); + Log.i(TAG, "Presentation display=" + d.getDisplayId() + " " + d.getName() + " mode=" + d.getMode()); + } catch (RuntimeException ex) { closeMonitor(); outputStatus = "외부 출력 실패: " + ex.getMessage(); Log.e(TAG, outputStatus, ex); } + refreshOutputShortcut(); + } + private void bind(Surface surface) { + overrideRequested = true; + if (!((MajdataPlayActivity)owner.activity).oniimaiSurface(surface)) + throw new IllegalStateException("Unity surface not ready"); + active = true; owner.panel.refreshEntry(); showDashboard(); + refreshOutputShortcut(); + } + private void restore() { + if (overrideRequested) { + ((MajdataPlayActivity)owner.activity).oniimaiSurface(null); + overrideRequested = false; + Log.i(TAG, "Unity surface restored to device"); + } + active = false; + if(!destroyed)owner.panel.refreshEntry(); + if (dashboard != null) { ((ViewGroup)dashboard.getParent()).removeView(dashboard); dashboard = null; } + refreshOutputShortcut(); + } + private void closeMonitor() { + Monitor old = monitor; monitor = null; + // Detach Unity's EGL producer before releasing either consumer route. + restore(); + if (old != null) { old.releaseSurface(); old.dismiss(); } + } + private void restart() { closeMonitor(); reconcile(); } + String deviceName(){Display d=selectedDisplay();return d==null?GameUi.tr("연결된 모니터 없음","未连接显示器"):d.getName();} + void chooseMonitor(OniimaiPanel ui){ + Display[] list=displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION);String[] names=new String[list.length];int selected=-1; + for(int i=0;i{owner.prefs.edit().putString("displayName",names[i]).apply();restart();}); + } + void updateDashboard(DashboardView view) { + view.update(latestStats,SystemClock.uptimeMillis()-statsAt>2500,coverArt,graphArt); + } + boolean dashboardBack() { return dashboard!=null && dashboard.back(); } + void previewDashboard() { + if(previewDialog!=null)return; + android.app.Dialog d=new android.app.Dialog(owner.activity,android.R.style.Theme_Material_Light_NoActionBar){ + @Override public void onBackPressed(){if(previewDashboard==null||!previewDashboard.back())super.onBackPressed();} + }; + previewDialog=d;previewDashboard=new DashboardView(owner,true,d::dismiss);d.setContentView(previewDashboard); + owner.panel.protect(d,()->{previewDialog=null;previewDashboard=null;});owner.panel.show(d,true); + } + private void showDashboard() { + if(dashboard!=null)return; + dashboard=new DashboardView(owner,false,this::switchToPhone); + owner.activity.addContentView(dashboard,new ViewGroup.LayoutParams(-1,-1));dashboard.requestApplyInsets(); + } + private void switchToPhone() { + owner.prefs.edit().putBoolean("external",false).apply(); + reconcile(); + hint(GameUi.tr("‘외부 화면 켜기’를 누르면 모니터로 돌아갑니다. 버튼을 끌어서 옮길 수 있어요.", + "点击“开启外接屏幕”即可返回显示器。拖动按钮可调整位置。")); + } + private void switchToMonitor() { + Log.i(TAG,"Display shortcut requested: resumed="+resumed+" active="+active+" connecting="+(monitor!=null)); + if(destroyed || !resumed || active || monitor!=null)return; + if(outputHint!=null)outputHint.cancel(); + owner.prefs.edit().putBoolean("external",true).apply(); + reconcile(); + if(selectedDisplay()==null)hint(GameUi.tr("외부 출력을 켰습니다. 모니터를 연결하면 자동으로 전환됩니다.", + "已开启外接输出。连接显示器后将自动切换。")); + else if(monitor==null)hint(GameUi.tr("외부 화면을 열지 못했습니다. 모니터 연결을 확인하고 다시 눌러 주세요.", + "无法开启外接屏幕。请检查显示器连接后重试。")); + } + private void hint(String message) { + if(outputHint!=null)outputHint.cancel(); + outputHint=Toast.makeText(owner.activity,message,Toast.LENGTH_LONG); + outputHint.show(); + } + private void refreshOutputShortcut() { + boolean visible=!destroyed && resumed && !active + && (!owner.prefs.getBoolean("external",true) || selectedDisplay()!=null); + if(!visible) { if(outputShortcut!=null)outputShortcut.setVisibility(View.GONE); return; } + if(outputShortcut==null) { + outputShortcut=GameUi.action(owner.activity,"",true,this::switchToMonitor); + outputShortcut.setSingleLine(true); + outputShortcut.setMinHeight(dp(GameUi.TOUCH_DP)); + outputShortcut.setEllipsize(android.text.TextUtils.TruncateAt.END); + outputShortcut.setElevation(dp(4)); + // Only this button owns touch events; the rest of the screen remains Unity's. + // A separate row leaves room for the game's centered Oniimai settings entry. + FrameLayout.LayoutParams lp=new FrameLayout.LayoutParams(-2,-2,Gravity.TOP|Gravity.RIGHT); + lp.topMargin=dp(80);lp.rightMargin=dp(12); + owner.activity.addContentView(outputShortcut,lp); + FloatingShortcut.attach(outputShortcut,owner.prefs); + outputShortcut.requestApplyInsets(); + } + boolean connecting=monitor!=null; + String title=connecting?GameUi.tr("외부 화면 연결 중…","正在连接外接屏幕…") + :GameUi.tr("외부 화면 켜기","开启外接屏幕"); + GameUi.setText(outputShortcut,title);outputShortcut.setContentDescription(title); + GameUi.enabled(outputShortcut,!connecting); + outputShortcut.setVisibility(View.VISIBLE);outputShortcut.bringToFront(); + } + private void removeOutputShortcut() { + if(outputShortcut==null)return; + if(outputShortcut.getParent() instanceof ViewGroup)((ViewGroup)outputShortcut.getParent()).removeView(outputShortcut); + outputShortcut=null; + } + private int dp(int n) { return Math.round(n * owner.activity.getResources().getDisplayMetrics().density); } + private TextView label(LinearLayout parent, String text, int size) { + TextView t = new TextView(owner.activity); t.setText(text); t.setTextSize(size); t.setTextColor(Color.rgb(29, 33, 43)); t.setPadding(0, dp(10), 0, dp(14)); parent.addView(t); return t; + } + private void card(TextView text) { + GradientDrawable bg = new GradientDrawable(); bg.setColor(Color.WHITE); bg.setCornerRadius(dp(22)); text.setBackground(bg); text.setPadding(dp(20), dp(20), dp(20), dp(20)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(-1, -2); lp.setMargins(0, dp(14), 0, dp(12)); text.setLayoutParams(lp); + } + void addSettings(OniimaiPanel ui, LinearLayout body) { + LinearLayout c=GameUi.card(body,ui.tr("외부 모니터","外接显示器"),ui.tr("가로 모니터에 세로 게임 화면을 회전해 출력합니다.","将竖屏游戏旋转后铺满横向显示器。")); + ui.toggle(c, ui.tr("외부 화면으로 게임 출력", "将游戏输出到外接屏幕"), "external", true, value -> reconcile()); + ui.watchRow(c,ui.tr("출력 장치","输出设备"),()->{Display d=selectedDisplay();return d==null?ui.tr("연결된 모니터 없음","未连接显示器"):d.getName();}, () -> { + Display[] list = displays.getDisplays(DisplayManager.DISPLAY_CATEGORY_PRESENTATION); + String[] names = new String[list.length]; for (int i = 0; i < list.length; i++) names[i] = list[i].getName(); + ui.choose(ui.tr("외부 모니터 선택","选择外接显示器"), names, -1, i -> { owner.prefs.edit().putString("displayName", names[i]).apply(); restart(); }); + }); + c=GameUi.card(body,ui.tr("게임 방향","游戏方向"),"1080 × 1920 → 16:9"); + final DisplayPreview preview=new DisplayPreview(owner.activity,()->owner.prefs.getBoolean("displayReverse",false));c.addView(preview,new LinearLayout.LayoutParams(-1,-2)); + ui.watchRow(c,ui.tr("회전 방향","旋转方向"),()->owner.prefs.getBoolean("displayReverse",false)?ui.tr("역방향 세로 · 270°","反向竖屏 · 270°"):ui.tr("세로 · 90°","竖屏 · 90°"), () -> ui.choose(ui.tr("회전 방향","旋转方向"), + new String[]{ui.tr("세로 · 90°", "竖屏 · 90°"), ui.tr("역방향 세로 · 270°", "反向竖屏 · 270°")}, owner.prefs.getBoolean("displayReverse", false) ? 1 : 0, + i -> { owner.prefs.edit().putBoolean("displayReverse", i == 1).apply(); if (monitor != null) monitor.rotate();preview.invalidate(); })); + ui.text(c,ui.tr("방향은 즉시 적용되고 저장됩니다. 폰에는 스탯과 센서 상태가 표시됩니다.","方向立即生效并保存,手机显示统计和传感器状态。")); + c=GameUi.card(body,ui.tr("현재 출력","当前输出"),null); + ui.watchRow(c,ui.tr("해상도 · 주사율","分辨率 · 刷新率"),this::outputDescription,()->ui.info(ui.tr("출력 해상도","输出分辨率"),ui.tr("게임은 1080×1920으로 렌더링합니다. HDMI 해상도와 주사율은 폰·허브·모니터가 함께 지원하는 모드를 사용합니다. 16:9 모니터에서 전체 영상을 채웁니다.","游戏以 1080×1920 渲染。HDMI 分辨率和刷新率取决于手机、扩展坞和显示器共同支持的模式,完整画面铺满 16:9 显示器。"))); + } + private final class Monitor extends Presentation implements TextureView.SurfaceTextureListener, SurfaceHolder.Callback2 { + private FrameLayout root; + private TextureView texture; + private SurfaceView anchor; + private SurfaceControl gameLayer; + private Method layerMatrix, layerPosition; + private boolean direct = Build.VERSION.SDK_INT >= 29, fallbackPending; + private Surface unitySurface; + private boolean firstFrame; + private int appliedWidth, appliedHeight; + private boolean appliedReverse; + private float requestedRate = Float.NaN; + private int modeRequest = -1; + private Runnable modeCheck; + private String lastGeometry = ""; + Monitor(Display display) { super(owner.activity, display, android.R.style.Theme_Material_NoActionBar_Fullscreen); } + @Override protected void onCreate(Bundle bundle) { + super.onCreate(bundle); + requestWindowFeature(Window.FEATURE_NO_TITLE); + Window window = getWindow(); + window.setTitle("MajdataPlay Oniimai External"); + window.setBackgroundDrawableResource(android.R.color.black); + window.getDecorView().setPadding(0, 0, 0, 0); + window.addFlags(WindowManager.LayoutParams.FLAG_HARDWARE_ACCELERATED | WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON | WindowManager.LayoutParams.FLAG_FULLSCREEN | WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE); + window.getDecorView().setSystemUiVisibility(5894); // Fullscreen + hide navigation + immersive sticky. + if (Build.VERSION.SDK_INT >= 30) { + window.setDecorFitsSystemWindows(false); + WindowInsetsController bars = window.getInsetsController(); + if (bars != null) { + bars.hide(WindowInsets.Type.systemBars()); + bars.setSystemBarsBehavior(WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE); + } + } + root = new FrameLayout(getContext()); root.setBackgroundColor(Color.BLACK); + if (direct) { + // Android owns the unrotated anchor. Only our child layer receives + // Unity buffers, bypassing TextureView and the UI render thread. + anchor = new SurfaceView(getContext()); anchor.setZOrderOnTop(true); + anchor.getHolder().addCallback(this); + root.addView(anchor, new FrameLayout.LayoutParams(-1, -1)); + } else installTexture(); + root.addOnLayoutChangeListener((v,l,t,r,b,ol,ot,or,ob) -> rotate()); + setContentView(root); window.setLayout(-1, -1); applyMode(false); rotate(); + } + private OutputMode.Mode mode(Display.Mode m){ + return new OutputMode.Mode(m.getModeId(),m.getPhysicalWidth(),m.getPhysicalHeight(),m.getRefreshRate()); + } + private OutputMode.Mode findMode(int rate){ + Display.Mode[] supported=getDisplay().getSupportedModes(); + OutputMode.Mode[] modes=new OutputMode.Mode[supported.length]; + for(int i=0;i{ + if(monitor!=this || destroyed || !resumed)return; + float actual=getDisplay().getRefreshRate(); + if(!OutputMode.matches(actual,requested)){ + Log.i(TAG,"External mode not applied: requested="+requested+" actual="+actual); + if(requested==120 && rejectedHighRateDisplay!=getDisplay().getDisplayId()){ + rejectedHighRateDisplay=getDisplay().getDisplayId(); + OutputMode.Mode fallback=findMode(60);if(fallback!=null)setMode(fallback); + hint(GameUi.tr("120Hz가 적용되지 않아 60Hz 모드로 복귀합니다. 프레임 선택은 저장됩니다.","120Hz 未生效,返回 60Hz 模式。保留帧率选择。")); + } + } + rotate(); + }; + owner.main.postDelayed(modeCheck,5000); + } + private void installTexture() { + texture = new TextureView(getContext()); + texture.setOpaque(true); + texture.setSurfaceTextureListener(this); + root.addView(texture, new FrameLayout.LayoutParams(-1, -1)); + texture.addOnLayoutChangeListener((v,l,t,r,b,ol,ot,or,ob) -> rotate()); + } + void rotate() { + if (monitor != this || root == null || root.getWidth() <= 0 || root.getHeight() <= 0) return; + boolean reverse = owner.prefs.getBoolean("displayReverse", false); + int width = direct ? root.getWidth() : texture == null ? 0 : texture.getWidth(); + int height = direct ? root.getHeight() : texture == null ? 0 : texture.getHeight(); + if (width <= 0 || height <= 0) return; + boolean geometryChanged = appliedWidth != width || appliedHeight != height || appliedReverse != reverse; + if (direct) { + if (gameLayer == null || !gameLayer.isValid() || fallbackPending) return; + if (geometryChanged) { + float[] m = DisplayGeometry.surfaceMatrix(width, height, reverse); + try (SurfaceControl.Transaction tx = new SurfaceControl.Transaction()) { + // Only changed geometry needs a compositor transaction. + // Resolve these non-SDK APIs once when creating the layer. + layerMatrix.invoke(tx, gameLayer, m[0], m[3], m[1], m[4]); + layerPosition.invoke(tx, gameLayer, m[2], m[5]); + tx.setLayer(gameLayer, 1).setVisibility(gameLayer, true).apply(); + } catch (ReflectiveOperationException | RuntimeException error) { fallbackToTexture(error); return; } + } + } else if (geometryChanged) { + Matrix matrix = new Matrix(); + matrix.setValues(DisplayGeometry.textureMatrix(width, height, reverse)); + texture.setTransform(matrix); + } + appliedWidth = width; appliedHeight = height; appliedReverse = reverse; + requestFrameRate(); + outputStatus = "1080×1920 → " + root.getWidth() + "×" + root.getHeight() + + " · " + DisplayGeometry.angle(reverse) + "° · " + Math.round(getDisplay().getMode().getRefreshRate()) + " Hz"; + String geometry = outputStatus + (direct ? " SurfaceControl direct" : " TextureView fallback"); + if (!lastGeometry.equals(geometry)) { + lastGeometry = geometry; + Log.i(TAG, geometry); + } + } + private void requestFrameRate() { + if (Build.VERSION.SDK_INT >= 30 && unitySurface != null && unitySurface.isValid()) { + float rate = getDisplay().getRefreshRate(); + if (Float.compare(rate, requestedRate) == 0) return; + try { + unitySurface.setFrameRate(rate, Surface.FRAME_RATE_COMPATIBILITY_DEFAULT); + requestedRate = rate; + } + catch (RuntimeException error) { Log.w(TAG, "Display frame-rate hint unavailable", error); } + } + } + @Override public void surfaceCreated(SurfaceHolder holder) { + if (monitor != this || !resumed || destroyed || !direct) return; + try { + layerMatrix = SurfaceControl.Transaction.class.getMethod("setMatrix", SurfaceControl.class, float.class, float.class, float.class, float.class); + layerPosition = SurfaceControl.Transaction.class.getMethod("setPosition", SurfaceControl.class, float.class, float.class); + gameLayer = new SurfaceControl.Builder().setName("MajdataPlay Oniimai Unity External") + .setParent(anchor.getSurfaceControl()).setBufferSize(DisplayGeometry.BUFFER_WIDTH, DisplayGeometry.BUFFER_HEIGHT) + .setOpaque(true).setHidden(true).build(); + unitySurface = new Surface(gameLayer); + rotate(); + if (fallbackPending) return; + bind(unitySurface); + Log.i(TAG, "Unity attached to direct portrait surface 1080x1920"); + } catch (ReflectiveOperationException | RuntimeException error) { fallbackToTexture(error); } + } + private void fallbackToTexture(Exception error) { + if (fallbackPending || monitor != this || destroyed) return; + fallbackPending = true; + Log.w(TAG, "Direct surface unavailable; using TextureView", error); + // Replace after the current SurfaceHolder/layout callback returns. + owner.main.post(() -> { + if (monitor != this || destroyed) return; + try { + restore(); + direct = false; + anchor.getHolder().removeCallback(this); + releaseSurface(); root.removeView(anchor); anchor = null; + installTexture(); applyMode(true); + } catch (RuntimeException failure) { + Log.e(TAG, "External fallback failed", failure); closeMonitor(); + } + }); + } + @Override public void surfaceChanged(SurfaceHolder holder, int format, int width, int height) { rotate(); } + @Override public void surfaceRedrawNeeded(SurfaceHolder holder) { + if (monitor != this || destroyed || !holder.getSurface().isValid()) return; + // A black buffer completes window relayout, without a per-frame UI copy. + Canvas canvas = null; + try { canvas = holder.lockCanvas(); if (canvas != null) canvas.drawColor(Color.BLACK); } + finally { if (canvas != null) holder.unlockCanvasAndPost(canvas); } + } + @Override public void surfaceDestroyed(SurfaceHolder holder) { + if (monitor == this) { closeMonitor(); owner.main.post(OniimaiDisplay.this::reconcile); } + releaseSurface(); + } + private void attachTexture(SurfaceTexture buffer) { + if (monitor != this || !resumed || destroyed) return; + // TextureView resets its default to landscape bounds on resize. + buffer.setDefaultBufferSize(DisplayGeometry.BUFFER_WIDTH, DisplayGeometry.BUFFER_HEIGHT); + rotate(); + // Geometry changes must not recreate Unity's EGL surface. + if (unitySurface == null) { + try { + unitySurface = new Surface(buffer); requestFrameRate(); bind(unitySurface); + Log.i(TAG, "Unity attached to fallback portrait texture 1080x1920"); + } catch (RuntimeException error) { Log.e(TAG, "Texture output failed", error); closeMonitor(); } + } + } + public void onSurfaceTextureAvailable(SurfaceTexture buffer, int width, int height) { attachTexture(buffer); } + public void onSurfaceTextureSizeChanged(SurfaceTexture buffer, int width, int height) { + attachTexture(buffer); + } + public boolean onSurfaceTextureDestroyed(SurfaceTexture buffer) { + if (monitor == this) { closeMonitor(); owner.main.post(OniimaiDisplay.this::reconcile); } + releaseSurface(); + return true; + } + void releaseSurface() { + if(modeCheck!=null){owner.main.removeCallbacks(modeCheck);modeCheck=null;} + appliedWidth = appliedHeight = 0; + requestedRate = Float.NaN; + if (unitySurface != null) { unitySurface.release(); unitySurface = null; } + if (gameLayer != null) { + try (SurfaceControl.Transaction tx = new SurfaceControl.Transaction()) { + if (gameLayer.isValid()) tx.reparent(gameLayer, null).apply(); + } catch (RuntimeException error) { Log.w(TAG, "External layer already disconnected", error); } + finally { gameLayer.release(); gameLayer = null; } + } + } + public void onSurfaceTextureUpdated(SurfaceTexture buffer) { + if (!firstFrame) { firstFrame = true; Log.i(TAG, "First external game frame presented"); } + } + } + @Override public void onDisplayAdded(int id) { reconcile(); } + @Override public void onDisplayRemoved(int id) { + if(rejectedHighRateDisplay==id)rejectedHighRateDisplay=-1; + reconcile(); + } + @Override public void onDisplayChanged(int id) { + // Phone brightness/refresh events do not change the external surface. + if (monitor == null || monitor.getDisplay().getDisplayId() == id) reconcile(); + } + void destroy() { destroyed = true; displays.unregisterDisplayListener(this); if(previewDialog!=null)previewDialog.dismiss(); closeMonitor(); removeOutputShortcut(); if(outputHint!=null)outputHint.cancel(); } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiDisplay.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiDisplay.java.meta new file mode 100644 index 000000000..4f5e5b899 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiDisplay.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: da9486192a4f46088c2a1426da9801fd +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiPanel.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiPanel.java new file mode 100644 index 000000000..3cd42cfb7 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiPanel.java @@ -0,0 +1,285 @@ +package net.majdata.majdataplay.oniimai; + +import android.app.*; +import android.content.SharedPreferences; +import android.graphics.Color; +import android.graphics.drawable.ColorDrawable; +import android.os.SystemClock; +import android.view.*; +import android.widget.*; +import java.util.*; + +/** Game-styled, task-oriented settings. Every popup keeps physical gameplay input gated. */ +final class OniimaiPanel { + private final OniimaiController owner; + private final Activity activity; + private final SharedPreferences prefs; + private Dialog dialog, learningDialog; + private InitialSetup setup; + private TextView entry, statusText, notice; + private LinearLayout settingsBody; + private ScrollView settingsScroll; + private final TextView[] categoryTabs=new TextView[4]; + private final ArrayList settingControls=new ArrayList<>(); + private final int[] scrollPositions=new int[4]; + private final ArrayList settings=new ArrayList<>(); + private String displayed=""; + private final int[] positions=new int[4]; + private int tab, dialogs, epoch; + private String lastStatus=""; + private boolean entryWanted; + private final Set openDialogs=new HashSet<>(); + private final ArrayList bindings=new ArrayList<>(); + private final Runnable update=new Runnable(){ public void run(){ + if(dialog==null)return; + long[] raw=owner.state.diagnostic(SystemClock.uptimeMillis()); + String feedback=owner.sensitivityBusy()?UiText.message(owner.status):owner.connectionDescription(); + GameUi.setText(statusText,feedback+" · "+owner.leds.brief()); + statusText.setTextColor(feedback.contains("실패")||feedback.contains("오류")||feedback.contains("失败")||feedback.contains("错误")?GameUi.ERROR:GameUi.INK); + for(View control:settingControls)GameUi.enabled(control,!owner.uiBusy()); + for(Runnable binding:new ArrayList<>(bindings))binding.run(); + refreshSetting(); + if(!lastStatus.equals(owner.status)){lastStatus=owner.status;if(lastStatus.startsWith("감도")||lastStatus.startsWith("컨트롤러에 감도"))flash(UiText.message(lastStatus));} + if(learningDialog!=null&&owner.learning<0){ learningDialog.dismiss(); flash(tr("버튼을 저장했습니다","已保存按钮映射")); } + owner.main.postDelayed(this,200); + }}; + OniimaiPanel(OniimaiController owner){this.owner=owner;activity=owner.activity;prefs=owner.prefs;tab=Math.max(0,Math.min(3,prefs.getInt("uiCategory",0)));for(int i=0;i<4;i++){positions[i]=Math.max(0,prefs.getInt("uiItem"+i,0));scrollPositions[i]=Math.max(0,prefs.getInt("uiScroll"+i,0));}} + String tr(String ko,String zh){return GameUi.tr(ko,zh);} + boolean isOpen(){return dialogs>0;} + void entry(boolean visible){ + entryWanted=visible; + if(owner.display.active()){if(entry!=null)entry.setVisibility(View.GONE);return;} + if(entry==null&&visible){ + entry=GameUi.action(activity,"Oniimai · USB / Display",true,this::open); + FrameLayout.LayoutParams lp=new FrameLayout.LayoutParams(-2,dp(48),Gravity.TOP|Gravity.CENTER_HORIZONTAL);lp.topMargin=dp(18);activity.addContentView(entry,lp); + } + if(entry!=null)entry.setVisibility(visible?View.VISIBLE:View.GONE); + if(!visible&&dialog!=null&&setup==null)closeAll(); + } + void refreshEntry(){entry(entryWanted);} + void textConfigurationChanged(){boolean wasSetup=setup!=null,wasOpen=dialog!=null;int selected=tab;closeAll();if(entry!=null){((ViewGroup)entry.getParent()).removeView(entry);entry=null;}refreshEntry();if(wasSetup)owner.main.post(this::openSetup);else if(wasOpen)owner.main.post(()->open(selected));} + void openSetup(){if(setup==null){setup=new InitialSetup(owner,this);setup.show();}} + void setupClosed(InitialSetup closed){if(setup==closed)setup=null;} + void open(){open(tab);} + void open(int section){ + if(dialog!=null){selectTab(section);return;} + tab=Math.max(0,Math.min(3,section));owner.refresh(); + LinearLayout root=column();root.setBackgroundColor(GameUi.PAGE); + if(android.os.Build.VERSION.SDK_INT>=30)root.setOnApplyWindowInsetsListener((v,insets)->{android.graphics.Insets safe=insets.getInsets(WindowInsets.Type.systemBars()|WindowInsets.Type.displayCutout());root.setPadding(safe.left,safe.top,safe.right,safe.bottom);return insets;}); + LinearLayout header=column();header.setPadding(dp(18),dp(12),dp(18),dp(12)); + LinearLayout navigation=new LinearLayout(activity);navigation.setGravity(Gravity.CENTER_VERTICAL); + TextView back=GameUi.action(activity,tr("‹ 뒤로","‹ 返回"),false,()->{if(dialog!=null)dialog.dismiss();});navigation.addView(back); + TextView title=GameUi.text(activity,tr("컨트롤러 설정","控制器设置"),21,GameUi.INK,true);title.setPadding(dp(14),0,0,0);navigation.addView(title,new LinearLayout.LayoutParams(0,-2,1));header.addView(navigation); + statusText=GameUi.text(activity,"",12,GameUi.MUTED,false);statusText.setPadding(0,dp(16),0,dp(8));header.addView(statusText); + LinearLayout footer=new LinearLayout(activity);footer.setGravity(Gravity.CENTER_VERTICAL); + notice=GameUi.text(activity,tr("변경사항은 자동 저장됩니다","更改会自动保存"),12,GameUi.MUTED,false);footer.addView(notice,new LinearLayout.LayoutParams(0,-2,1)); + TextView language=GameUi.action(activity,"한국어 / 中文",false,this::language);language.setTextSize(12);footer.addView(language);header.addView(footer);GameUi.space(header,14); + LinearLayout tabs=new LinearLayout(activity);String[] names={tr("연결","连接"),tr("입력","输入"),"LED",tr("화면","屏幕")}; + for(int i=0;i<4;i++){final int category=i;TextView button=GameUi.action(activity,names[i],false,()->selectTab(category));button.setTextSize(15);button.setPadding(dp(6),dp(12),dp(6),dp(12));categoryTabs[i]=button;LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(0,-2,1);if(i>0)lp.leftMargin=dp(6);tabs.addView(button,lp);} + header.addView(tabs);root.addView(header); + settingsScroll=new ScrollView(activity);settingsScroll.setFillViewport(false);settingsScroll.setVerticalScrollBarEnabled(false); + settingsBody=column();settingsBody.setPadding(dp(18),0,dp(18),dp(20));settingsScroll.addView(settingsBody);root.addView(settingsScroll,new LinearLayout.LayoutParams(-1,0,1)); + Dialog d=new Dialog(activity,android.R.style.Theme_Material_Light_NoActionBar);dialog=d;d.setContentView(root); + protect(d,()->{saveScroll();epoch++;dialog=null;owner.learning=-1;learningDialog=null;bindings.clear();settingControls.clear();owner.main.removeCallbacks(update);}); + show(d,true);root.requestApplyInsets();render();owner.main.post(update); + } + private void saveScroll(){if(settingsScroll!=null){scrollPositions[tab]=settingsScroll.getScrollY();prefs.edit().putInt("uiScroll"+tab,scrollPositions[tab]).apply();}} + void language(){ + choose(tr("언어","语言"),new String[]{"한국어","简体中文"},"zh-Hans".equals(UiText.language())?1:0,i->{ + UiText.language(i==1?"zh-Hans":"ko");prefs.edit().putString("language",UiText.language()).apply(); + boolean wasSetup=setup!=null;int selected=tab;closeAll();owner.main.post(()->{owner.display.refreshLanguage();if(wasSetup)openSetup();else open(selected);}); + }); + } + private String connectionSummary(long[] raw){ + String touch=(raw[3]&1)!=0?tr("터치 연결됨","触摸已连接"):tr("터치 대기","等待触摸"); + String buttons=(raw[3]&2)!=0?tr("버튼 연결됨","按钮已连接"):tr("버튼 대기","等待按钮"); + return touch+" · "+buttons+" · LED "+(owner.leds.running()?"ON":"OFF"); + } + private void selectTab(int value){if(value==tab)return;saveScroll();epoch++;tab=value;owner.learning=-1;render();} + private void render(){ + if(settingsBody==null)return;settings.clear();bindings.clear();settingControls.clear();populate();positions[tab]=Math.min(positions[tab],settings.size()-1);settingsBody.removeAllViews(); + for(int i=0;i<4;i++){boolean selected=i==tab;categoryTabs[i].setTextColor(selected?Color.WHITE:GameUi.INK);categoryTabs[i].setBackground(GameUi.ripple(activity,selected?GameUi.TAB:0xffe6eaed,14));categoryTabs[i].setSelected(selected);} + LinearLayout group=column();GameUi.surface(group);group.setPadding(dp(16),dp(2),dp(16),dp(2));settingsBody.addView(group); + for(int i=0;i0)GameUi.divider(group); + LinearLayout row=new LinearLayout(activity);row.setGravity(Gravity.CENTER_VERTICAL);row.setMinimumHeight(dp(76));row.setPadding(0,dp(14),0,dp(14)); + LinearLayout words=column();TextView name=GameUi.text(activity,item.name,16,GameUi.INK,true);words.addView(name);GameUi.space(words,5); + TextView detail=GameUi.text(activity,item.toggle?item.hint:item.value.get(),13,GameUi.MUTED,false);words.addView(detail);row.addView(words,new LinearLayout.LayoutParams(0,-2,1)); + Runnable activate=()->{if(owner.uiBusy())return;epoch++;positions[tab]=index;item.action.run();refreshSetting();}; + if(item.toggle){ + Switch toggle=new Switch(new ContextThemeWrapper(activity,android.R.style.Theme_Material_Light_NoActionBar));toggle.setShowText(false);toggle.setChecked("ON".equals(item.value.get()));toggle.setMinHeight(dp(48));toggle.setContentDescription(item.name);toggle.setThumbTintList(new android.content.res.ColorStateList(new int[][]{{android.R.attr.state_checked},{}},new int[]{GameUi.BLUE,0xffa8b5c9}));toggle.setTrackTintList(new android.content.res.ColorStateList(new int[][]{{android.R.attr.state_checked},{}},new int[]{0xffc2dcf6,GameUi.LINE}));toggle.setOnClickListener(v->activate.run());row.addView(toggle);settingControls.add(toggle);bindings.add(()->toggle.setChecked("ON".equals(item.value.get()))); + row.setOnClickListener(v->activate.run());row.setBackground(GameUi.ripple(activity,Color.TRANSPARENT,12));settingControls.add(row); + }else{ + detail.setTextColor(GameUi.BLUE);bindings.add(()->GameUi.setText(detail,item.value.get()));TextView arrow=GameUi.text(activity,"›",25,GameUi.MUTED,false);arrow.setGravity(Gravity.CENTER);row.addView(arrow,new LinearLayout.LayoutParams(dp(32),dp(48)));row.setBackground(GameUi.ripple(activity,Color.TRANSPARENT,12));row.setOnClickListener(v->activate.run());GameUi.buttonRole(row);settingControls.add(row); + } + group.addView(row,new LinearLayout.LayoutParams(-1,-2)); + } + displayed="";refreshSetting();int section=tab,revision=epoch;settingsScroll.post(()->{if(dialog!=null&&tab==section&&epoch==revision)settingsScroll.scrollTo(0,scrollPositions[section]);});GameUi.appear(group); + } + private void rebuild(){render();} + private static final class Setting { + final String name,hint;final Value value;final Runnable action;final java.util.function.IntConsumer adjust;final boolean toggle; + Setting(String n,Value v,String h,Runnable a,java.util.function.IntConsumer d){this(n,v,h,a,d,false);} + Setting(String n,Value v,String h,Runnable a,java.util.function.IntConsumer d,boolean t){name=n;value=v;hint=h;action=a;adjust=d;toggle=t;} + } + private Setting current(){return settings.get(positions[tab]);} + private void item(String name,Value value,String hint,Runnable action){settings.add(new Setting(name,value,hint,action,null));} + private void flag(String name,String key,boolean fallback,String hint,Toggle action){ + java.util.function.IntConsumer change=n->{boolean value=n>0;prefs.edit().putBoolean(key,value).apply();action.accept(value);}; + settings.add(new Setting(name,()->prefs.getBoolean(key,fallback)?"ON":"OFF",hint,()->change.accept(prefs.getBoolean(key,fallback)?-1:1),change,true)); + } + private void numberItem(String name,String key,int fallback,int min,int max,int step,String suffix,String hint,Runnable action){ + java.util.function.IntConsumer change=n->{int value=Math.max(min,Math.min(max,prefs.getInt(key,fallback)+n*step));prefs.edit().putInt(key,value).apply();action.run();}; + settings.add(new Setting(name,()->prefs.getInt(key,fallback)+suffix,hint,()->{LinearLayout body=column();slider(body,name,key,fallback,min,max,action);sheet(name,body,tr("완료","完成"));},change)); + } + private void refreshSetting(){ + if(dialog==null||settings.isEmpty())return;String stamp=tab+"/"+positions[tab];if(stamp.equals(displayed))return;displayed=stamp;prefs.edit().putInt("uiCategory",tab).putInt("uiItem"+tab,positions[tab]).apply(); + } + private void populate(){ + String pick=tr("눌러서 선택하세요","点击选择"),adjust=tr("눌러서 슬라이더로 조절하세요","点击后使用滑块调整"); + if(tab==0){ + item(tr("초기 설정","初始设置"),()->tr("연결 · 모니터 · 부가 설정","连接 · 显示器 · 更多设置"),tr("초기 설정 안내를 다시 엽니다","重新打开初始设置向导"),this::openSetup); + item(tr("자동 연결","自动连接"),()->tr("연결하기","连接"),tr("이름으로 포트를 찾아 연결합니다","按端口名称自动查找并连接"),()->{owner.enableInput(true);owner.automaticPorts();flash(tr("컨트롤러 연결 중…","正在连接控制器…"));}); + flag(tr("컨트롤러 입력","控制器输入"),"input",false,tr("게임에 버튼과 터치 입력을 전달합니다","向游戏传送按钮和触摸输入"),owner::enableInput); + item(tr("터치 포트","触摸端口"),()->portName("touchPort"),pick,()->ports("touchPort",false)); + item(tr("LED 포트","LED 端口"),()->portName("ledPort"),pick,()->ports("ledPort",false)); + flag(tr("다음에도 자동 연결","下次自动连接"),"autoConnect",true,tr("저장된 포트를 다음 실행에도 사용합니다","下次启动继续使用已保存端口"),v->owner.refresh()); + item(tr("USB 권한","USB 权限"),()->tr("장치 확인","查看设备"),pick,this::permissions); + item(tr("다시 연결","重新连接"),()->tr("연결 재시도","重试连接"),tr("현재 포트로 다시 연결합니다","使用当前端口重新连接"),owner::connect); + item(tr("연결 해제","断开连接"),()->tr("연결 끊기","断开"),tr("포트 선택은 저장된 상태로 유지됩니다","保留已保存的端口选择"),owner::disconnect); + item(tr("연결 상태","连接状态"),owner::connectionDescription,tr("눌러서 상세 정보를 확인하세요","点击查看详细信息"),this::details); + }else if(tab==1){ + item(tr("실시간 센서","实时传感器"),()->"34 TOUCH",tr("눌러서 원형 센서와 P1 입력 확인","点击检查圆形传感器与 P1 输入"),()->{LinearLayout body=column();body.addView(new SensorBoard(activity,owner.state),new LinearLayout.LayoutParams(-1,-2));sheet(tr("실시간 입력","实时输入"),body,tr("닫기","关闭"));}); + String[] modes={tr("사용 안 함","关闭"),"USB Keyboard","IO4 HID"}; + item(tr("버튼 입력 방식","按钮输入方式"),()->modes[Math.max(0,Math.min(2,prefs.getInt("buttonMode",2)))],pick,()->choose(tr("버튼 입력 방식","按钮输入方式"),modes,prefs.getInt("buttonMode",2),i->{prefs.edit().putInt("buttonMode",i).apply();owner.configurationChanged();rebuild();})); + if(prefs.getInt("buttonMode",2)==1){ + item(tr("버튼 등록","映射按钮"),()->"A1 – A8",tr("실제 버튼을 눌러 키를 등록합니다","按实体按钮录入按键"),this::learn); + item(tr("P1 버튼 등록","映射 P1 按钮"),()->keyName(owner.keyboard.map[8]),pick,()->learnKey(8)); + }else if(prefs.getInt("buttonMode",2)==2){ + item("IO4 HID",()->portName("hidPort"),pick,()->ports("hidPort",true)); + item(tr("플레이어 뱅크","玩家分组"),()->prefs.getInt("bank",0)==0?"P1":"P2",pick,()->choose("IO4",new String[]{"P1","P2"},prefs.getInt("bank",0),i->{prefs.edit().putInt("bank",i).apply();owner.configurationChanged();})); + } + String[] actions={tr("정렬 · 검색","排序 · 搜索"),tr("시작 · 확인","开始 · 确认")}; + item(tr("P1 동작","P1 功能"),()->actions[prefs.getBoolean("p1Start",false)?1:0],pick,()->choose("P1 / START",actions,prefs.getBoolean("p1Start",false)?1:0,i->prefs.edit().putBoolean("p1Start",i==1).apply())); + String[] protocols={"Command · 115200","Mai2 Touch · 9600"}; + item(tr("터치 프로토콜","触摸协议"),()->protocols[prefs.getInt("touchMode",1)==1?1:0],pick,()->choose(tr("터치 프로토콜","触摸协议"),protocols,prefs.getInt("touchMode",1),i->{prefs.edit().putInt("touchMode",i).apply();owner.configurationChanged();})); + item(tr("터치 감도","触摸灵敏度"),()->tr("센서 선택","选择传感器"),tr("값을 읽고 저장해야 장치에 적용됩니다","读取数值后需点击保存才写入设备"),this::sensitivity); + }else if(tab==2){ + flag(tr("LED 연동","LED 联动"),"led",true,tr("게임의 조명 색상을 그대로 전달합니다","同步游戏的灯光颜色"),v->{owner.updateLedSettings();if(v)owner.startLeds();else owner.leds.stop();}); + flag(tr("상단 스피커 RGB","顶部扬声器 RGB"),"ceiling",true,tr("IO4 연결 · 게임 버튼 조명의 평균 색상을 표시합니다","连接 IO4 · 显示游戏按钮灯光的平均颜色"),v->owner.updateLedSettings()); + item(tr("스피커 조명 상태","扬声器灯光状态"),owner.ceiling::summary,tr("버튼 입력 방식을 IO4 HID로 연결하세요","请使用 IO4 HID 连接按钮输入"),()->flash(owner.ceiling.summary())); + item(tr("스피커 색상 테스트","扬声器颜色测试"),()->"R · G · B",tr("2초 후 게임 조명으로 복귀합니다","2 秒后恢复游戏灯光"),()->choose(tr("스피커 색상 테스트","扬声器颜色测试"),new String[]{tr("빨강","红色"),tr("초록","绿色"),tr("파랑","蓝色")},-1,i->{if(prefs.getBoolean("led",true)&&prefs.getBoolean("ceiling",true))owner.ceiling.test(new int[]{0xff0000,0x00ff00,0x0000ff}[i]);else flash(tr("LED 연결을 먼저 켜세요","请先连接并启用 LED"));})); + numberItem(tr("밝기","亮度"),"brightness",100,0,100,5,"%",adjust,owner::updateLedSettings); + numberItem(tr("LED 회전","LED 旋转"),"ledRotation",0,0,7,1,"",tr("버튼 단위로 이동 · 0 = 기본 방향","按按钮步进 · 0 = 默认方向"),owner::updateLedSettings); + flag(tr("순서 반전","反转顺序"),"ledReverse",false,tr("LED 순서를 반대로 바꿉니다","反转 LED 排列顺序"),v->owner.updateLedSettings()); + flag(tr("캐비닛 조명","机柜灯光"),"ring",false,tr("캐비닛 조명 출력을 연동합니다","同步机柜灯光输出"),v->owner.updateLedSettings()); + item(tr("색상 테스트","颜色测试"),()->"R · G · B",tr("2초 후 게임 조명으로 복귀합니다","2 秒后恢复游戏灯光"),()->choose(tr("색상 테스트","颜色测试"),new String[]{tr("빨강","红色"),tr("초록","绿色"),tr("파랑","蓝色")},-1,i->{if(owner.leds.running())owner.leds.test(new int[]{0xff0000,0x00ff00,0x0000ff}[i]);else flash(tr("LED 연결을 먼저 켜세요","请先连接并启用 LED"));})); + item(tr("LED 포트","LED 端口"),()->portName("ledPort"),pick,()->ports("ledPort",false)); + item(tr("노드 주소","节点地址"),()->prefs.getInt("address",17)+" / "+prefs.getInt("base",0),pick,this::ledAddress); + }else{ + flag(tr("외부 모니터 출력","外接屏幕输出"),"external",true,tr("폰에는 스탯 · 모니터에는 게임 출력","手机显示统计 · 显示器输出游戏"),v->owner.display.reconcile()); + item(tr("프레임 고정","固定帧率"),owner.display::frameRateDescription,tr("기본 60fps · 120fps는 지원되는 120Hz 모니터에서 사용","默认 60fps · 120fps 需支持 120Hz 的显示器"),()->owner.display.chooseFrameRate(this)); + item(tr("폰 대시보드 · 위젯 편집","手机仪表盘 · 编辑小组件"),()->tr("8종 위젯 · 이동 · 크기 · 저장","8 种小组件 · 移动 · 大小 · 保存"),tr("모니터 없이도 배치를 편집할 수 있습니다","无需显示器也可编辑布局"),owner.display::previewDashboard); + item(tr("출력 장치","输出设备"),owner.display::deviceName,pick,()->owner.display.chooseMonitor(this)); + flag(tr("역방향 세로","反向竖屏"),"displayReverse",false,tr("OFF = 90° · ON = 270°","OFF = 90° · ON = 270°"),v->owner.display.reconcile()); + item(tr("게임 해상도","游戏分辨率"),()->"1080 × 1920",tr("세로 9:16 → 가로 16:9 화면 채우기","竖屏 9:16 → 铺满横屏 16:9"),()->{LinearLayout body=column();body.addView(new DisplayPreview(activity,()->prefs.getBoolean("displayReverse",false)),new LinearLayout.LayoutParams(-1,-2));text(body,owner.display.outputDescription());sheet(tr("외부 출력","外接输出"),body,tr("닫기","关闭"));}); + item(tr("출력 상태","输出状态"),owner.display::outputDescription,tr("눌러서 해상도와 주사율 확인","点击查看分辨率与刷新率"),()->info(tr("출력 정보","输出信息"),owner.display.outputDescription()+"\n\n"+tr("HDMI 해상도와 주사율은 폰·허브·모니터의 지원 모드를 사용합니다.","HDMI 分辨率和刷新率取决于手机、扩展坞和显示器支持的模式。"))); + } + } + String portName(String key){ + String id=prefs.getString(key,"");if(id.isEmpty())return tr("선택 안 됨","尚未选择"); + for(UsbIo.Port p:owner.ports)if(PortSelection.samePort(id,OniimaiController.portId(p)))return (p.hid?"IO4 HID":p.name)+" · IF"+p.controlId; + return prefs.getString(key+"Name",tr("저장된 포트","已保存端口"))+" · "+tr("연결 대기","等待连接"); + } + void permissions(){ + owner.refresh();List devices=new ArrayList<>(owner.usb.getDeviceList().values()); + if(devices.isEmpty()){info(tr("USB 장치를 찾지 못했습니다","未找到 USB 设备"),tr("컨트롤러와 OTG 허브를 연결한 뒤 다시 시도하세요.","请连接控制器和 OTG 扩展坞后重试。"));return;} + String[] labels=new String[devices.size()];for(int i=0;i{owner.permission(devices.get(i));owner.refresh();}); + } + void ports(String key,boolean hid){ + List options=new ArrayList<>();for(UsbIo.Port p:owner.ports)if(p.hid==hid)options.add(p); + String[] labels=new String[options.size()+1];labels[0]=tr("사용 안 함","关闭");int selected=0; + for(int i=0;i{owner.selectPort(key,i==0?null:options.get(i-1));rebuild();}); + } + private String keyName(int code){return code<0?tr("등록 안 됨","未映射"):KeyEvent.keyCodeToString(code).replace("KEYCODE_","");} + private void learn(){String[] labels=new String[8];synchronized(owner.keyboard){for(int i=0;i<8;i++)labels[i]="A"+(i+1)+" · "+keyName(owner.keyboard.map[i]);}choose(tr("등록할 버튼","要映射的按钮"),labels,-1,this::learnKey);} + private void learnKey(int index){ + owner.learning=index;LinearLayout body=column();TextView symbol=GameUi.text(activity,index==8?"P1":"A"+(index+1),44,GameUi.BLUE,true);symbol.setGravity(Gravity.CENTER);symbol.setPadding(0,dp(24),0,dp(24));body.addView(symbol); + text(body,tr("컨트롤러의 해당 버튼을 한 번 누르세요.","请按一次控制器上的对应按钮。"));text(body,tr("등록 후 자동 저장됩니다. 게임에는 입력되지 않습니다.","映射后自动保存。不会向游戏发送此输入。")); + Dialog d=sheet(tr("버튼 등록","映射按钮"),body,tr("취소","取消"));learningDialog=d;d.setOnCancelListener(which->owner.learning=-1); + } + private void sensitivity(){ + String[] zones=Arrays.copyOf(Protocol.ZONES,35);zones[34]=tr("전체 34개 센서","全部 34 个传感器"); + choose(tr("감도를 읽을 센서","选择要读取的传感器"),zones,-1,zone->{final int requestedEpoch=epoch;flash(tr("컨트롤러에서 감도를 읽는 중…","正在读取控制器灵敏度…"));owner.sensitivity(zone,config->{ + if(dialog==null||epoch!=requestedEpoch)return;LinearLayout body=column();text(body,zones[zone]);draftNotice(body);int index=Math.min(zone,33); + if(zone==34)text(body,tr("초기값은 E8 기준입니다. 저장하면 모든 센서에 같은 값이 적용됩니다.","初始值来自 E8。保存后所有传感器将使用相同数值。")); + EditText finger=numberDraft(body,"sensitivity."+zone+".finger","Finger",config.finger(index),0,512),noise=numberDraft(body,"sensitivity."+zone+".noise","Noise",config.noise(index),0,255),hyst=numberDraft(body,"sensitivity."+zone+".hyst","Hysteresis",config.hysteresis(index),0,128); + final Dialog[] holder=new Dialog[1];button(body,tr("컨트롤러에 저장","保存到控制器"),()->{try{int f=GameUi.readNumber(finger,0,512),n=GameUi.readNumber(noise,0,255),h=GameUi.readNumber(hyst,0,128);if(owner.sensitivityBusy())return;owner.saveSensitivity(zone,f,n,h,()->clearDraft("sensitivity."+zone+"."));holder[0].dismiss();flash(tr("저장 후 장치 값을 검증합니다…","保存后将验证设备数值…"));}catch(NumberFormatException ignored){}},true); + holder[0]=sheet(tr("터치 감도","触摸灵敏度"),body,tr("닫기","关闭")); + });}); + } + private void ledAddress(){ + LinearLayout body=column();draftNotice(body);text(body,tr("기본값: 노드 17 · 시작 번호 0","默认值:节点 17 · 起始编号 0")); + EditText address=numberDraft(body,"led.address",tr("노드 주소","节点地址"),prefs.getInt("address",17),1,255),base=numberDraft(body,"led.base",tr("시작 번호","起始编号"),prefs.getInt("base",0),0,24); + final Dialog[] holder=new Dialog[1];button(body,tr("저장하고 다시 연결","保存并重新连接"),()->{try{int a=GameUi.readNumber(address,1,255),b=GameUi.readNumber(base,0,24);owner.leds.stop();prefs.edit().putInt("address",a).putInt("base",b).apply();if(prefs.getBoolean("led",true))owner.startLeds();clearDraft("led.");holder[0].dismiss();rebuild();}catch(NumberFormatException ignored){}},true); + holder[0]=sheet(tr("LED 주소","LED 地址"),body,tr("닫기","关闭")); + } + private void draftNotice(LinearLayout body){text(body,tr("작성 중인 값은 유지됩니다. 저장을 눌러야 장치에 적용됩니다.","保留未保存的输入。点击保存后才应用到设备。"));} + private EditText numberDraft(LinearLayout body,String key,String label,int value,int min,int max){ + EditText input=GameUi.number(body,label,value,min,max);String saved=prefs.getString("draft:"+key,null);if(saved!=null)input.setText(saved); + input.addTextChangedListener(new android.text.TextWatcher(){public void beforeTextChanged(CharSequence s,int start,int count,int after){}public void onTextChanged(CharSequence s,int start,int before,int count){}public void afterTextChanged(android.text.Editable text){prefs.edit().putString("draft:"+key,text.toString()).apply();}}); + return input; + } + private void clearDraft(String prefix){SharedPreferences.Editor edit=prefs.edit();for(String key:prefs.getAll().keySet())if(key.startsWith("draft:"+prefix))edit.remove(key);edit.apply();} + private void details(){LinearLayout body=column();TextView detail=text(body,UiText.message(owner.status)+"\n\n"+owner.leds.diagnostic()+"\n"+owner.ceiling.diagnostic());detail.setTextIsSelectable(true);sheet(tr("연결 상세 정보","连接详细信息"),body,tr("닫기","关闭"));} + void info(String title,String message){LinearLayout body=column();text(body,message);sheet(title,body,tr("확인","确定"));} + interface Pick{void accept(int value);}interface Toggle{void accept(boolean value);}interface Value{String get();} + void choose(String title,String[] labels,int selected,Pick action){ + if(labels.length==0){info(title,tr("연결된 장치가 없습니다. 연결 후 다시 확인하세요.","没有已连接设备,请连接后重试。"));return;} + LinearLayout list=column();final Dialog[] holder=new Dialog[1]; + for(int i=0;i{if(holder[0]==null||!holder[0].isShowing())return;holder[0].dismiss();action.accept(index);});option.setGravity(Gravity.CENTER_VERTICAL);option.setTextSize(14);option.setTextColor(i==selected?GameUi.BLUE:GameUi.INK);option.setBackground(GameUi.ripple(activity,i==selected?GameUi.SLATE:0xeeffffff,24));option.setTextColor(i==selected?GameUi.GOLD:GameUi.INK);list.addView(option,new LinearLayout.LayoutParams(-1,-2));GameUi.space(list,4);} + holder[0]=sheet(title,list,tr("취소","取消")); + } + private Dialog sheet(String title,LinearLayout contents,String dismissLabel){ + LinearLayout root=column();root.setPadding(dp(22),dp(22),dp(22),dp(18));GameUi.surface(root); + TextView heading=GameUi.text(activity,title,21,GameUi.INK,true);root.addView(heading);GameUi.space(root,16); + ScrollView sc=new ScrollView(activity);sc.setFillViewport(false);sc.addView(contents);root.addView(sc,new LinearLayout.LayoutParams(-1,0,1));GameUi.space(root,12); + Dialog d=new Dialog(activity,android.R.style.Theme_Material_Light_NoActionBar);TextView dismiss=GameUi.action(activity,dismissLabel,false,d::dismiss);root.addView(dismiss);d.setContentView(root); + protect(d,()->{if(learningDialog==d){owner.learning=-1;learningDialog=null;}});show(d,false); + int width=Math.min(activity.getResources().getDisplayMetrics().widthPixels-dp(24),dp(540)); + contents.measure(View.MeasureSpec.makeMeasureSpec(width-dp(44),View.MeasureSpec.EXACTLY),View.MeasureSpec.makeMeasureSpec(0,View.MeasureSpec.UNSPECIFIED)); + int exact=View.MeasureSpec.makeMeasureSpec(width-dp(44),View.MeasureSpec.EXACTLY),free=View.MeasureSpec.makeMeasureSpec(0,View.MeasureSpec.UNSPECIFIED); + heading.measure(exact,free);dismiss.measure(exact,free); + int chrome=dp(68)+heading.getMeasuredHeight()+dismiss.getMeasuredHeight(); + int height=Math.max(dp(200),Math.min(Math.min(activity.getResources().getDisplayMetrics().heightPixels-dp(48),dp(760)),contents.getMeasuredHeight()+chrome)); + d.getWindow().setLayout(width,height);GameUi.appear(root);return d; + } + void show(Dialog d,boolean full){ + Window w=d.getWindow();w.setBackgroundDrawable(new ColorDrawable(Color.TRANSPARENT));w.addFlags(WindowManager.LayoutParams.FLAG_DIM_BEHIND);w.setDimAmount(.38f);w.setSoftInputMode(WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE); + w.getDecorView().setSystemUiVisibility(5894);d.show();int width=activity.getResources().getDisplayMetrics().widthPixels,height=activity.getResources().getDisplayMetrics().heightPixels; + w.setLayout(full?WindowManager.LayoutParams.MATCH_PARENT:Math.min(width-dp(24),dp(540)),full?WindowManager.LayoutParams.MATCH_PARENT:Math.min(height-dp(48),dp(760))); + } + void protect(Dialog d,Runnable dismissed){ + openDialogs.add(d);dialogs++;owner.state.panel(true);d.setOnKeyListener((which,code,event)->OniimaiController.handleKey(event)); + d.setOnDismissListener(which->{if(!openDialogs.remove(d))return;dialogs--;if(dismissed!=null)dismissed.run();owner.state.panel(dialogs>0);}); + } + LinearLayout column(){return GameUi.column(activity);} + TextView text(LinearLayout body,String value){TextView t=GameUi.text(activity,value,13,GameUi.MUTED,false);t.setPadding(0,dp(6),0,dp(10));body.addView(t);return t;} + void button(LinearLayout body,String label,Runnable action){button(body,label,action,false);} + void button(LinearLayout body,String label,Runnable action,boolean primary){body.addView(GameUi.action(activity,label,primary,action),new LinearLayout.LayoutParams(-1,-2));} + private void pair(LinearLayout body,String a,Runnable aa,String b,Runnable bb){LinearLayout row=new LinearLayout(activity);row.addView(GameUi.action(activity,a,false,aa),new LinearLayout.LayoutParams(0,-2,1));LinearLayout.LayoutParams lp=new LinearLayout.LayoutParams(0,-2,1);lp.leftMargin=dp(8);row.addView(GameUi.action(activity,b,false,bb),lp);body.addView(row);} + void toggle(LinearLayout body,String label,String key,boolean fallback,Toggle action){GameUi.toggle(body,label,null,prefs.getBoolean(key,fallback),(v,checked)->{prefs.edit().putBoolean(key,checked).apply();action.accept(checked);});} + void watchRow(LinearLayout body,String label,Value value,Runnable action){TextView detail=GameUi.row(body,label,value.get(),action);bindings.add(()->GameUi.setText(detail,value.get()));} + void slider(LinearLayout body,String label,String key,int fallback,int min,int max,Runnable action){ + int value=Math.max(min,Math.min(max,prefs.getInt(key,fallback)));String suffix=key.equals("brightness")?"%":""; + TextView title=GameUi.text(activity,label+" "+value+suffix,14,GameUi.INK,true);title.setPadding(0,dp(10),0,0);body.addView(title); + SeekBar seek=new SeekBar(new ContextThemeWrapper(activity,android.R.style.Theme_Material_Light_NoActionBar));seek.setProgressTintList(android.content.res.ColorStateList.valueOf(GameUi.BLUE));seek.setThumbTintList(android.content.res.ColorStateList.valueOf(GameUi.BLUE));seek.setMax(max-min);seek.setProgress(value-min);seek.setContentDescription(label);body.addView(seek,new LinearLayout.LayoutParams(-1,dp(48))); + seek.setOnSeekBarChangeListener(new SeekBar.OnSeekBarChangeListener(){public void onProgressChanged(SeekBar s,int p,boolean user){title.setText(label+" "+(p+min)+suffix);if(user){prefs.edit().putInt(key,p+min).apply();action.run();}}public void onStartTrackingTouch(SeekBar s){}public void onStopTrackingTouch(SeekBar s){}}); + } + private void flash(String message){GameUi.setText(notice,message);Toast.makeText(activity,message,Toast.LENGTH_SHORT).show();} + int dp(int n){return GameUi.dp(activity,n);} + void destroy(){closeAll();if(entry!=null&&entry.getParent() instanceof ViewGroup)((ViewGroup)entry.getParent()).removeView(entry);} + private void closeAll(){for(Dialog d:new ArrayList<>(openDialogs))d.dismiss();} +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiPanel.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiPanel.java.meta new file mode 100644 index 000000000..692fbd38b --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiPanel.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: 0251944d4ec544aca048616cca89adb6 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OutputMode.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OutputMode.java new file mode 100644 index 000000000..277ef392a --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OutputMode.java @@ -0,0 +1,30 @@ +package net.majdata.majdataplay.oniimai; + +/** Picks an advertised monitor mode; a requested mode is not proof of activation. */ +final class OutputMode { + static int frameRate(int saved) { return saved == 120 ? 120 : 60; } + static boolean matches(float actual, int requested) { + return Float.isFinite(actual) && Math.abs(actual - requested) < 1f; + } + static final class Mode { + final int id, width, height; + final float rate; + Mode(int id, int width, int height, float rate) { + this.id=id; this.width=width; this.height=height; this.rate=rate; + } + long pixels() { return (long)width*height; } + } + static Mode choose(Mode current, Mode[] modes, int requested) { + Mode best=null; + for (Mode mode:modes) { + if (!matches(mode.rate,requested) || mode.width<=0 || mode.height<=0) continue; + // Preserve the monitor aspect ratio, including portrait-oriented EDIDs. + if (Math.abs((double)mode.width/mode.height-(double)current.width/current.height)>.03) continue; + if (mode.id==current.id) return mode; + boolean same=mode.width==current.width && mode.height==current.height; + boolean bestSame=best!=null && best.width==current.width && best.height==current.height; + if (best==null || (same&&!bestSame) || (same==bestSame&&mode.pixels()>best.pixels())) best=mode; + } + return best; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OutputMode.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OutputMode.java.meta new file mode 100644 index 000000000..bc81e958c --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OutputMode.java.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e992ed82818332a40bba5e4e82ba52f6 \ No newline at end of file diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PhoneInputCard.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PhoneInputCard.java new file mode 100644 index 000000000..535f89809 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PhoneInputCard.java @@ -0,0 +1,38 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.os.SystemClock; +import android.view.*; +import android.widget.*; + +/** Read-only physical input card; native labels remain legible with larger system text. */ +final class PhoneInputCard extends LinearLayout { + private final InputSnapshot input; + private final TextView touch,buttons,p1,active; + private long lastTouch=-1,lastButtons=-1,lastFlags=-1; + private final Runnable refresh=new Runnable(){public void run(){ + long[] f=input.diagnostic(SystemClock.uptimeMillis()); + if(f[0]!=lastButtons||f[1]!=lastTouch||f[3]!=lastFlags){ + lastButtons=f[0];lastTouch=f[1];lastFlags=f[3]; + GameUi.setText(touch,GameUi.tr("터치","触摸")+" "+Long.bitCount(f[1])+"/34"); + GameUi.setText(buttons,GameUi.tr("버튼","按钮")+" "+Long.bitCount(f[0]&255)+"/8"); + boolean down=(f[0]&256)!=0;GameUi.setText(p1,"P1 "+(down?"ON":"OFF"));p1.setTextColor(down?GameUi.BLUE:GameUi.MUTED); + StringBuilder names=new StringBuilder();for(int i=0;i<34;i++)if((f[1]&(1L<0)names.append(" · ");names.append(Protocol.ZONES[i]);} + for(int i=0;i<9;i++)if((f[0]&(1<0)names.append(" · ");names.append(i==8?"P1":GameUi.tr("버튼 ","按钮 ")+(i+1));} + GameUi.setText(active,names.length()>0?names.toString():(f[3]&3)==0?GameUi.tr("컨트롤러 연결을 기다리고 있어요","正在等待控制器连接"):GameUi.tr("센서를 터치하거나 버튼을 눌러보세요","触摸传感器或按下按钮进行检查")); + } + postDelayed(this,80); + }}; + PhoneInputCard(Context c,InputSnapshot input){ + super(c);this.input=input;setOrientation(VERTICAL);setPadding(dp(18),dp(20),dp(18),dp(18));GameUi.surface(this); + addView(GameUi.text(c,GameUi.tr("컨트롤러 입력","控制器输入"),20,GameUi.INK,true));GameUi.space(this,5); + addView(GameUi.text(c,GameUi.tr("터치 34개 · 버튼 8개 · P1","34 个触摸区 · 8 个按钮 · P1"),12,GameUi.MUTED,false));GameUi.space(this,12); + SensorBoard board=new SensorBoard(c,input).withoutFooter();board.setImportantForAccessibility(IMPORTANT_FOR_ACCESSIBILITY_NO);addView(board,new LayoutParams(-1,-2));GameUi.space(this,10); + LinearLayout counts=new LinearLayout(c);touch=chip(counts,"");buttons=chip(counts,"");p1=chip(counts,"");addView(counts);GameUi.space(this,12); + active=GameUi.text(c,"",13,GameUi.MUTED,false);active.setGravity(Gravity.CENTER);active.setMinLines(2);active.setMaxLines(3);active.setEllipsize(android.text.TextUtils.TruncateAt.END);addView(active); + } + private TextView chip(LinearLayout parent,String label){TextView t=GameUi.text(getContext(),label,13,GameUi.INK,true);t.setGravity(Gravity.CENTER);t.setPadding(dp(3),dp(10),dp(3),dp(10));t.setBackground(GameUi.shape(getContext(),GameUi.PAGE,12));LayoutParams lp=new LayoutParams(0,-2,1);if(parent.getChildCount()>0)lp.leftMargin=dp(6);parent.addView(t,lp);return t;} + private int dp(int n){return GameUi.dp(getContext(),n);} + @Override protected void onAttachedToWindow(){super.onAttachedToWindow();post(refresh);} + @Override protected void onDetachedFromWindow(){removeCallbacks(refresh);super.onDetachedFromWindow();} +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PhoneInputCard.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PhoneInputCard.java.meta new file mode 100644 index 000000000..bdbcfa47e --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PhoneInputCard.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: a1e2551782e5417782e9d1cd1c4ce7bf +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PortSelection.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PortSelection.java new file mode 100644 index 000000000..5c02c6823 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PortSelection.java @@ -0,0 +1,46 @@ +package net.majdata.majdataplay.oniimai; + +import java.util.Locale; + +/** Matches persistent identities and named firmware interfaces, never USB bus addresses. */ +final class PortSelection { + static final int NONE=0, COMMAND=1, TOUCH=2, LED=3, IO4=4; + static int role(String name, boolean hid) { + if (name == null) return NONE; + String n=name.trim().toLowerCase(Locale.ROOT); + // Actual onii-mai IF0 uses the SEGA IO4 board identity; IF1 is a separate keyboard. + if(hid && n.startsWith("i/o control bd;15257;"))return IO4; + if (!n.startsWith("onii-mai ") && !n.startsWith("oniimai ")) return NONE; + if(hid)return n.endsWith(" io4")||n.endsWith(" io4 hid")?IO4:NONE; + if (n.endsWith(" command")) return COMMAND; + if (n.endsWith(" touch")) return TOUCH; + if (n.endsWith(" led")) return LED; + return NONE; + } + static String base(String id) { + int a=id.indexOf(':'), b=id.indexOf(':',a+1), c=id.indexOf(':',b+1); + return a<0 || b<0 || c<0 ? "" : id.substring(0,c+1); + } + static boolean samePort(String saved, String current) { + return !saved.isEmpty() && (saved.equals(current) || + (saved.equals(base(saved)) && saved.equals(base(current)))); + } + static int unique(String[] ids, String saved) { + int found=-1; + for(int i=0;i=0) return -2; + found=i; + } + return found; + } + static int named(String[] names, boolean[] hid, int role) { + int found=-1; + for(int i=0;i=0) return -2; + found=i; + } + return found; + } + static int automaticTouch(String[] names,boolean[] hid){return named(names,hid,TOUCH);} + static int protocol(String name,boolean hid,int fallback){int role=role(name,hid);return role==TOUCH?1:role==COMMAND?0:fallback;} +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PortSelection.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PortSelection.java.meta new file mode 100644 index 000000000..adb8b0d62 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/PortSelection.java.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 42d6db24825c4d138fb8791a8368c10b +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Protocol.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Protocol.java new file mode 100644 index 000000000..41ca95a29 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Protocol.java @@ -0,0 +1,138 @@ +package net.majdata.majdataplay.oniimai; + +import java.io.ByteArrayOutputStream; +import java.util.Arrays; + +/** Wire formats recovered from the supplied Assistant 1.0.1; no original app code is bundled. */ +public final class Protocol { + private Protocol() {} + public static final String[] ZONES = {"A1","A2","A3","A4","A5","A6","A7","A8","B1","B2","B3","B4","B5","B6","B7","B8","C1","C2","D1","D2","D3","D4","D5","D6","D7","D8","E1","E2","E3","E4","E5","E6","E7","E8"}; + public static final int INFO=0, DEBUG_START=0x10, DEBUG_STOP=0x11, DEBUG_DATA=0x12, + TOUCH_RESTART=0x13, CONFIG_GET=0x20, CONFIG_SET=0x21, CONFIG_SAVE=0x23; + public static int u(byte b) { return b & 255; } + public static int u16(byte[] b,int o) { return u(b[o]) | u(b[o+1])<<8; } + public static long u32(byte[] b,int o) { return (long)u16(b,o) | (long)u16(b,o+2)<<16; } + public static void range(int value,int min,int max) { + if(valuemax) throw new IllegalArgumentException(UiText.t("범위: ")+min+"–"+max); + } + public static byte[] command(int cmd, byte[] payload) { + range(cmd,0,255); range(payload.length,0,255); + ByteArrayOutputStream out=new ByteArrayOutputStream(); out.write(0x53); + escaped(out,cmd,0x53,0x7c,false); escaped(out,payload.length,0x53,0x7c,false); + for(byte b:payload) escaped(out,u(b),0x53,0x7c,false); + return out.toByteArray(); + } + private static void escaped(ByteArrayOutputStream out,int b,int sync,int esc,boolean decrement) { + if(b==sync || b==esc) { out.write(esc); if(decrement) b=(b-1)&255; } + out.write(b); + } + public interface Reply { void accept(int command,int status,byte[] payload); } + public static final class CommandParser { + private final byte[] buffer=new byte[258]; private int size; private boolean active,escape; + private final Reply listener; + public CommandParser(Reply listener) { this.listener=listener; } + public void feed(byte[] bytes,int length) { + for(int i=0;i=buffer.length) { active=false;size=0;continue; } + buffer[size++]=(byte)b; + if(size>=3 && size==3+u(buffer[2])) { + byte[] data=Arrays.copyOfRange(buffer,3,size); + int cmd=u(buffer[0]),status=u(buffer[1]);active=false;size=0; + listener.accept(cmd,status,data); + } + } + } + } + public static final class TouchDebug { + public final boolean[] pressed=new boolean[34]; + public final int[] raw=new int[34],baseline=new int[34]; + public TouchDebug(byte[] b) { + if(b.length!=144) throw new IllegalArgumentException(UiText.t("터치 디버그 길이: ")+b.length+UiText.t(" (144 필요)")); + for(int i=0;i<34;i++) { + pressed[i]=(u(b[i/8])&(1<<(i%8)))!=0; + raw[i]=u16(b,8+2*i);baseline[i]=u16(b,76+2*i); + } + } + } + public interface TouchListener { void accept(boolean[] pressed); } + public static final class TouchParser { + private final byte[] body=new byte[7]; private int size=-1; private final TouchListener listener; + public TouchParser(TouchListener listener) { this.listener=listener; } + public void feed(byte[] bytes,int length) { + for(int i=0;i=0) {if(size<7 && b<=31) body[size++]=(byte)b;else size=-1;} + } + } + } + public static final class Config { + private final byte[] bytes; + public Config(byte[] bytes) { + if(bytes.length!=156) throw new IllegalArgumentException(UiText.t("설정 형식이 다릅니다: ")+bytes.length+UiText.t(" bytes. 쓰기를 중단합니다.")); + this.bytes=bytes.clone(); + } + public int finger(int zone) {range(zone,0,33);return u16(bytes,zone*4);} + public int noise(int zone) {range(zone,0,33);return u(bytes[zone*4+2]);} + public int hysteresis(int zone) {range(zone,0,33);return u(bytes[zone*4+3]);} + public void sensitivity(int zone,int finger,int noise,int hysteresis) { + range(zone,0,33);range(finger,0,512);range(noise,0,255);range(hysteresis,0,128); + int i=zone*4;bytes[i]=(byte)finger;bytes[i+1]=(byte)(finger>>8);bytes[i+2]=(byte)noise;bytes[i+3]=(byte)hysteresis; + } + public int get(int offset) {return u(bytes[offset]);} + public void brightness(int front,int external) {range(front,0,255);range(external,0,255);bytes[139]=(byte)external;bytes[140]=(byte)front;} + public byte[] bytes() {return bytes.clone();} + } + /** Mai2LED / BD15070-04: corroborated by onii.cc and Sucareto/Mai2Touch. */ + public static byte[] led(int destination,int cmd,byte[] payload) { + range(destination,1,255);range(payload.length,0,254); + ByteArrayOutputStream out=new ByteArrayOutputStream();out.write(0xe0); + byte[] body=new byte[4+payload.length];body[0]=(byte)destination;body[1]=1;body[2]=(byte)(payload.length+1);body[3]=(byte)cmd; + System.arraycopy(payload,0,body,4,payload.length);int sum=0; + for(byte b:body) {sum=(sum+u(b))&255;escaped(out,u(b),0xe0,0xd0,true);} + escaped(out,sum,0xe0,0xd0,true);return out.toByteArray(); + } + public interface LedReply {void accept(int source,int command,int status,int report,byte[] payload);} + public static final class LedParser { + private final byte[] buf=new byte[260]; private int n;private boolean active,escape;private final LedReply listener; + public LedParser(LedReply listener) {this.listener=listener;} + public void feed(byte[] bytes,int length) { + for(int i=0;i=buf.length) {active=false;continue;} + buf[n++]=(byte)b; + if(n>=3 && n==u(buf[2])+4) { + active=false;int sum=0;for(int j=0;j=7 && sum==u(buf[n-1]) && u(buf[0])==1) + listener.accept(u(buf[1]),u(buf[4]),u(buf[3]),u(buf[5]),Arrays.copyOfRange(buf,6,n-1)); + } + } + } + } + public static String hex(byte[] b) {StringBuilder s=new StringBuilder();for(byte v:b)s.append(String.format("%02X",u(v)));return s.toString();} + public static boolean[] io4(byte[] report,int player) { + // SEGA IO4 report ID 1, 63-byte payload. Button banks begin at payload offset 28. + if(report.length!=64 || u(report[0])!=1) throw new IllegalArgumentException("IO4 report ID/length mismatch"); + range(player,0,1);int word=u16(report,29+player*2);int[] bit={2,3,0,15,14,13,12,11,9,6,1}; + boolean[] states=new boolean[11];for(int i=0;i<11;i++)states[i]=((word&(1<=8);return states; + } + public static boolean io4P1(byte[] report) { + if(report.length!=64 || u(report[0])!=1) throw new IllegalArgumentException("IO4 report ID/length mismatch"); + // MajdataPlay GeneralHIDDevice: payload byte 28, bit 1, active high. + // P1 is a function switch independent of the selected ring-button bank. + return (u(report[29]) & 2) != 0; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Protocol.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Protocol.java.meta new file mode 100644 index 000000000..21364be4b --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/Protocol.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: c2484c0f25124171a689a8d4e494f30d +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorBoard.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorBoard.java new file mode 100644 index 000000000..dec9f58f1 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorBoard.java @@ -0,0 +1,124 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.graphics.*; +import android.os.SystemClock; +import android.view.View; + +/** Read-only physical input monitor. It never drains Unity's queued input edges. */ +final class SensorBoard extends View { + private final InputSnapshot input; + private final Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG); + private final Path[] paths = new Path[34]; + private final Path[] buttonPaths = new Path[8]; + private final float[] labelX = new float[34], labelY = new float[34]; + private long touches = -1, flags; + private int buttons = -1; + private boolean footer=true; + private boolean gameTheme; + private boolean compact; + SensorBoard withoutFooter(){footer=false;requestLayout();return this;} + SensorBoard gameTheme(){gameTheme=true;setBackgroundColor(Color.TRANSPARENT);invalidate();return this;} + SensorBoard compact(boolean value){compact=value;invalidate();return this;} + private final Runnable refresh = new Runnable() { + public void run() { + long[] frame=input.diagnostic(SystemClock.uptimeMillis()); + if(buttons!=(int)frame[0] || touches!=frame[1] || flags!=frame[3]) { + buttons=(int)frame[0]; touches=frame[1]; flags=frame[3]; + setContentDescription("Touch " + Long.bitCount(touches) + "/34; Buttons " + Integer.bitCount(buttons&255) + "/8; P1 " + ((buttons&256)!=0)); + postInvalidateOnAnimation(); + } + postDelayed(this,33); + } + }; + SensorBoard(Context context,InputSnapshot input) { + super(context); this.input=input; + setBackgroundColor(Color.WHITE); + for(int i=0;i<8;i++)buttonPaths[i]=sector(161,188,i*45-88,41); + for(int i=0;i<34;i++) { + String name=Protocol.ZONES[i]; + if(i==16 || i==17) { + // The actual centre electrode is an octagon split vertically. + float side=i==16?1:-1; + paths[i]=polygon(new float[]{side,-29,12*side,-29,29*side,-12,29*side,12,12*side,29,side,29}); + labelX[i]=side*14; labelY[i]=0; + } else { + char group=name.charAt(0); int n=name.charAt(1)-'1'; + float angle=n*45+(group=='A'||group=='B'?22.5f:0)-90; + float radius=group=='A'?127:group=='B'?61:group=='D'?131:90; + labelX[i]=x(radius,angle); labelY[i]=y(radius,angle); + if(group=='B') paths[i]=regular(labelX[i],labelY[i],23,8,22.5f); + else if(group=='E') paths[i]=regular(labelX[i],labelY[i],21,4,angle); + else if(group=='D') { + // D's inner edge follows the neighbouring E diamond, as in the supplied diagram. + Path p=new Path();p.moveTo(x(154,-97.2f),y(154,-97.2f)); + p.arcTo(new RectF(-154,-154,154,154),-97.2f,14.4f); + p.lineTo(11.8f,-101);p.lineTo(0,-113);p.lineTo(-11.8f,-101);p.close(); + Matrix rotation=new Matrix();rotation.setRotate(n*45);p.transform(rotation);paths[i]=p; + } + else { + // Trace A1 from the user's 980px reference (also Sprites/Default_Background.png). + // The stepped inner edge borders E1's diamond and E2's square; it is not a wedge. + Path p=new Path();p.moveTo(x(154,-81.6f),y(154,-81.6f)); + p.arcTo(new RectF(-154,-154,154,154),-81.6f,28.2f); + p.lineTo(59.4f,-80.3f);p.lineTo(44.8f,-80.3f);p.lineTo(25.4f,-88.6f);p.lineTo(15.6f,-98.8f);p.close(); + Matrix rotation=new Matrix();rotation.setRotate(n*45);p.transform(rotation);paths[i]=p; + } + } + } + } + private static float x(float r,float angle) { return r*(float)Math.cos(Math.toRadians(angle)); } + private static float y(float r,float angle) { return r*(float)Math.sin(Math.toRadians(angle)); } + private static Path polygon(float[] points) { + Path p=new Path(); p.moveTo(points[0],points[1]); + for(int i=2;i=0 && (touches & (1L<=0 && (buttons & (1<=0&&(buttons&256)!=0)?"ON":"OFF"),0,209,paint); + paint.setTextSize(10); + canvas.drawText((flags&1)==0?GameUi.tr("터치 데이터 대기","等待触摸数据"):GameUi.tr("LIVE · 센서 상태는 읽기 전용입니다","LIVE · 传感器状态为只读"),0,226,paint); + } + canvas.restore(); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorBoard.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorBoard.java.meta new file mode 100644 index 000000000..1be29ec3c --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorBoard.java.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: 4e8952074cd745a6af2259c9a355fc3b +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorCabinet.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorCabinet.java new file mode 100644 index 000000000..8bb5ff96e --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorCabinet.java @@ -0,0 +1,30 @@ +package net.majdata.majdataplay.oniimai; + +import android.content.Context; +import android.graphics.*; +import android.view.*; +import android.widget.*; +import android.util.TypedValue; + +/** Photo-matched sensor layout inside the game's circular cabinet frame. */ +final class SensorCabinet extends FrameLayout { + private final SensorBoard board; + private final TextView title,settings,phone; + private final Paint paint=new Paint(Paint.ANTI_ALIAS_FLAG|Paint.FILTER_BITMAP_FLAG); + private final GameUi.Pattern pattern=new GameUi.Pattern(); + SensorCabinet(Context c,InputSnapshot state,Runnable open,Runnable returnToPhone){ + super(c);setWillNotDraw(false);pattern.setBounds(0,0,1080,1080); + board=new SensorBoard(c,state);board.setBackgroundColor(Color.TRANSPARENT);addView(board); + title=GameUi.text(c,GameUi.tr("컨트롤러 입력","控制器输入"),20,GameUi.INK,true);title.setGravity(Gravity.CENTER);addView(title); + settings=GameUi.action(c,GameUi.tr("설정 ›","设置 ›"),true,open);addView(settings); + phone=GameUi.action(c,GameUi.tr("↶ 폰 화면","↶ 手机画面"),true,returnToPhone);addView(phone); + } + @Override protected void onMeasure(int ws,int hs){int w=MeasureSpec.getSize(ws);setMeasuredDimension(w,w);float s=w/1080f; + measure(board,800,812,s);measure(title,630,70,s);measure(settings,270,100,s);measure(phone,270,100,s); + title.setTextSize(TypedValue.COMPLEX_UNIT_PX,42*s);settings.setTextSize(TypedValue.COMPLEX_UNIT_PX,32*s);phone.setTextSize(TypedValue.COMPLEX_UNIT_PX,32*s); + } + private void measure(View v,int w,int h,float s){v.measure(MeasureSpec.makeMeasureSpec(Math.round(w*s),MeasureSpec.EXACTLY),MeasureSpec.makeMeasureSpec(Math.round(h*s),MeasureSpec.EXACTLY));} + private void place(View v,int x,int y){float s=getWidth()/1080f;int l=Math.round(x*s),t=Math.round(y*s);v.layout(l,t,l+v.getMeasuredWidth(),t+v.getMeasuredHeight());} + @Override protected void onLayout(boolean changed,int l,int t,int r,int b){place(title,225,66);place(board,140,130);place(phone,208,945);place(settings,602,945);} + @Override protected void onDraw(Canvas c){c.save();c.scale(getWidth()/1080f,getHeight()/1080f);Path p=new Path();p.addCircle(540,540,539,Path.Direction.CW);c.clipPath(p);GameAssets.draw(c,getContext(),10,74,74,1006,1006,paint);c.restore();} +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorCabinet.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorCabinet.java.meta new file mode 100644 index 000000000..a1a7581ad --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SensorCabinet.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: 53585e7eb6cc4274aa3152de54d29751 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SetupDefaults.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SetupDefaults.java new file mode 100644 index 000000000..eb64c4a34 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SetupDefaults.java @@ -0,0 +1,18 @@ +package net.majdata.majdataplay.oniimai; + +import java.util.*; + +/** First-install defaults and upgrade migration, without overwriting explicit choices. */ +final class SetupDefaults { + static Map missing(Map saved){ + Map patch=new LinkedHashMap<>(); + if(!saved.containsKey("setupComplete"))patch.put("setupComplete",!saved.isEmpty()); + if(!saved.containsKey("touchMode"))patch.put("touchMode",1); + if(!saved.containsKey("buttonMode"))patch.put("buttonMode",2); + if(!saved.containsKey("led"))patch.put("led",true); + if(!saved.containsKey("external"))patch.put("external",true); + if(!saved.containsKey("frameRate"))patch.put("frameRate",60); + if(saved.isEmpty()){patch.put("input",true);patch.put("autoConnect",true);} + return patch; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SetupDefaults.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SetupDefaults.java.meta new file mode 100644 index 000000000..c2bdba57f --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/SetupDefaults.java.meta @@ -0,0 +1,34 @@ +fileFormatVersion: 2 +guid: 4421f24a8b924de49b7d41390021a884 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + + diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiText.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiText.java new file mode 100644 index 000000000..0afb1445e --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiText.java @@ -0,0 +1,20 @@ +package net.majdata.majdataplay.oniimai; + +/** Module-owned text only: no changes to the game's Resources or system locale. */ +final class UiText { + private static volatile String language="ko"; + static void language(String value){language="zh-Hans".equals(value)?"zh-Hans":"ko";} + static String language(){return language;} + static String message(String value){ + if(!"zh-Hans".equals(language))return value; + java.util.ArrayList keys=new java.util.ArrayList<>(UiTextCatalog.CHINESE.keySet()); + keys.sort((a,b)->Integer.compare(b.length(),a.length())); + for(String key:keys)value=value.replace(key,UiTextCatalog.CHINESE.get(key)); + return value; + } + static String t(String korean){ + if(!"zh-Hans".equals(language))return korean; + String translated=UiTextCatalog.CHINESE.get(korean); + return translated==null?korean:translated; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiText.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiText.java.meta new file mode 100644 index 000000000..2d14ae451 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiText.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: da93ced017c340419273618cf261bf97 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiTextCatalog.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiTextCatalog.java new file mode 100644 index 000000000..2c2f6e7f7 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiTextCatalog.java @@ -0,0 +1,82 @@ +package net.majdata.majdataplay.oniimai; + +// USB/protocol diagnostic translations shared with the in-game control panel. +final class UiTextCatalog { + static final java.util.Map CHINESE; + static { + java.util.Map text=new java.util.LinkedHashMap<>(); + text.put("LED 재연결 대기: ","LED 等待重连:"); + text.put("초 · ","秒 · "); + text.put("Command 포트가 여러 개입니다. 컨트롤러 하나만 연결하세요.","存在多个 Command 端口,请只连接一个控制器。"); + text.put("감도 설정용 Command 포트를 찾지 못했습니다.","找不到用于灵敏度设置的 Command 端口。"); + text.put("감도 읽는 중…","正在读取灵敏度…"); + text.put("감도를 읽었습니다","已读取灵敏度"); + text.put("감도 저장 중…","正在保存灵敏度…"); + text.put("연결됨 · 모든 버튼과 터치에서 손을 떼세요","已连接 · 请松开所有按钮和传感器"); + text.put("USB 연결 대기","等待 USB 连接"); + text.put("USB 권한 허용됨 · 저장된 포트 연결 중","已获 USB 授权 · 正在连接已保存端口"); + text.put("USB 권한이 없습니다","尚未获得 USB 授权"); + text.put("연결 해제됨","已断开连接"); + text.put("연결 중…","正在连接…"); + text.put("연결 실패: ","连接失败:"); + text.put("입력 연결 끊김: ","输入连接中断:"); + text.put("저장된 포트 연결 대기: ","等待已保存端口:"); + text.put("동일한 이름의 포트가 여러 개입니다. 직접 선택하세요.","存在多个同名端口,请手动选择。"); + text.put("동일한 USB 장치가 여러 개입니다. 하나만 연결하세요.","存在多个相同 USB 设备,请仅连接一个。"); + text.put("LED 포트를 선택하세요.","请选择 LED 端口。"); + text.put("터치와 LED는 서로 다른 포트여야 합니다.","触摸和 LED 必须使用不同端口。"); + text.put("Command 포트로 먼저 연결하세요.","请先连接 Command 端口。"); + text.put("감도 읽기 실패: ","读取灵敏度失败:"); + text.put("감도 저장 실패: ","保存灵敏度失败:"); + text.put("컨트롤러에 감도를 저장했습니다","已将灵敏度保存到控制器"); + text.put("장치의 감도 읽기 결과가 다릅니다. 저장을 중단합니다.","设备回读的灵敏度不一致,已停止保存。"); + text.put("장치 오류 상태: ","设备错误状态:"); + text.put("명령 연결이 닫혔습니다. 다시 연결하세요.","命令连接已关闭,请重新连接。"); + text.put("응답 시간 초과. 포트 선택을 확인한 뒤 다시 연결하세요.","响应超时。请确认所选端口后重新连接。"); + text.put("요청 취소","请求已取消"); + text.put("연결 해제","断开连接"); + text.put("LED 응답 오류 ","LED 响应错误 "); + text.put("LED 포트가 닫혔습니다. 다시 연결하세요.","LED 端口已关闭,请重新连接。"); + text.put("LED 응답 없음 — 포트·노드 주소를 확인하세요.","LED 无响应,请检查端口和节点地址。"); + text.put("LED 요청 취소","LED 请求已取消"); + text.put("LED 연결 종료","LED 连接已结束"); + text.put("LED 연결 대기","等待连接 LED"); + text.put("\n게임 LED ","\n游戏 LED "); + text.put("준비 8/8","就绪 8/8"); + text.put("상태 ","状态 "); + text.put(" · 버튼 신호 "," · 按钮信号 "); + text.put("/8 · 송신 ","/8 · 已发送 "); + text.put("LED 범위 오류","LED 参数超出范围"); + text.put("LED 보드 확인 중…","正在检查 LED 灯板…"); + text.put("지원하는 LED 보드 응답이 아닙니다.","此响应不是来自受支持的 LED 灯板。"); + text.put("앱 백그라운드 · LED 꺼짐","应用在后台 · LED 已关闭"); + text.put("색상 테스트 · 2초 후 게임 연동","颜色测试 · 2 秒后恢复游戏联动"); + text.put("LED 연동 중","LED 联动中"); + text.put("연결됨 · 게임 LED 신호 대기 (게임 조명 명령 대기)","已连接 · 等待游戏 LED 信号(灯光指令)"); + text.put("LED 중단: ","LED 已停止:"); + text.put("LED 연동 OFF","LED 联动 OFF"); + text.put("범위: ","范围:"); + text.put("터치 디버그 길이: ","触摸调试数据长度:"); + text.put(" (144 필요)"," (需要 144)"); + text.put("설정 형식이 다릅니다: ","设置格式不匹配:"); + text.put(" bytes. 쓰기를 중단합니다."," bytes。已停止写入。"); + text.put("USB 인터페이스를 찾을 수 없습니다: ","找不到 USB 接口:"); + text.put("시리얼 포트가 아닙니다.","这不是串口。"); + text.put("USB 권한을 먼저 허용하세요.","请先允许 USB 访问。"); + text.put("USB 열기 실패","无法打开 USB 设备"); + text.put("CDC 데이터 인터페이스가 아닙니다.","这不是 CDC 数据接口。"); + text.put("USB 포트 사용 중 또는 권한 오류","USB 端口被占用或授权错误"); + text.put("CDC bulk endpoint 없음","没有 CDC bulk 端点"); + text.put("115200/9600 8N1 설정 실패","115200/9600 8N1 设置失败"); + text.put("DTR/RTS 설정 실패","DTR/RTS 设置失败"); + text.put("포트가 닫혔습니다.","端口已关闭。"); + text.put("USB 전송 실패 (","USB 传输失败 ("); + text.put("HID USB 권한을 허용하세요.","请允许 HID USB 访问。"); + text.put("HID 열기 실패","无法打开 HID 设备"); + text.put("HID 인터페이스 사용 중","HID 接口被占用"); + text.put("HID interrupt endpoint 없음","没有 HID interrupt 端点"); + text.put("HID queue 실패","HID 请求入队失败"); + text.put("HID 연결 종료","HID 连接已结束"); + CHINESE=java.util.Collections.unmodifiableMap(text); + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiTextCatalog.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiTextCatalog.java.meta new file mode 100644 index 000000000..4bfd65b64 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UiTextCatalog.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: c8fd97cc3afb4046ac526af47be8aea5 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UsbIo.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UsbIo.java new file mode 100644 index 000000000..521edefa6 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UsbIo.java @@ -0,0 +1,221 @@ +package net.majdata.majdataplay.oniimai; + +import android.hardware.usb.*; +import java.io.IOException; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicBoolean; + +/** CDC-ACM implementation for the STM32 composite device, with independent handles per interface. */ +public final class UsbIo { + public interface Bytes {void accept(byte[] data,int length);} + public interface Failure {void accept(String message);} + public static final class Port { + public final UsbDevice device; public final int controlId;public final boolean hid; + public String name; + Port(UsbDevice d,UsbInterface i,String name) {device=d;controlId=i.getId();hid=i.getInterfaceClass()==3;this.name=name;} + public String key() {return device.getDeviceName()+"#"+controlId;} + } + public static List ports(UsbManager manager) { + List result=new ArrayList<>(); + for(UsbDevice d:manager.getDeviceList().values()) { + UsbDeviceConnection conn=manager.hasPermission(d)?manager.openDevice(d):null; + try { + Map names=conn==null?new HashMap<>():names(conn); + for(int n=0;n names(UsbDeviceConnection conn) { + Map result=new HashMap<>();byte[] raw=conn.getRawDescriptors(); + if(raw==null)return result; + byte[] languages=new byte[255];int count=conn.controlTransfer(0x80,6,0x300,0,languages,languages.length,300); + int language=count>=4?Protocol.u16(languages,2):0x0409; + for(int p=0;p+2<=raw.length;) { + int len=Protocol.u(raw[p]),type=Protocol.u(raw[p+1]);if(len<2 || p+len>raw.length)break; + if((type==4 && len>=9) || (type==11 && len>=8)) { + int id=Protocol.u(raw[p+2]),index=Protocol.u(raw[p+(type==4?8:7)]); + if(index>0) { + byte[] str=new byte[255];int n=conn.controlTransfer(0x80,6,0x300|index,language,str,str.length,300); + if(n>=2) {int end=Math.min(n,Protocol.u(str[0]));if(end>=2)result.put(id,new String(str,2,(end-2)&~1,StandardCharsets.UTF_16LE));} + } + } + p+=len; + } + return result; + } + private static UsbInterface byId(UsbDevice d,int id) throws IOException { + for(int n=0;nraw.length)break; + if(type==4 && len>=9)current=Protocol.u(raw[p+2]); + if(type==0x24 && len>=5 && Protocol.u(raw[p+2])==6 && current==control.getId() && Protocol.u(raw[p+3])==control.getId()) dataId=Protocol.u(raw[p+4]); + p+=len; + } + if(dataId<0)dataId=control.getId()+1; + data=byId(port.device,dataId);if(data.getInterfaceClass()!=10)throw new IOException(UiText.t("CDC 데이터 인터페이스가 아닙니다.")); + if(!conn.claimInterface(control,true) || !conn.claimInterface(data,true))throw new IOException(UiText.t("USB 포트 사용 중 또는 권한 오류")); + for(int n=0;n>8),(byte)(baud>>16),(byte)(baud>>24),0,0,8}; + if(conn.controlTransfer(0x21,0x20,0,control.getId(),coding,coding.length,1000)!=7)throw new IOException(UiText.t("115200/9600 8N1 설정 실패")); + if(conn.controlTransfer(0x21,0x22,3,control.getId(),null,0,1000)<0)throw new IOException(UiText.t("DTR/RTS 설정 실패")); + } catch(Exception e) {close();throw e instanceof IOException?(IOException)e:new IOException(e);} + } + public void start(Bytes callback,Failure failure) { + reader=new Thread(()->{ + byte[] b=new byte[1024]; + try {while(open) {int n=conn.bulkTransfer(in,b,b.length,200);if(n>0)callback.accept(b,n);}} + catch(Exception e) {if(open)failure.accept(e.toString());} + },"Oniimai-CDC-"+control.getId());reader.start(); + } + public synchronized void write(byte[] bytes) throws IOException { + if(!open)throw new IOException(UiText.t("포트가 닫혔습니다.")); + for(int offset=0;offset completion=new CompletableFuture<>(); + Output(byte[] report){buffer.put(report).flip();} + } + public Hid(UsbManager manager,Port port,Bytes listener,Failure failure) throws IOException { + if(!manager.hasPermission(port.device))throw new IOException(UiText.t("HID USB 권한을 허용하세요.")); + conn=manager.openDevice(port.device);if(conn==null)throw new IOException(UiText.t("HID 열기 실패")); + try { + intf=byId(port.device,port.controlId); + if(!port.hid || !conn.claimInterface(intf,true))throw new IOException(UiText.t("HID 인터페이스 사용 중")); + UsbEndpoint ep=null,out=null; + for(int n=0;n{ + ByteBuffer buffer=ByteBuffer.allocateDirect(capacity);boolean queued=false; + try {while(open) { + if(!queued){ + synchronized(submissionLock){ + if(!open)break;buffer.clear(); + if(!request.queue(buffer))throw new IOException(UiText.t("HID queue 실패"));queued=true; + } + } + UsbRequest done;try {done=conn.requestWait(1000);}catch(TimeoutException timeout){continue;} + if(!open)break; + // requestWait returns completions for every endpoint on this handle. + // Only this thread drains them, so output never steals a button report. + if(done==outputRequest){Output output=pendingOutput;if(output!=null)output.completion.complete(output.buffer.position());continue;} + if(done!=request)throw new IOException(UiText.t("HID 연결 종료"));queued=false; + int n=buffer.position();buffer.flip();byte[] bytes=new byte[n];buffer.get(bytes);if(n>0)listener.accept(bytes,n); + }}catch(Exception e){ + outputFailed=true;Output output=pendingOutput;if(output!=null)output.completion.completeExceptionally(e); + if(open)failure.accept(e.toString()); + } + },"Oniimai-HID");reader.start(); + } catch(Exception e) {close();throw e instanceof IOException?(IOException)e:new IOException(e);} + } + /** Blocking, bounded output: call from the LED worker, never the UI or HID input callback. + * An output error disables further writes on this handle without closing button input. + */ + public synchronized void writeCeiling(int rgb)throws IOException { + if(Thread.currentThread()==reader)throw new IOException("IO4 output cannot run on its input reader"); + if(!open||closed.get())throw new IOException("IO4 handle is closed"); + if(outputFailed)throw new IOException("IO4 ceiling output unavailable"); + byte[] report=Io4Output.ceiling(rgb); + if(!interruptOutput){ + int sent; + synchronized(submissionLock){ + if(!open||closed.get())throw new IOException("IO4 handle is closed"); + try{sent=conn.controlTransfer(0x21,0x09,0x0200|Io4Output.REPORT_ID,intf.getId(),report,report.length,300);} + catch(RuntimeException e){outputFailed=true;throw new IOException("IO4 SET_REPORT failed",e);} + } + if(sent!=report.length){outputFailed=true;throw new IOException("IO4 SET_REPORT incomplete");} + if(!open)throw new IOException("IO4 output canceled"); + return; + } + Output output=new Output(report); + try { + synchronized(submissionLock){ + if(!open||closed.get())throw new IOException("IO4 handle is closed"); + pendingOutput=output; + if(!outputRequest.queue(output.buffer))throw new IOException("IO4 output queue failed"); + } + if(output.completion.get(500,TimeUnit.MILLISECONDS)!=report.length)throw new IOException("IO4 output report incomplete"); + if(!open)throw new IOException("IO4 output canceled"); + pendingOutput=null; + }catch(InterruptedException e){Thread.currentThread().interrupt();disableOutput();throw new IOException("IO4 output interrupted",e);} + catch(ExecutionException|TimeoutException e){disableOutput();throw new IOException("IO4 output did not complete",e);} + catch(IOException|RuntimeException e){disableOutput();throw e instanceof IOException?(IOException)e:new IOException(e);} + } + private void disableOutput(){ + outputFailed=true; + // Never reuse a canceled request: a late completion can still reach requestWait. + // Its buffer stays strongly referenced until this HID handle is closed. + try{outputRequest.cancel();}catch(Exception ignored){} + } + public void close(){ + if(!closed.compareAndSet(false,true))return; + synchronized(submissionLock){ + open=false; + Output output=pendingOutput;if(output!=null)output.completion.completeExceptionally(new IOException("IO4 handle closed")); + try{outputRequest.cancel();}catch(Exception ignored){} + } + try{request.cancel();}catch(Exception ignored){} + // A control transfer may already be running. Do not close its fd underneath it. + synchronized(this){conn.close();} + if(reader!=null&&Thread.currentThread()!=reader)try{reader.join(500);}catch(InterruptedException e){Thread.currentThread().interrupt();} + try{request.close();}catch(Exception ignored){} + try{outputRequest.close();}catch(Exception ignored){} + pendingOutput=null; + } + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UsbIo.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UsbIo.java.meta new file mode 100644 index 000000000..13cf6af1f --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/UsbIo.java.meta @@ -0,0 +1,32 @@ +fileFormatVersion: 2 +guid: f55c7f66395d4bd1b912aeeddf14de74 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/WidgetFields.java b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/WidgetFields.java new file mode 100644 index 000000000..4f1b20344 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/WidgetFields.java @@ -0,0 +1,28 @@ +package net.majdata.majdataplay.oniimai; + +import java.util.Objects; + +/** A time tick must not invalidate song artwork, judgments, connection status or the clock. */ +final class WidgetFields { + interface Values { Object get(String key); } + private static final String[] SONG = {"title", "artist", "level"}; + private static final String[] SCORE = {"achievement", "combo", "dx"}; + private static final String[] JUDGMENTS = {"critical", "perfect", "great", "good", "miss"}; + private static final String[] TIMING = {"fast", "late"}; + private static final String[] GRAPH = {"seconds", "length"}; + static boolean changed(String type, Values before, Values after) { + String[] fields; + switch (type) { + case "song": fields = SONG; break; + case "score": fields = SCORE; break; + case "judgments": fields = JUDGMENTS; break; + case "timing": fields = TIMING; break; + case "graph": fields = GRAPH; break; + default: return false; + } + if (!Objects.equals(before.get("game"), after.get("game")) + || !Objects.equals(before.get("scene"), after.get("scene"))) return true; + for (String key : fields) if (!Objects.equals(before.get(key), after.get(key))) return true; + return false; + } +} diff --git a/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/WidgetFields.java.meta b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/WidgetFields.java.meta new file mode 100644 index 000000000..1551611d4 --- /dev/null +++ b/Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/WidgetFields.java.meta @@ -0,0 +1,33 @@ +fileFormatVersion: 2 +guid: cc63893bc8f84cccb89896768230b3c5 +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + Android: Android + second: + enabled: 1 + settings: {} + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 0 + settings: + DefaultValueInitialized: true + userData: + assetBundleName: + assetBundleVariant: + diff --git a/Assets/Scripts/Editor/OniimaiBuild.cs b/Assets/Scripts/Editor/OniimaiBuild.cs new file mode 100644 index 000000000..fb5ac1985 --- /dev/null +++ b/Assets/Scripts/Editor/OniimaiBuild.cs @@ -0,0 +1,51 @@ +using System; +using System.IO; +using System.Linq; +using UnityEditor; +using UnityEditor.Build; +using UnityEditor.Build.Reporting; +using UnityEngine; +using UnityEngine.Rendering; + +public static class OniimaiBuild +{ + [MenuItem("MajdataPlay/Build Oniimai Android")] + public static void Android() + { + OniimaiFrameChecks.Run(); + OniimaiStatsChecks.Run(); + var destination = Environment.GetEnvironmentVariable("ONIIMAI_APK"); + if (string.IsNullOrEmpty(destination)) destination = "Build/Oniimai-MajdataPlay.apk"; + Directory.CreateDirectory(Path.GetDirectoryName(Path.GetFullPath(destination))); + PlayerSettings.productName = "MajdataPlay Oniimai"; + PlayerSettings.SetApplicationIdentifier(NamedBuildTarget.Android, "net.majdata.majdataplay.oniimai"); + PlayerSettings.Android.bundleVersionCode = 22; + PlayerSettings.bundleVersion = "2.0.3-oniimai.22"; + PlayerSettings.Android.minSdkVersion = AndroidSdkVersions.AndroidApiLevel26; + PlayerSettings.Android.targetSdkVersion = AndroidSdkVersions.AndroidApiLevel35; + PlayerSettings.Android.targetArchitectures = AndroidArchitecture.ARM64; + PlayerSettings.Android.buildApkPerCpuArchitecture = false; + PlayerSettings.Android.splitApplicationBinary = false; + PlayerSettings.Android.applicationEntry = AndroidApplicationEntry.Activity; + PlayerSettings.Android.displayOptions = AndroidDisplayOptions.None; + PlayerSettings.Android.optimizedFramePacing = false; + PlayerSettings.SetScriptingBackend(NamedBuildTarget.Android, ScriptingImplementation.IL2CPP); + PlayerSettings.SetManagedStrippingLevel(NamedBuildTarget.Android, ManagedStrippingLevel.Minimal); + PlayerSettings.SetUseDefaultGraphicsAPIs(BuildTarget.Android, false); + PlayerSettings.SetGraphicsAPIs(BuildTarget.Android, new[] { GraphicsDeviceType.OpenGLES3 }); + PlayerSettings.defaultInterfaceOrientation = UIOrientation.Portrait; + PlayerSettings.Android.useCustomKeystore = false; + EditorUserBuildSettings.buildAppBundle = false; + EditorUserBuildSettings.exportAsGoogleAndroidProject = false; + var report = BuildPipeline.BuildPlayer(new BuildPlayerOptions { + scenes = EditorBuildSettings.scenes.Where(s => s.enabled).Select(s => s.path).ToArray(), + target = BuildTarget.Android, + locationPathName = destination, + options = BuildOptions.None, + }); + if (report.summary.result != BuildResult.Succeeded) + throw new BuildFailedException("Oniimai Android build failed: " + report.summary.result); + if (!File.Exists(destination)) throw new BuildFailedException("Expected a single APK file: " + destination); + Debug.Log("ONIIMAI_APK_READY " + destination); + } +} diff --git a/Assets/Scripts/Editor/OniimaiBuild.cs.meta b/Assets/Scripts/Editor/OniimaiBuild.cs.meta new file mode 100644 index 000000000..310d56800 --- /dev/null +++ b/Assets/Scripts/Editor/OniimaiBuild.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: c3116ee41cd54b53b692cc086533c262 diff --git a/Assets/Scripts/Editor/OniimaiFrameChecks.cs b/Assets/Scripts/Editor/OniimaiFrameChecks.cs new file mode 100644 index 000000000..d0edc0053 --- /dev/null +++ b/Assets/Scripts/Editor/OniimaiFrameChecks.cs @@ -0,0 +1,39 @@ +using System; +using MajdataPlay.IO; +internal static class OniimaiFrameChecks +{ + static int checks; + static void Check(bool ok) { checks++; if (!ok) throw new Exception("Frame merge check " + checks); } + public static void Run() + { + for (int i = 0; i < 34; i++) + { + var touch = new bool[34]; var buttons = new bool[12]; var tc = new int[34]; var bc = new int[8]; + touch[(i + 1) % 34] = true; + OniimaiFrame.Merge(new long[]{0, 1L << i, 0, 1L << i, 0}, touch, buttons, tc, bc); + Check(touch[i]); Check(touch[(i + 1) % 34]); Check(tc[i] == 1); + Array.Clear(tc, 0, tc.Length); OniimaiFrame.Merge(new long[]{0, 1L << i, 0, 0, 0}, touch, buttons, tc, bc); + Check(tc[i] == 0); // held physical touch must not repeat a tap + } + for (int i = 0; i < 8; i++) + { + var touch = new bool[34]; var buttons = new bool[12]; var tc = new int[34]; var bc = new int[8]; + buttons[(i + 1) % 8] = true; bc[i] = 1; + OniimaiFrame.Merge(new long[]{1L << i, 0, 1L << i, 0, 0}, touch, buttons, tc, bc); + Check(buttons[i]); Check(buttons[(i + 1) % 8]); Check(bc[i] == 1); + bc[i] = 2; OniimaiFrame.Merge(new long[]{1L << i, 0, 1L << i, 0, 0}, touch, buttons, tc, bc); Check(bc[i] == 2); + Array.Clear(bc, 0, bc.Length); OniimaiFrame.Merge(new long[]{1L << i, 0, 0, 0, 0}, touch, buttons, tc, bc); Check(bc[i] == 0); + } + foreach (var flags in new long[]{0,8}) { + var touch = new bool[34]; var buttons = new bool[12]; var tc = new int[34]; var bc = new int[8]; + var target = flags == 0 ? 9 : 3; + OniimaiFrame.Merge(new long[]{256,0,256,0,flags},touch,buttons,tc,bc); + Check(buttons[target]); Check(!buttons[target==3?9:3]); Check(bc[3]==(target==3?1:0)); + Array.Clear(bc,0,bc.Length); + OniimaiFrame.Merge(new long[]{256,0,0,0,flags},touch,buttons,tc,bc); Check(bc[3]==0); + Array.Clear(buttons,0,buttons.Length); + OniimaiFrame.Merge(new long[]{0,0,0,0,flags},touch,buttons,tc,bc); Check(!buttons[target]); + } + Console.WriteLine("FrameMerge: " + checks + " checks passed"); + } +} diff --git a/Assets/Scripts/Editor/OniimaiFrameChecks.cs.meta b/Assets/Scripts/Editor/OniimaiFrameChecks.cs.meta new file mode 100644 index 000000000..5bbb37edf --- /dev/null +++ b/Assets/Scripts/Editor/OniimaiFrameChecks.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 03017087896b0fc4ab496b3a3bc82fb1 \ No newline at end of file diff --git a/Assets/Scripts/Editor/OniimaiStatsChecks.cs b/Assets/Scripts/Editor/OniimaiStatsChecks.cs new file mode 100644 index 000000000..154cd1aa8 --- /dev/null +++ b/Assets/Scripts/Editor/OniimaiStatsChecks.cs @@ -0,0 +1,55 @@ +using System; +using MajdataPlay.IO; + +internal static class OniimaiStatsChecks +{ + private sealed class Record { internal int Miss; internal string Song = ""; } + private static int _checks; + private static void Check(bool ok) { _checks++; if (!ok) throw new Exception("Stats retention check " + _checks); } + internal static void Run() + { + _checks = 0; + var state = new OniimaiStatsRetention(); + Check(state.Advance(true, false, 10)); + state.Capture(new Record { Miss = 187, Song = "first" }, false); + Check(state.Frame.Miss == 187 && !state.IsFinal); + // A final note arrives after the last 100 ms sample. + var final = new Record { Miss = 188, Song = "first" }; + state.Capture(final, true); + Check(state.Frame == final && state.IsFinal); + state.Capture(new Record { Miss = 0 }, false); + Check(state.Frame == final); + Check(!state.Advance(true, false, 0)); // singleton freed during fade + Check(state.Frame == final); + for (int i = 0; i < 100; i++) // Result / TotalResult, including a disconnected monitor + { + Check(!state.Advance(false, true, 0)); + Check(state.Frame == final && state.Frame.Miss == 188 && state.IsFinal); + } + Check(state.Advance(false, false, 0)); // leave results while monitor is off + Check(state.Frame == null && !state.IsFinal); + Check(!state.Advance(false, false, 0)); + Check(state.Advance(true, false, 20)); + state.Capture(new Record { Miss = 7, Song = "retry" }, false); + Check(state.Advance(true, false, 21)); // Game -> Game retry + Check(state.Frame == null && !state.IsFinal); + state.Capture(new Record { Miss = 8 }, false); + Check(state.Advance(false, true, 0)); // no completed record: don't invent results + Check(state.Frame == null); + Check(state.Advance(true, false, 30)); + state.Capture(new Record { Miss = 9 }, true); + Check(!state.Advance(false, true, 0)); + Check(state.Advance(true, false, 0)); // next course song starts before singleton ready + Check(state.Frame == null); + Check(state.Advance(true, false, 31)); + state.Capture(new Record { Song = "new", Miss = 1 }, false); + Check(state.Frame.Song == "new" && !state.IsFinal); + Check(state.Advance(false, false, 0)); // abort to song list + Check(state.Frame == null); + Check(state.Advance(true, false, 40)); + state.Capture(new Record { Miss = 12 }, true); + Check(state.Advance(true, false, 41)); // completed practice round directly to next round + Check(state.Frame == null && !state.IsFinal); + Console.WriteLine("StatsRetention: " + _checks + " checks passed"); + } +} diff --git a/Assets/Scripts/Editor/OniimaiStatsChecks.cs.meta b/Assets/Scripts/Editor/OniimaiStatsChecks.cs.meta new file mode 100644 index 000000000..51025b66c --- /dev/null +++ b/Assets/Scripts/Editor/OniimaiStatsChecks.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: e0759554f0e44a238ab5e91b32b67e4f diff --git a/Assets/Scripts/Global/GameUpdater.cs b/Assets/Scripts/Global/GameUpdater.cs index 21d3d1a20..9a2cd445a 100644 --- a/Assets/Scripts/Global/GameUpdater.cs +++ b/Assets/Scripts/Global/GameUpdater.cs @@ -144,6 +144,9 @@ void LateUpdate() break; } CabinetLed.OnLateUpdate(); +#if UNITY_ANDROID && !UNITY_EDITOR + OniimaiController.UpdateStats(); +#endif _dummyLedRenderer.OnLateUpdate(); } catch (Exception e) diff --git a/Assets/Scripts/Global/Managers/GameManager.cs b/Assets/Scripts/Global/Managers/GameManager.cs index 34da0c905..c542cbb64 100644 --- a/Assets/Scripts/Global/Managers/GameManager.cs +++ b/Assets/Scripts/Global/Managers/GameManager.cs @@ -339,6 +339,9 @@ private void ApplyScreenConfig() } #endif Application.targetFrameRate = Settings.Display.FPSLimit; +#if UNITY_ANDROID && !UNITY_EDITOR + MajDebug.LogInfo($"Oniimai Android FPS selected: target={Application.targetFrameRate}, setting={Settings.Display.FPSLimit}"); +#endif } #if UNITY_STANDALONE_OSX && !UNITY_EDITOR diff --git a/Assets/Scripts/IO/CabinetLed.cs b/Assets/Scripts/IO/CabinetLed.cs index da575b49f..5eeddecf7 100644 --- a/Assets/Scripts/IO/CabinetLed.cs +++ b/Assets/Scripts/IO/CabinetLed.cs @@ -36,6 +36,10 @@ public static void Init() { #if UNITY_STANDALONE IsEnabled = MajEnv.Settings.IO.OutputDevice.Led.Enable; +#elif UNITY_ANDROID + // Produce game lighting independently of the on-screen dummy button display. + // The USB worker applies the player's output toggle and brightness. + IsEnabled = true; #else IsEnabled = false; #endif @@ -70,7 +74,7 @@ public static void Init() } } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] internal static void OnLateUpdate() { @@ -93,14 +97,14 @@ internal static void OnLateUpdate() DummyLedRenderer.SetLedRingColorData(ledColors); } #region Cabinet light - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetCabinetLight(byte brightness) { SetCabinetLight(brightness / 255f); } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetCabinetLight(float brightness) { @@ -112,7 +116,7 @@ public static void SetCabinetLight(float brightness) func.SetColor(Color.white * Mathf.Clamp01(brightness)); _cabinetLight.UpdateFunction = func; } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetCabinetLightSineFunc(float brightness, long T_Ms, float phi = 0.5f) { if (!IsEnabled) @@ -121,7 +125,7 @@ public static void SetCabinetLightSineFunc(float brightness, long T_Ms, float ph } SetCabinetLightSineFunc(brightness, TimeSpan.FromMilliseconds(T_Ms), phi); } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetCabinetLightSineFunc(float brightness, TimeSpan T, float phi = 0.5f) { if (!IsEnabled) @@ -134,7 +138,7 @@ public static void SetCabinetLightSineFunc(float brightness, TimeSpan T, float p } #endregion #region Led ring - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetAllLight(Color lightColor) { @@ -149,7 +153,7 @@ public static void SetAllLight(Color lightColor) _ledRingDevices[i].UpdateFunction = func; } } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetAllLightLinearTo(Color from, Color to, long durationMs) { @@ -159,7 +163,7 @@ public static void SetAllLightLinearTo(Color from, Color to, long durationMs) } SetAllLightLinearTo(from, to, TimeSpan.FromMilliseconds(durationMs)); } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetAllLightLinearTo(Color from, Color to, TimeSpan duration) { @@ -174,7 +178,7 @@ public static void SetAllLightLinearTo(Color from, Color to, TimeSpan duration) _ledRingDevices[i].UpdateFunction = func; } } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetAllLightSineFunc(Color color, long T_Ms, float phi = 0.5f) { @@ -184,7 +188,7 @@ public static void SetAllLightSineFunc(Color color, long T_Ms, float phi = 0.5f) } SetAllLightSineFunc(color, TimeSpan.FromMilliseconds(T_Ms), phi); } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetAllLightSineFunc(Color color, TimeSpan T, float phi = 0.5f) { @@ -199,7 +203,7 @@ public static void SetAllLightSineFunc(Color color, TimeSpan T, float phi = 0.5f _ledRingDevices[i].UpdateFunction = func; } } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] [MethodImpl(MethodImplOptions.AggressiveInlining)] public static void SetAllLightUpdateFunc(ReadOnlySpan funcs) { @@ -216,7 +220,7 @@ public static void SetAllLightUpdateFunc(ReadOnlySpan funcs) _ledRingDevices[i].UpdateFunction = funcs[i]; } } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetButtonLight(Color lightColor, int button) { if (!IsEnabled) @@ -227,7 +231,7 @@ public static void SetButtonLight(Color lightColor, int button) func.SetColor(lightColor); _ledRingDevices[button].UpdateFunction = func; } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetButtonLightWithTimeout(Color lightColor, int button, long durationMs = 500) { if (!IsEnabled) @@ -238,7 +242,7 @@ public static void SetButtonLightWithTimeout(Color lightColor, int button, long func.SetColor(lightColor, durationMs); _ledRingDevices[button].UpdateFunction = func; } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetButtonLightWithTimeout(Color lightColor, int button, TimeSpan duration) { if (!IsEnabled) @@ -249,7 +253,7 @@ public static void SetButtonLightWithTimeout(Color lightColor, int button, TimeS func.SetColor(lightColor, duration); _ledRingDevices[button].UpdateFunction = func; } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void LinearTo(int button, Color from, Color to, long durationMs) { if (!IsEnabled) @@ -258,7 +262,7 @@ public static void LinearTo(int button, Color from, Color to, long durationMs) } LinearTo(button, from, to, TimeSpan.FromMilliseconds(durationMs)); } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void LinearTo(int button, Color from, Color to, TimeSpan duration) { if (!IsEnabled) @@ -269,7 +273,7 @@ public static void LinearTo(int button, Color from, Color to, TimeSpan duration) func.LinearTo(from, to, duration); _ledRingDevices[button].UpdateFunction = func; } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetSineFunc(int button, Color color, long T_Ms, float phi = 0.5f) { if (!IsEnabled) @@ -278,7 +282,7 @@ public static void SetSineFunc(int button, Color color, long T_Ms, float phi = 0 } SetSineFunc(button, color, TimeSpan.FromMilliseconds(T_Ms), phi); } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetSineFunc(int button, Color color, TimeSpan T, float phi = 0.5f) { if (!IsEnabled) @@ -289,7 +293,7 @@ public static void SetSineFunc(int button, Color color, TimeSpan T, float phi = func.SetSineFunc(color, T, phi); _ledRingDevices[button].UpdateFunction = func; } - [Conditional("UNITY_STANDALONE")] + [Conditional("UNITY_STANDALONE"), Conditional("UNITY_ANDROID")] public static void SetUpdateFunc(int button, ILedUpdateFunction func) { if (!IsEnabled) diff --git a/Assets/Scripts/IO/InputManager/InputManager.cs b/Assets/Scripts/IO/InputManager/InputManager.cs index 3732ca965..9b9bf79c1 100644 --- a/Assets/Scripts/IO/InputManager/InputManager.cs +++ b/Assets/Scripts/IO/InputManager/InputManager.cs @@ -42,6 +42,8 @@ public static bool IsTouchPanelConnected { #if UNITY_STANDALONE return TouchPanel.IsConnected; +#elif UNITY_ANDROID && !UNITY_EDITOR + return OniimaiController.TouchConnected; #else return false; #endif @@ -53,6 +55,8 @@ public static bool IsButtonRingConnected { #if UNITY_STANDALONE return ButtonRing.IsConnected; +#elif UNITY_ANDROID && !UNITY_EDITOR + return ButtonRing.IsConnected || OniimaiController.ButtonConnected; #else return false; #endif @@ -480,7 +484,7 @@ internal static void OnPreUpdate() var cExtraRad = Override_TouchCAreaExtraRadius ?? debugOptions.TouchCAreaExtraRadius; var dExtraRad = Override_TouchDAreaExtraRadius ?? debugOptions.TouchDAreaExtraRadius; var eExtraRad = Override_TouchEAreaExtraRadius ?? debugOptions.TouchEAreaExtraRadius; - var mainScreenTransform = displayOptions.MainScreenTransform; + var mainScreenTransform = OniimaiLayout.PhoneTransform(displayOptions.MainScreenTransform); var mainScreenOffset = displayOptions.MainScreenOffset; var isModified = height != _lastScreenHeight || diff --git a/Assets/Scripts/IO/InputManager/RawDeviceHandle/InputManager.DummyInput.cs b/Assets/Scripts/IO/InputManager/RawDeviceHandle/InputManager.DummyInput.cs index b52da8476..7d9f2d1ef 100644 --- a/Assets/Scripts/IO/InputManager/RawDeviceHandle/InputManager.DummyInput.cs +++ b/Assets/Scripts/IO/InputManager/RawDeviceHandle/InputManager.DummyInput.cs @@ -1,4 +1,4 @@ -using MajdataPlay.Collections; +using MajdataPlay.Collections; using MajdataPlay.Diagnostics; using MajdataPlay.Utils; using System; @@ -92,6 +92,11 @@ static void UpdateMousePosition() { FromMouse(Mouse.current, buttonClickedCount, sensorClickedCount, newStates, extraButtonStates, mainCamera); } +#endif +#if UNITY_ANDROID && !UNITY_EDITOR + // OR physical input with phone touches before queuing. One source must never + // release a zone still held by the other. No Android touch/key injection. + OniimaiController.MergeInput(newStates, extraButtonStates, sensorClickedCount, buttonClickedCount); #endif var now = MajTimeline.UnscaledTime; foreach (var (i, state) in newStates.WithIndex()) diff --git a/Assets/Scripts/IO/OniimaiController.cs b/Assets/Scripts/IO/OniimaiController.cs new file mode 100644 index 000000000..69108e90e --- /dev/null +++ b/Assets/Scripts/IO/OniimaiController.cs @@ -0,0 +1,202 @@ +#if UNITY_ANDROID && !UNITY_EDITOR +using System; +using UnityEngine; +using MajdataPlay.Scenes.Game; +using MajdataPlay.Utils; + +namespace MajdataPlay.IO +{ + internal static class OniimaiController + { + private static AndroidJavaClass _bridge; + private static bool _failed; + private static readonly int[] LedFrame = new int[9]; + internal static bool TouchConnected { get; private set; } + internal static bool ButtonConnected { get; private set; } + internal static bool ExternalActive { get; private set; } + internal static int GameFrameRate { get; private set; } = 60; + private static bool _frameRateApplied; + private static float _nextStats; + private static int _artGame, _coverId, _graphId; + private static readonly OniimaiStatsRetention Stats = new(); + [Serializable] + private sealed class StatsFrame + { + public bool game, result; + public string scene, title, artist, designer, level; + public int levelColor; + public float seconds, length; + public double achievement; + public long combo, dx, critical, perfect, great, good, miss, fast, late; + } + + // Presentation uses the unmodified 1080x1920 cabinet canvas. Phone layout settings + // stay saved as-is and automatically become effective again after unplugging. + internal static bool UsePhoneLayout(bool configured) => !ExternalActive && configured; + + private static AndroidJavaClass Bridge => _bridge ??= new AndroidJavaClass( + "net.majdata.majdataplay.oniimai.OniimaiController"); + + internal static void MergeInput(Span touch, Span buttons, + Span touchClicks, Span buttonClicks) + { + if (_failed) return; + try + { + var frame = Bridge.CallStatic("poll"); + if (frame == null || frame.Length != 5) return; + TouchConnected = (frame[4] & 1) != 0; + ButtonConnected = (frame[4] & 2) != 0; + ExternalActive = (frame[4] & 4) != 0; + var frameRate = (frame[4] & 16) != 0 ? 120 : 60; + if (!_frameRateApplied || GameFrameRate != frameRate) + { + GameFrameRate = frameRate; + Application.targetFrameRate = frameRate; + _frameRateApplied = true; + Debug.Log($"Oniimai Android FPS selected: target={frameRate}"); + } + OniimaiFrame.Merge(frame, touch, buttons, touchClicks, buttonClicks); + } + catch (Exception ex) { Fail(ex); } + } + internal static void SubmitLights(ReadOnlySpan colors, byte cabinetBrightness) + { + if (_failed) return; + try + { + for (var i = 0; i < 8; i++) + { + Color32 c = colors[i]; + LedFrame[i] = (c.r << 16) | (c.g << 8) | c.b; + } + LedFrame[8] = cabinetBrightness * 0x010101; + Bridge.CallStatic("lights", LedFrame); + } + catch (Exception ex) { Fail(ex); } + } + internal static void SetSettingsVisible(bool visible) + { + if (_failed) return; + try { Bridge.CallStatic("settingsVisible", visible); } + catch (Exception ex) { Fail(ex); } + } + internal static void UpdateStats() + { + if (_failed) return; + try + { + var scene = SceneSwitcher.CurrentScene; + var game = Majdata.Instance; + var inGame = scene == MajScenes.Game; + var inResult = scene == MajScenes.Result || scene == MajScenes.TotalResult; + if (Stats.Advance(inGame, inResult, game != null ? game.GetInstanceID() : 0)) + { + ClearArtwork(); + _nextStats = 0; + } + if (Time.unscaledTime < _nextStats) return; + _nextStats = Time.unscaledTime + 0.1f; + var counter = Majdata.Instance; + if (inGame && !Stats.IsFinal && counter != null && game != null) + { + Stats.Capture(ReadStats(game, counter), false); + if (ExternalActive) UpdateArtwork(game); + } + var frame = Stats.Frame ?? new StatsFrame { title = "MajdataPlay" }; + frame.scene = scene.ToString(); + frame.result = inResult && Stats.IsFinal; + // Keep refreshing the heartbeat on results, including dashboard previews. + Bridge.CallStatic("stats", JsonUtility.ToJson(frame)); + } + catch (Exception ex) { Debug.LogWarning("Oniimai stats: " + ex.Message); } + } + internal static void CaptureResult(GamePlayManager game, ObjectCounter counter, GameResult result) + { + if (_failed) return; + try + { + if (Stats.Advance(true, false, game.GetInstanceID())) ClearArtwork(); + var frame = ReadStats(game, counter); + var judge = MajdataPlay.Collections.JudgeDetail.UnpackJudgeRecord(result.JudgeRecord.TotalJudgeInfo); + frame.achievement = result.Acc.DX; + frame.dx = result.DXScore; + frame.critical = judge.CriticalPerfect; frame.perfect = judge.Perfect; + frame.great = judge.Great; frame.good = judge.Good; frame.miss = judge.Miss; + frame.fast = result.Fast; frame.late = result.Late; + Stats.Capture(frame, true); + _nextStats = 0; + // Copy PNGs before Unity destroys the song's textures. This also supports + // enabling the monitor for the first time while already on results. + UpdateArtwork(game); + } + catch (Exception ex) { Debug.LogWarning("Oniimai final stats: " + ex.Message); } + } + private static StatsFrame ReadStats(GamePlayManager game, ObjectCounter counter) + { + ref readonly var judge = ref counter.JudgeStats; + Color32 color = game.OniimaiLevelColor; + return new StatsFrame { + game = true, title = game.OniimaiSongTitle, artist = game.OniimaiArtist, + designer = game.OniimaiDesigner, level = game.OniimaiLevel, + levelColor = unchecked((int)0xff000000) | color.r << 16 | color.g << 8 | color.b, + seconds = Mathf.Clamp(game.ThisFrameSec, 0, Mathf.Max(0, game.AudioLength)), length = game.AudioLength, + combo = counter.OniimaiCombo, dx = counter.OniimaiRemainingDxScore, + achievement = counter.AccurateStats.Achievement_C, + critical = judge.TotalCriticalCount, perfect = judge.TotalPerfectCount, + great = judge.TotalGreatCount, good = judge.TotalGoodCount, miss = judge.TotalMissCount, + fast = judge.TotalFastCount, late = judge.TotalLateCount, + }; + } + private static void ClearArtwork() + { + _artGame = _coverId = _graphId = 0; + Bridge.CallStatic("artwork", 0, Array.Empty()); + Bridge.CallStatic("artwork", 1, Array.Empty()); + } + private static void UpdateArtwork(GamePlayManager game) + { + if (_artGame != game.GetInstanceID()) { + ClearArtwork(); _artGame = game.GetInstanceID(); + } + var cover = game.OniimaiCover; + if (cover != null && _coverId != cover.GetInstanceID()) { + SendArtwork(0, cover.texture, cover.rect, 192, 192); + _coverId = cover.GetInstanceID(); + } + var graph = game.OniimaiGraph; + if (graph != null && graph.width > 1 && _graphId != graph.GetInstanceID()) { + SendArtwork(1, graph, new Rect(0, 0, graph.width, graph.height), 640, 100); + _graphId = graph.GetInstanceID(); + } + } + // Small, static artwork is transferred only when the song/graph changes. + // Live gameplay frames are never read back or copied for the phone panel. + private static void SendArtwork(int kind, Texture source, Rect crop, int width, int height) + { + var previous = RenderTexture.active; + var target = RenderTexture.GetTemporary(width, height, 0, RenderTextureFormat.ARGB32); + Texture2D image = null; + try { + Graphics.Blit(source, target, new Vector2(crop.width / source.width, crop.height / source.height), + new Vector2(crop.x / source.width, crop.y / source.height)); + RenderTexture.active = target; + image = new Texture2D(width, height, TextureFormat.RGBA32, false); + image.ReadPixels(new Rect(0, 0, width, height), 0, 0); image.Apply(); + Bridge.CallStatic("artwork", kind, image.EncodeToPNG()); + } + finally { + RenderTexture.active = previous; + if (image != null) UnityEngine.Object.Destroy(image); + RenderTexture.ReleaseTemporary(target); + } + } + private static void Fail(Exception ex) + { + _failed = true; + TouchConnected = ButtonConnected = false; + Debug.LogError("Oniimai Android bridge unavailable: " + ex); + } + } +} +#endif diff --git a/Assets/Scripts/IO/OniimaiController.cs.meta b/Assets/Scripts/IO/OniimaiController.cs.meta new file mode 100644 index 000000000..88f502c74 --- /dev/null +++ b/Assets/Scripts/IO/OniimaiController.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 771ab04a0fc74bd29f18e22994f13acc diff --git a/Assets/Scripts/IO/OniimaiFrame.cs b/Assets/Scripts/IO/OniimaiFrame.cs new file mode 100644 index 000000000..90088e580 --- /dev/null +++ b/Assets/Scripts/IO/OniimaiFrame.cs @@ -0,0 +1,30 @@ +using System; + +namespace MajdataPlay.IO +{ + // Pure frame merge, kept independent of Unity/JNI for reproducible regression tests. + public static class OniimaiFrame + { + public static void Merge(long[] frame, Span touch, Span buttons, + Span touchClicks, Span buttonClicks) + { + if (frame == null || frame.Length != 5) return; + for (var i = 0; i < 8; i++) + { + buttons[i] |= (frame[0] & (1L << i)) != 0; + if ((frame[2] & (1L << i)) != 0) buttonClicks[i] = Math.Max(1, buttonClicks[i]); + } + for (var i = 0; i < 34; i++) + { + touch[i] |= (frame[1] & (1L << i)) != 0; + if ((frame[3] & (1L << i)) != 0) touchClicks[i] = Math.Max(1, touchClicks[i]); + } + // Separate auxiliary switch. Keep native P1 behavior by default; A4 is optional. + var p1Target = (frame[4] & 8) != 0 ? 3 : 9; + buttons[p1Target] |= (frame[0] & 256) != 0; + if (p1Target == 3 && (frame[2] & 256) != 0) + buttonClicks[3] = Math.Max(1, buttonClicks[3]); + // InputManager's existing path merges physical C1/C2 -> game C (33 zones). + } + } +} diff --git a/Assets/Scripts/IO/OniimaiFrame.cs.meta b/Assets/Scripts/IO/OniimaiFrame.cs.meta new file mode 100644 index 000000000..fa6bcd616 --- /dev/null +++ b/Assets/Scripts/IO/OniimaiFrame.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: bb9bbf9be99149efa71dde9fbbe3bcff diff --git a/Assets/Scripts/IO/OniimaiLayout.cs b/Assets/Scripts/IO/OniimaiLayout.cs new file mode 100644 index 000000000..973dbee8e --- /dev/null +++ b/Assets/Scripts/IO/OniimaiLayout.cs @@ -0,0 +1,25 @@ +namespace MajdataPlay.IO +{ + internal static class OniimaiLayout + { + internal static bool PhoneTransform(bool configured) + { +#if UNITY_ANDROID && !UNITY_EDITOR + return OniimaiController.UsePhoneLayout(configured); +#else + return configured; +#endif + } + internal static bool ExternalActive + { + get + { +#if UNITY_ANDROID && !UNITY_EDITOR + return OniimaiController.ExternalActive; +#else + return false; +#endif + } + } + } +} diff --git a/Assets/Scripts/IO/OniimaiLayout.cs.meta b/Assets/Scripts/IO/OniimaiLayout.cs.meta new file mode 100644 index 000000000..014be5adc --- /dev/null +++ b/Assets/Scripts/IO/OniimaiLayout.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: 4c41742a5a19443b93b3217cb929f5ae diff --git a/Assets/Scripts/IO/OniimaiStatsRetention.cs b/Assets/Scripts/IO/OniimaiStatsRetention.cs new file mode 100644 index 000000000..d273fef0d --- /dev/null +++ b/Assets/Scripts/IO/OniimaiStatsRetention.cs @@ -0,0 +1,45 @@ +#nullable enable +namespace MajdataPlay.IO +{ + // Independent of rendering/USB: leaving a result must reset even with the monitor off. + public sealed class OniimaiStatsRetention where T : class + { + public T? Frame { get; private set; } + public bool IsFinal { get; private set; } + private bool _inGame; + private int _session; + + public bool Advance(bool inGame, bool inResult, int session) + { + if (inGame) + { + // A scene can reload directly from Game to Game (retry/practice). + // A zero ID during teardown must not erase the just-completed record. + var changed = !_inGame || (session != 0 && session != _session); + if (changed) Clear(); + _inGame = true; + if (session != 0) _session = session; + return changed; + } + _inGame = false; + if (inResult && IsFinal) return false; + var hadRecord = Frame != null || _session != 0; + Clear(); + return hadRecord; + } + + public void Capture(T frame, bool final) + { + if (IsFinal) return; // LateUpdate cannot replace the exact final snapshot. + Frame = frame; + IsFinal = final; + } + + private void Clear() + { + Frame = null; + IsFinal = false; + _session = 0; + } + } +} diff --git a/Assets/Scripts/IO/OniimaiStatsRetention.cs.meta b/Assets/Scripts/IO/OniimaiStatsRetention.cs.meta new file mode 100644 index 000000000..17dda0e41 --- /dev/null +++ b/Assets/Scripts/IO/OniimaiStatsRetention.cs.meta @@ -0,0 +1,2 @@ +fileFormatVersion: 2 +guid: f1c3c315e08c4a2680273f2613261cb6 diff --git a/Assets/Scripts/IO/OutputManager/OutputManager.cs b/Assets/Scripts/IO/OutputManager/OutputManager.cs index 5af5539b8..1ff8aab15 100644 --- a/Assets/Scripts/IO/OutputManager/OutputManager.cs +++ b/Assets/Scripts/IO/OutputManager/OutputManager.cs @@ -1,4 +1,4 @@ -using MajdataPlay.Numerics; +using MajdataPlay.Numerics; using MajdataPlay.Settings; using System; using System.Collections.Generic; @@ -33,6 +33,9 @@ public static void SetLedRingColorData(ReadOnlySpan colors) public static void SetCabinetLightBrightness(float brightness) { _cabinetLightBrightness = (byte)Mathf.RoundToInt(Mathf.Clamp01(brightness) * 255f); +#if UNITY_ANDROID && !UNITY_EDITOR + OniimaiController.SubmitLights(_ledRingColors, _cabinetLightBrightness); +#endif } } } diff --git a/Assets/Scripts/Misc/ScreenPos/ScreenPosCameraMover.cs b/Assets/Scripts/Misc/ScreenPos/ScreenPosCameraMover.cs index ecc97ae00..86a5775ab 100644 --- a/Assets/Scripts/Misc/ScreenPos/ScreenPosCameraMover.cs +++ b/Assets/Scripts/Misc/ScreenPos/ScreenPosCameraMover.cs @@ -91,8 +91,23 @@ void ApplyTransform() } } + int _lastViewportWidth, _lastViewportHeight; + bool _lastExternal; + void UpdateViewport() + { + var external = MajdataPlay.IO.OniimaiLayout.ExternalActive; + if (_lastViewportWidth == Screen.width && _lastViewportHeight == Screen.height && _lastExternal == external) return; + _lastViewportWidth = Screen.width; + _lastViewportHeight = Screen.height; + _lastExternal = external; + var aspect = (float)Screen.width / Screen.height; + _cam.rect = external ? new Rect(0, 0, 1, 1) + : aspect < 9f / 18f ? new Rect(0, .22f, 1, 1) + : aspect < 9f / 16f ? new Rect(0, .12f, 1, 1) : new Rect(0, 0, 1, 1); + } void Update() { + UpdateViewport(); switch (_flag) { case FLAG_NOT_INIT: @@ -104,34 +119,21 @@ void Update() } _flag = FLAG_INITED; - _lastTransformDisplay = _displayOptions.MainScreenTransform; + _lastTransformDisplay = MajdataPlay.IO.OniimaiLayout.PhoneTransform(_displayOptions.MainScreenTransform); _lastMainScreenOffset = _displayOptions.MainScreenOffset; _lastMainScreenScale = _displayOptions.MainScreenScale; ApplyTransform(); //transform.position = new Vector3(0, 1.5f + 2.7f * (MajEnv.Settings?.Display.MainScreenPosition ?? 1f), -10); //Original - var aspectratio = (float)Screen.width / (float)Screen.height; - + UpdateViewport(); - if (aspectratio < (9f / 18f)) - { - _cam.rect = new Rect(0, 0.22f, 1, 1); - } - else if (aspectratio < (9f / 16f)) - { - _cam.rect = new Rect(0, 0.12f, 1, 1); - } - else - { - _cam.rect = new Rect(0, 0, 1, 1); - } } goto case FLAG_INITED; case FLAG_INITED: { var transformDisplay = _lastTransformDisplay; - _lastTransformDisplay = _displayOptions!.MainScreenTransform; + _lastTransformDisplay = MajdataPlay.IO.OniimaiLayout.PhoneTransform(_displayOptions!.MainScreenTransform); _lastMainScreenOffset = _displayOptions!.MainScreenOffset; _lastMainScreenScale = _displayOptions!.MainScreenScale; //如果没开调整显示位置,就直接return diff --git a/Assets/Scripts/Misc/ScreenPos/ScreenPosCanvasMover.cs b/Assets/Scripts/Misc/ScreenPos/ScreenPosCanvasMover.cs index df4c6a2e2..74ada543f 100644 --- a/Assets/Scripts/Misc/ScreenPos/ScreenPosCanvasMover.cs +++ b/Assets/Scripts/Misc/ScreenPos/ScreenPosCanvasMover.cs @@ -313,7 +313,7 @@ void UpdateSubCover() void ApplyTransform() { - var initTransform = _displayOptions!.MainScreenTransform; + var initTransform = MajdataPlay.IO.OniimaiLayout.PhoneTransform(_displayOptions!.MainScreenTransform); _lastTransformDisplay = initTransform; if (initTransform) { @@ -353,7 +353,7 @@ void Update() goto case FLAG_INITED; case FLAG_INITED: { - var transformDisplay = _displayOptions!.MainScreenTransform; + var transformDisplay = MajdataPlay.IO.OniimaiLayout.PhoneTransform(_displayOptions!.MainScreenTransform); if (!transformDisplay) { diff --git a/Assets/Scripts/Misc/Settings/GameSetting.cs b/Assets/Scripts/Misc/Settings/GameSetting.cs index 57b79210b..4a65370f0 100644 --- a/Assets/Scripts/Misc/Settings/GameSetting.cs +++ b/Assets/Scripts/Misc/Settings/GameSetting.cs @@ -225,10 +225,25 @@ public class DisplayOptions public bool Topmost { get; set; } = false; #endif +#if UNITY_ANDROID + [HideInSettingUI] + // The native Oniimai display panel owns this setting, across user profiles. + // Accept old JSON files without letting them override the saved 60/120 choice. + public int FPSLimit + { +#if UNITY_EDITOR + get => 60; +#else + get => IO.OniimaiController.GameFrameRate; +#endif + set { } + } +#else [Step("1")] [Range("-1", null, HasMax = false, HasMin = true)] [OptionEnumerator(typeof(EngineNumberSettingEnumerator))] public int FPSLimit { get; set; } = 120; +#endif #if !(UNITY_ANDROID || UNITY_IOS) [OptionEnumerator(typeof(EngineBooleanSettingEnumerator))] diff --git a/Assets/Scripts/Scenes/Game/GamePlayManager.cs b/Assets/Scripts/Scenes/Game/GamePlayManager.cs index 41876c7a6..b5b295c12 100644 --- a/Assets/Scripts/Scenes/Game/GamePlayManager.cs +++ b/Assets/Scripts/Scenes/Game/GamePlayManager.cs @@ -191,6 +191,13 @@ public float PlaybackSpeed SimaiChart _chart; ChartSetting _chartSetting; ISongDetail _songDetail; + internal string OniimaiSongTitle => _songDetail?.Title ?? "MajdataPlay"; + internal string OniimaiArtist => _chartInfoDisplayer?.artist?.text ?? ""; + internal string OniimaiDesigner => _chartInfoDisplayer?.designer?.text ?? ""; + internal string OniimaiLevel => _chartInfoDisplayer?.level?.text ?? ""; + internal Sprite OniimaiCover => _chartInfoDisplayer?.coverImg?.sprite; + internal Texture OniimaiGraph => _chartVisualDisplayer != null ? _chartVisualDisplayer.GetComponent().texture : null; + internal Color OniimaiLevelColor => _levelCircleDisplayer != null ? _levelCircleDisplayer.color : Color.magenta; float _trackVolume = 1f; @@ -1664,6 +1671,9 @@ private GameResult CalculateScore(bool playEffect = true) print("GameResult: " + accStats.Achievement_A); var result = _objectCounter.GetPlayRecord(_songDetail, _listConfig.SelectedDiff); _gameInfo.RecordResult(result); +#if UNITY_ANDROID && !UNITY_EDITOR + OniimaiController.CaptureResult(this, _objectCounter, result); +#endif if (!playEffect) { diff --git a/Assets/Scripts/Scenes/Game/ObjectCounter.cs b/Assets/Scripts/Scenes/Game/ObjectCounter.cs index a534d7116..0d75603cf 100644 --- a/Assets/Scripts/Scenes/Game/ObjectCounter.cs +++ b/Assets/Scripts/Scenes/Game/ObjectCounter.cs @@ -157,6 +157,8 @@ public ref readonly NoteJudgeStatistics JudgeStats long _lostDXScore = 0; long _combo = 0; // Combo + internal long OniimaiCombo => _combo; + internal long OniimaiRemainingDxScore => _totalDXScore + _lostDXScore; long _pCombo = 0; // Perfect Combo long _cPCombo = 0; // Critical Perfect diff --git a/Assets/Scripts/Scenes/Setting/SettingManager.cs b/Assets/Scripts/Scenes/Setting/SettingManager.cs index 2780b501a..63b6d2e34 100644 --- a/Assets/Scripts/Scenes/Setting/SettingManager.cs +++ b/Assets/Scripts/Scenes/Setting/SettingManager.cs @@ -63,6 +63,9 @@ void Awake() } void Start() { +#if UNITY_ANDROID && !UNITY_EDITOR + OniimaiController.SetSettingsVisible(true); +#endif var type = Setting.GetType(); var properties = type.GetProperties() .Where(x => !x.GetCustomAttributes().Any()) @@ -385,6 +388,9 @@ static void EnsureNestedCanvas(GameObject target) } private void OnDestroy() { +#if UNITY_ANDROID && !UNITY_EDITOR + OniimaiController.SetSettingsVisible(false); +#endif _isExited = true; _menuTitleDisplayerAnim.TryCancel(); InputManager.TouchButtonRingEdge = 5.4f; diff --git a/Assets/StreamingAssets/OniimaiUI.meta b/Assets/StreamingAssets/OniimaiUI.meta new file mode 100644 index 000000000..92cb765e3 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: ee71be3fca484781b7cd9519c6faff88 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/AlimamaFangYuanTiVF-Thin-2.ttf b/Assets/StreamingAssets/OniimaiUI/AlimamaFangYuanTiVF-Thin-2.ttf new file mode 100644 index 000000000..d7dc77355 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/AlimamaFangYuanTiVF-Thin-2.ttf differ diff --git a/Assets/StreamingAssets/OniimaiUI/AlimamaFangYuanTiVF-Thin-2.ttf.meta b/Assets/StreamingAssets/OniimaiUI/AlimamaFangYuanTiVF-Thin-2.ttf.meta new file mode 100644 index 000000000..49d4488c2 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/AlimamaFangYuanTiVF-Thin-2.ttf.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9e5b3a56f72a46069503f73326451117 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/Jua-Regular.ttf b/Assets/StreamingAssets/OniimaiUI/Jua-Regular.ttf new file mode 100644 index 000000000..f0943a5a7 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/Jua-Regular.ttf differ diff --git a/Assets/StreamingAssets/OniimaiUI/Jua-Regular.ttf.meta b/Assets/StreamingAssets/OniimaiUI/Jua-Regular.ttf.meta new file mode 100644 index 000000000..3eda7484c --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/Jua-Regular.ttf.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: f20f2b63b51b4755b65b56324b0e7222 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/README.md b/Assets/StreamingAssets/OniimaiUI/README.md new file mode 100644 index 000000000..310dcb3b2 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/README.md @@ -0,0 +1,12 @@ +# Original MajdataPlay UI assets + +Unmodified assets copied from the same source tree so the native Android companion UI can use the actual game's artwork and font: + +- `newui@3x.png` → `Assets/Sprites/Ribbon/newui@3x.png` +- `xxlb.png` → `Assets/Sprites/List/xxlb.png` +- `Jua-Regular.ttf` → `Assets/Fonts/General/Jua-Regular.ttf` +- `AlimamaFangYuanTiVF-Thin-2.ttf` → `Assets/Fonts/General/AlimamaFangYuanTiVF-Thin-2.ttf` (Chinese) + +`Tools/Export-OniimaiUI.ps1` losslessly extracts `ui-*.png` from the original Unity sprite metadata (12288×12288, bottom-left coordinates). `GameAssets.java` decodes these small assets directly, avoiding large-atlas parsing when opening the settings. These are the original slate/gold settings card, plus/minus buttons, circular navigation pads, category pill, inner ring and profile card. Existing project and asset licenses apply; keep the original sources and license files when redistributing. + +The pale circle/ring/star/bowtie/cross background is rendered by `GameUi.Pattern`. Dynamic text, numbers and controls remain native interactive UI; screenshots are not used as UI backgrounds. diff --git a/Assets/StreamingAssets/OniimaiUI/README.md.meta b/Assets/StreamingAssets/OniimaiUI/README.md.meta new file mode 100644 index 000000000..e851ff320 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: c10e0959334ce7a409cd6f5c1ad99aa8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/newui@3x.png b/Assets/StreamingAssets/OniimaiUI/newui@3x.png new file mode 100644 index 000000000..6251f65a4 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/newui@3x.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/newui@3x.png.meta b/Assets/StreamingAssets/OniimaiUI/newui@3x.png.meta new file mode 100644 index 000000000..d87bb124c --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/newui@3x.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 403c03bed66d486a905a1065fce6d70d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-10.png b/Assets/StreamingAssets/OniimaiUI/ui-10.png new file mode 100644 index 000000000..21f46acc1 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-10.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-10.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-10.png.meta new file mode 100644 index 000000000..964834965 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-10.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: a157b3422d92442195623e7990743722 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-16.png b/Assets/StreamingAssets/OniimaiUI/ui-16.png new file mode 100644 index 000000000..0959f0675 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-16.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-16.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-16.png.meta new file mode 100644 index 000000000..2bcd3b7af --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-16.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: dcfbacca96c24712834924e50932602d +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-17.png b/Assets/StreamingAssets/OniimaiUI/ui-17.png new file mode 100644 index 000000000..f1d6eb6ed Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-17.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-17.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-17.png.meta new file mode 100644 index 000000000..48a245b09 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-17.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 22d0c0b7146248a9adc5dab027baf62f +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-18.png b/Assets/StreamingAssets/OniimaiUI/ui-18.png new file mode 100644 index 000000000..a4b1418f0 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-18.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-18.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-18.png.meta new file mode 100644 index 000000000..e7fc835f7 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-18.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7974a382a1a444dfa0c4f2812137a30c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-20.png b/Assets/StreamingAssets/OniimaiUI/ui-20.png new file mode 100644 index 000000000..f74644754 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-20.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-20.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-20.png.meta new file mode 100644 index 000000000..6e7fee88f --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-20.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 05a2c6c6476847d7af371cde6ae3fc63 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-21.png b/Assets/StreamingAssets/OniimaiUI/ui-21.png new file mode 100644 index 000000000..b8db7ee7f Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-21.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-21.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-21.png.meta new file mode 100644 index 000000000..1cb404a6f --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-21.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6544f68f37174986b54bd9de01f6cb31 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-22.png b/Assets/StreamingAssets/OniimaiUI/ui-22.png new file mode 100644 index 000000000..71977dd44 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-22.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-22.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-22.png.meta new file mode 100644 index 000000000..02ac0c412 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-22.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 9daba378ac8b4491bcafe61f849b72aa +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-26.png b/Assets/StreamingAssets/OniimaiUI/ui-26.png new file mode 100644 index 000000000..1b7f97240 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-26.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-26.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-26.png.meta new file mode 100644 index 000000000..6f3793f73 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-26.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 7b3cc083f3bb4a70a143b53d41b157e8 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/ui-6.png b/Assets/StreamingAssets/OniimaiUI/ui-6.png new file mode 100644 index 000000000..57c53181f Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/ui-6.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/ui-6.png.meta b/Assets/StreamingAssets/OniimaiUI/ui-6.png.meta new file mode 100644 index 000000000..fd47fcd9d --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/ui-6.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 6bb578e1b1c74c099e8dd3787cf0b70c +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Assets/StreamingAssets/OniimaiUI/xxlb.png b/Assets/StreamingAssets/OniimaiUI/xxlb.png new file mode 100644 index 000000000..a4959c977 Binary files /dev/null and b/Assets/StreamingAssets/OniimaiUI/xxlb.png differ diff --git a/Assets/StreamingAssets/OniimaiUI/xxlb.png.meta b/Assets/StreamingAssets/OniimaiUI/xxlb.png.meta new file mode 100644 index 000000000..bc2d4ffe8 --- /dev/null +++ b/Assets/StreamingAssets/OniimaiUI/xxlb.png.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: 511e9a8c99094d1aa769415bd1a4d7d4 +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/Doc/Oniimai/overview-dark.svg b/Doc/Oniimai/overview-dark.svg new file mode 100644 index 000000000..ac020e7d3 --- /dev/null +++ b/Doc/Oniimai/overview-dark.svg @@ -0,0 +1,94 @@ + + MajdataPlay Oniimai overview + Native Android integration. An Oniimai USB controller supplies touch and IO4 button input and receives RGB lighting. MajdataPlay renders the game on a rotated external display while the phone shows configurable statistics widgets. Rendering defaults to 60fps with an optional 120fps target; actual output refresh rate depends on hardware. + + + + + + + NATIVE ANDROID INTEGRATION + MajdataPlay Oniimai + USB input, RGB lighting and a separate phone dashboard. + + + Android 8.0+ · ARM64 + No root, LSPosed or NPatch required + CONNECTION OVERVIEW + + ONIIMAI CONTROLLER + + + + + + + + + + + + + 34 + touch zones + 8 + P1 + buttons + + Touch protocol + IO4 HID + + + USB + + + ANDROID GAME + MajdataPlay Oniimai + Native game integration + + USB bridge ↔ Unity + In-game setup + Saved input and display settings + + + + + + + + + + + + + External display + 90° / 270° rotation + Game output + + + + + + + + + + Phone dashboard + Widgets + live statistics + Results stay until exit + + + RGB: buttons · ring · speakers + + 60fps default · 120fps option* + + Korean + Simplified Chinese setup + * Actual refresh rate depends on the device, adapter and monitor. 120Hz output has not been verified. + diff --git a/Doc/Oniimai/overview-light.svg b/Doc/Oniimai/overview-light.svg new file mode 100644 index 000000000..33469c6b3 --- /dev/null +++ b/Doc/Oniimai/overview-light.svg @@ -0,0 +1,94 @@ + + MajdataPlay Oniimai overview + Native Android integration. An Oniimai USB controller supplies touch and IO4 button input and receives RGB lighting. MajdataPlay renders the game on a rotated external display while the phone shows configurable statistics widgets. Rendering defaults to 60fps with an optional 120fps target; actual output refresh rate depends on hardware. + + + + + + + NATIVE ANDROID INTEGRATION + MajdataPlay Oniimai + USB input, RGB lighting and a separate phone dashboard. + + + Android 8.0+ · ARM64 + No root, LSPosed or NPatch required + CONNECTION OVERVIEW + + ONIIMAI CONTROLLER + + + + + + + + + + + + + 34 + touch zones + 8 + P1 + buttons + + Touch protocol + IO4 HID + + + USB + + + ANDROID GAME + MajdataPlay Oniimai + Native game integration + + USB bridge ↔ Unity + In-game setup + Saved input and display settings + + + + + + + + + + + + + External display + 90° / 270° rotation + Game output + + + + + + + + + + Phone dashboard + Widgets + live statistics + Results stay until exit + + + RGB: buttons · ring · speakers + + 60fps default · 120fps option* + + Korean + Simplified Chinese setup + * Actual refresh rate depends on the device, adapter and monitor. 120Hz output has not been verified. + diff --git a/ONIIMAI-DEVELOPMENT.md b/ONIIMAI-DEVELOPMENT.md new file mode 100644 index 000000000..6f9a81fc8 --- /dev/null +++ b/ONIIMAI-DEVELOPMENT.md @@ -0,0 +1,422 @@ +# Developing MajdataPlay Oniimai + +For a code review, start with [Architecture](#architecture), then the +[frame-rate rationale](#why-rendering-is-locked-to-60-or-120fps) and +[review checklist](#what-an-upstream-reviewer-should-inspect). To build a test +APK, start with [Clone and build](#clone-and-build). + +## Review baseline + +| Item | Value | +| --- | --- | +| Upstream | [TeamMajdata/MajdataPlay](https://github.com/TeamMajdata/MajdataPlay) | +| Upstream base | Nightly `2.0.3-20260925-5e9c2aa`, commit `5e9c2aa9d0aec133c5dcfca9e0dbcc228d39416b` | +| Integration snapshot | Oniimai `0.2.18` | +| Android version | `2.0.3-oniimai.22`, version code `22` | +| Application ID | `net.majdata.majdataplay.oniimai` | +| Runtime | Android API 26+, ARM64, Unity IL2CPP, OpenGL ES 3 | +| Base branch | `upstream-nightly-20260925` (pinned upstream Nightly commit) | +| Integration branch | `main` | + +This public fork preserves the original commit ancestry. `main` contains the +Oniimai integration; `dev` is the upstream branch copied when the fork was created. +The APK is published in the [Oniimai 0.2.18 release](https://github.com/kyarameru0/MajdataPlay-Oniimai/releases/tag/oniimai-v0.2.18). +No upstream PR has been opened. The integration is kept separate for review first. + +The 0.2.18 integration uses the latest published upstream build available on +September 27, 2026, while preserving the Oniimai integration. The moving `dev` +branch already contains newer, unreleased commits; those are not the published +build selected for this update. The pinned `upstream-nightly-20260925` branch is +the comparison base. The Nightly adds renderer changes, render-scale settings, +chart radar, and updated HidSharp/UniTask dependencies, including the MajRadar +submodule. Build outputs, signing keys, and device logs are not committed. + +## Clone and build + +Clone the public fork, including its dependencies: + +```powershell +git clone --recurse-submodules https://github.com/kyarameru0/MajdataPlay-Oniimai.git +cd MajdataPlay-Oniimai +git remote add upstream https://github.com/TeamMajdata/MajdataPlay.git +``` + +For an existing clone, run `git submodule update --init --recursive`. Submodules +remain pinned Git dependencies rather than copies of their expanded working trees. +Git LFS is needed for historical LFS assets if you check out older revisions. + +Install the Unity version in `ProjectSettings/ProjectVersion.txt` (**6000.3.17f1**) +with Android Build Support, OpenJDK, Android SDK, and NDK. Use this Editor's +supported tools: JDK 17, NDK r27c (`27.2.12479018`), SDK Build Tools 36, and SDK +Platform 35/36. Check **Preferences → External Tools → Android** if the Editor +reports a missing JDK or incompatible NDK. Activate your own Unity license. This +repository contains no Unity credentials or signing keys. + +Open the project in Unity, allow package imports to finish, and select +**MajdataPlay → Build Oniimai Android**. Alternatively, close the editor for this +project and run: + +```powershell +./Tools/Build-Oniimai.ps1 ` + -UnityEditor 'C:/Program Files/Unity/Hub/Editor/6000.3.17f1/Editor/Unity.exe' ` + -Output './Build/Oniimai-MajdataPlay.apk' +``` + +The script writes `Build/Oniimai-build.log`. `OniimaiBuild.Android` runs the C# +regression checks and configures an ARM64 IL2CPP APK with OpenGL ES 3, portrait +orientation, and Unity Android frame pacing disabled. It uses the default debug +signing configuration for local testing. Configure your own signing setup before +distributing a production build; never commit a private keystore. + +The whole game must be built with Unity. The optional .NET command below builds +only the isolated, Unity-independent regression harness. + +## Architecture + +```mermaid +flowchart LR + USB[Oniimai USB controller] --> Transport[UsbIo and Protocol] + Transport --> Java[Java OniimaiController] + Java --> Snapshot[InputSnapshot] + Snapshot --> Bridge[C# OniimaiController and OniimaiFrame] + Bridge --> Input[Unity InputManager] + Lights[Unity CabinetLed] --> Bridge + Bridge --> LED[LedOutput and CeilingOutput] + LED --> USB + Stats[Unity gameplay and results] --> Bridge + Bridge --> Dashboard[Native phone dashboard] + Surface[Unity render surface] --> Display[OniimaiDisplay] + Display --> Monitor[External monitor] + Settings[OniimaiPanel and SharedPreferences] --> Java + Settings --> Display +``` + +The Java and C# bridges share a class name but have different responsibilities. +The C# side runs inside Unity; the Java side owns Android lifecycle and hardware. + +| Area | Main source | Responsibility | +| --- | --- | --- | +| Activity lifecycle | `Assets/Plugins/Android/src/java/net/majdata/majdataplay/MajdataPlayActivity.java` | Creates and resumes the controller/display bridge | +| Android bridge | `Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiController.java` | Device state, settings, input snapshots, light output, and native UI | +| USB | `oniimai/UsbIo.java`, `Protocol.java`, `PortSelection.java` | Transport, frame parsing, and saved/named port selection | +| Input merge | `Assets/Scripts/IO/OniimaiController.cs`, `OniimaiFrame.cs` | `MergeInput` combines physical input with the game's input path | +| LED | `oniimai/LedOutput.java`, `LedChannel.java`, `CeilingOutput.java`, `Io4Output.java` | Latest-frame delivery, acknowledgments, reconnect, and speaker RGB | +| Display | `oniimai/OniimaiDisplay.java`, `DisplayGeometry.java`, `OutputMode.java` | Presentation, rotated output surface, refresh-mode request, and phone return | +| Dashboard | `oniimai/DashboardView.java`, `DashboardLayout.java`, `DashboardWidget.java` | Widget layout, editing, state persistence, and real game statistics | +| Results | `Assets/Scripts/IO/OniimaiStatsRetention.cs` | Holds final statistics through results and clears them on exit | +| Settings | `oniimai/OniimaiPanel.java`, `InitialSetup.java`, `SetupDefaults.java` | In-game settings and first-run configuration | +| UI foundation | `oniimai/GameUi.java`, `GameAssets.java`, `UiTextCatalog.java` | Shared native View styles, original game assets, and localization | + +`oniimai/` in the table is relative to +`Assets/Plugins/Android/src/java/net/majdata/majdataplay/`. + +### 1. A physical press becomes game input + +USB reader callbacks update `InputSnapshot`, which records both the current +state and newly pressed edges. The Unity input path calls the Java bridge's +`poll()` through JNI. `OniimaiFrame.Merge` combines the returned physical input +with the existing phone input instead of dispatching Android screen taps. + +```mermaid +sequenceDiagram + participant Device as Oniimai controller + participant USB as USB reader + participant State as InputSnapshot + participant Unity as Unity input path + participant Game as Game input consumers + Device->>USB: Touch packet or IO4 HID report + USB->>State: Update held state and latch new presses + loop Each game input sample + Unity->>State: JNI poll calls read(now) + State-->>Unity: Atomic long[5] snapshot + Note over State: Consumed press edges are cleared + Unity->>Unity: Merge physical and phone input + Unity->>Game: Normal game input state + end +``` + +The JNI snapshot is an internal Java/C# contract, not the controller's USB packet: + +| Index | Meaning | +| --- | --- | +| `frame[0]` | Eight button bits plus auxiliary P1; held state includes latched short presses | +| `frame[1]` | 34 raw touch-sensor bits, including both center halves | +| `frame[2]` | Newly pressed button bits since the previous game read | +| `frame[3]` | Newly pressed touch bits since the previous game read | +| `frame[4]` | Status/option flags: `1` touch connected, `2` buttons connected, `4` external output active, `8` P1 maps to A4, `16` selects 120fps | + +Core merge logic from [OniimaiFrame.cs](Assets/Scripts/IO/OniimaiFrame.cs): + +```csharp +buttons[i] |= (frame[0] & (1L << i)) != 0; +if ((frame[2] & (1L << i)) != 0) + buttonClicks[i] = Math.Max(1, buttonClicks[i]); +``` + +The edge latch preserves a quick tap that begins and ends between game samples. +`Math.Max` avoids counting the same merged press twice. The existing game path +combines C1/C2 into its single center zone; changing center halves while holding +does not create another click. The sensor dashboard calls `diagnostic()` instead +of `read()`, so viewing the sensors cannot steal gameplay edges. + +Input is gated while disabled, out of focus, or in the controller panel. After +such a transition the user must release the controls before presses are accepted +again. Touch/HID reports older than 500ms expire, preventing a disconnected +controller from leaving a held input active. Start review with +[InputSnapshot.java](Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/InputSnapshot.java) +and [InputManager.DummyInput.cs](Assets/Scripts/IO/InputManager/RawDeviceHandle/InputManager.DummyInput.cs). + +### 2. Game lighting reaches the hardware + +Unity's existing `CabinetLed` output is forwarded through `SubmitLights` as nine +packed RGB integers: eight button colors and the cabinet brightness represented +as a grayscale color. Java's `lights()` submits that state to `LedOutput` and +`CeilingOutput`. + +```mermaid +flowchart LR + Game[Unity CabinetLed] --> Pack[SubmitLights: int array of 9 colors] + Pack --> Latest[Java lights: replace latest state] + Latest --> Serial[LED worker and LedChannel] + Serial --> Ring[Button and ring LED board] + Latest --> Average[Average eight colors and apply brightness] + Average --> HID[Ceiling worker and Io4Output] + HID --> Speakers[Upper-speaker RGB] +``` + +The dedicated workers run on approximately 33ms intervals. They keep the latest +state instead of accumulating an unbounded queue of stale animation frames. +The serial LED channel validates the board response and acknowledgments. On an +error it closes that LED channel and retries after 1, 2, 4, then at most 8 seconds; +it does not deliberately close touch input to recover lighting. Reconnection +resends current light state. Backgrounding blanks the lights, and new LED opens +wait until foreground. Explicit stop/destroy invalidates pending work using a +generation counter. + +Start review with [LedOutput.java](Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedOutput.java), +[LedChannel.java](Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/LedChannel.java), +and [CeilingOutput.java](Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/CeilingOutput.java). + +### 3. The external monitor and phone have different jobs + +The game renders its portrait buffer to an Android `Presentation` surface on the +external display. A native Android dashboard occupies the phone screen. This +requires a display exposed by Android; it is not a screen-capture mirroring app. +The preferred route rotates/scales an app-owned SurfaceControl layer, while a +TextureView path provides compatibility where the preferred route is unavailable. + +```mermaid +sequenceDiagram + participant Activity as Android Activity + participant Display as OniimaiDisplay + participant Unity as Unity player + participant Monitor as External Presentation + participant Phone as Phone dashboard + Activity->>Display: Resume or display connection changes + Display->>Monitor: Create presentation and output surface + Monitor-->>Display: Surface available + Display->>Unity: oniimaiSurface(externalSurface) + Display->>Phone: Show native dashboard + Note over Unity,Monitor: Game frames use the render surface directly + Activity->>Display: Switch to phone, pause, or monitor removed + Display->>Unity: oniimaiSurface(null) + Note over Display,Unity: Detach producer before releasing surface + Display->>Monitor: Release surface and dismiss + Display->>Phone: Remove dashboard and restore phone game +``` + +The surface teardown ordering is deliberate: destroying the consumer before +detaching Unity can disrupt rendering. Ordinary rotation/size changes update the +transform without intentionally recreating the Unity output surface. A draggable +shortcut lets the user return to external output after switching to the phone. +Review [OniimaiDisplay.java](Assets/Plugins/Android/src/java/net/majdata/majdataplay/oniimai/OniimaiDisplay.java) +alongside [MajdataPlayActivity.java](Assets/Plugins/Android/src/java/net/majdata/majdataplay/MajdataPlayActivity.java). + +### 4. Dashboard statistics survive the result screen + +`GameUpdater` calls `UpdateStats`, throttled to 100ms; the native dashboard refreshes +about every 150ms. Statistics use compact JSON, and cover/graph PNGs are sent only +when artwork changes. Live game frames are not copied into dashboard widgets. + +`CaptureResult` saves final game-calculated judgments and score before gameplay +objects and textures are destroyed. `OniimaiStatsRetention` holds that exact +snapshot in Result/TotalResult and clears it on leaving results or entering a new +game session, including retry/practice. Course results display the last song's +record rather than inventing an aggregate. The retention state machine is +independent of whether an external monitor is currently connected. + +## Rules to preserve when changing the code + +- Keep Android-only Unity hooks behind the existing platform guards. Desktop + input and lighting paths must keep their original behavior. +- Preserve press/release edges in input snapshots. Diagnostic widgets must not + consume the gameplay input queue. Closing settings requires release before + gameplay input becomes active again. +- Select the named Touch interface for touch input, not Command or NFC. Defaults + must not overwrite a user's saved choices. P1 defaults to the game's P1 action. +- Keep LED I/O off the UI thread. Recover the LED channel independently of touch + input, send only the newest state, and cancel retries on deliberate disconnect. +- Detach Unity before releasing an output surface. Rotation/size changes should + avoid unnecessarily rebuilding the surface. Android 10+ uses an app-owned + SurfaceControl path where available; the compatibility path uses TextureView. +- Keep the requested game frame rate separate from the actual monitor refresh + rate. The default is 60fps. A 120fps choice requests a supported monitor mode; + a failed 120Hz request falls back to 60Hz without erasing the user's preference. +- Capture final statistics before gameplay objects are destroyed. Retain them in + Result/TotalResult and clear them when leaving results or starting a new run. +- Keep UI artwork static and small. Transfer updated artwork only when it changes; + avoid rendering or decoding a full dashboard on every game frame. + +The upper-speaker RGB color is derived from the average of the eight game button +colors because this base has no separate ceiling RGB game signal. This integration +does **not** implement Aime/NFC login; the base project uses its existing login +flow, and the NFC interface is excluded from automatic port assignment. + +## Why rendering is locked to 60 or 120fps + +In the tested Oniimai phone/adapter/monitor setup, running without a fixed frame +target produced frame drops and uneven motion on the external display, while +the phone display could still look smooth. The fixed-rate option exists to +address this observed external-output problem. **60fps is the default**; +**120fps is available for a compatible 120Hz display setup**. + +`OniimaiController.MergeInput` receives the saved frame-rate choice with the +input snapshot and updates `Application.targetFrameRate` when that choice +changes. Android's `DisplayOptions.FPSLimit` reads the same value, so upstream +screen/display settings do not restore an unrelated profile FPS limit. +`OniimaiDisplay` separately requests a supported display mode through +`OutputMode`; the render target (fps) and actual display refresh (Hz) are not +interchangeable. A failed 120Hz mode request falls back to 60Hz while retaining +the selected preference for a future compatible display. + +A target does not guarantee every frame meets its deadline, and a 60Hz output +cannot show 120 distinct frames per second. Keep the actual refresh-rate status +visible. Unity Android optimized frame pacing remains disabled in this variant; +the 60/120fps lock is a separate setting and does not enable that Unity option. + +## Regression tests + +Pass the path to a JDK 17+ installation (Unity's bundled OpenJDK is suitable): + +```powershell +./Tools/OniimaiTests/run.ps1 -JavaHome 'C:/path/to/OpenJDK' +./Tools/OniimaiTests/run-hid.ps1 -JavaHome 'C:/path/to/OpenJDK' +``` + +The first command tests protocol parsing, LED acknowledgments/recovery, input +snapshots, defaults, port selection, geometry, output modes, and widget layouts. +The second compiles the actual HID transport against test fixtures; it does not +require a physical USB controller. + +With the .NET 8 SDK installed, `run.ps1` also runs the frame-merge and result +retention tests. They can be invoked separately: + +```powershell +dotnet run --project Tools/OniimaiTests/FrameMerge/FrameMerge.csproj --configuration Release +``` + +Without a .NET SDK, the same C# checks run at the beginning of the Unity build. + +### Validation and limits + +**0.2.18 / September 27, 2026:** the merged Nightly source passed the following: + +- 9,027 Java regression assertions and 33 HID transport assertions. +- 186 C# frame-merge checks and 225 result-retention checks, run inside Unity. +- Upstream `RawSpriteDrawModeValidation`, covering mesh geometry and resource + behavior. This does not compare rendered images against SpriteRenderer. +- A complete Unity 6000.3.17f1 ARM64 IL2CPP / OpenGL ES 3 Android APK build. +- APK v2 signature and ZIP CRC verification; package + `net.majdata.majdataplay.oniimai`, version code `22`. +- All 42 exported Java source files matched the source used for the build. + +Released APK: `MajdataPlay-Oniimai-0.2.18.apk`. It is byte-for-byte identical to +the `MajdataPlay-Oniimai-0.2.18-test.apk` tested by the maintainer. APKs are attached +to the release rather than committed to the source tree. + +SHA-256: `33d08cf93c0fabdd46693710b1439cf90e143dd7af2d26de4867e02f71150ebd` + +On September 27, 2026, the maintainer reported that this exact test APK worked +normally on their device setup. The artifact was built from commit +`064cdf5320f6627f5d01d497c96d2f89a2241c90`; the release source differs only in +README/developer-guide publication text. Its v2 signature, ZIP CRC, and matching +signing certificate with the previous test build were verified. The Unity build +reran all 186 input and 225 retention checks. + +This confirmation is not a completed per-device compatibility matrix. It does +not establish working 120Hz output or every hotplug/lifecycle case listed below. + +#### Historical 0.2.17 evidence + +During the private repository import, the published checkout passed all 9,027 +Java regression assertions and 33 HID transport assertions. All 161 integration +source/asset/tool files were compared with the 0.2.17 source archive, and all four +submodule revisions matched. The C# harness was not rerun during this import +because no .NET SDK was installed; no new Unity APK was built for this source-only +upload. The following build and device results are from the prior 0.2.17 record. + +The 0.2.17 development record reports a successful ARM64 IL2CPP build, APK +signature/ZIP checks, 9,027 Java assertions, 186 C# frame checks, and 225 C# result +retention checks. Xiaomi device testing covered saved 60/120fps selection, fallback +to the actual 60Hz external mode, controller input, and lighting. + +The available monitor advertised 1080p120 but did not apply the requested mode; +it returned to 4K60. This is not evidence of working 120Hz output or a guarantee +of stutter-free play. A new Unity build and hardware pass are required after +substantive changes, especially surface lifecycle or USB changes. + +Before proposing upstream inclusion, also test a fresh installation, an upgrade +with saved settings, USB detach/reattach, monitor detach/reattach, both rotations, +background/resume, result-screen retention, and desktop behavior. + +## What an upstream reviewer should inspect + +| Review area | Relevant change or evidence | +| --- | --- | +| Platform boundary | Android Activity/JNI hooks and `UNITY_ANDROID && !UNITY_EDITOR` guards; verify desktop and iOS behavior separately | +| Input correctness | Edge latching, center merge, focus/settings gate, P1 mapping; Java snapshot tests and C# frame checks | +| USB recovery | Saved device identity, permission flow, detach handling, LED-only retries, and IO4 output serialization | +| Output lifecycle | Unity surface detachment before release, monitor hotplug, both rotations, return-to-phone behavior | +| Frame target | Default 60fps, saved 120fps choice, actual-Hz reporting and unsupported-mode fallback | +| Statistics | Final score capture before teardown, retention through results, reset on exit/retry, artwork lifetime | +| Rendering update | Upstream Nightly renderer/quality changes are inherited; compare against the pinned Nightly base to isolate Oniimai changes | +| Packaging | Separate application ID, version code 22, debug signing for this test build, no keys or local logs in Git | + +For a future PR description, summarize the user-facing behavior first: an Android +Oniimai controller can drive the game's normal inputs and lighting while an +external display shows the game and the phone shows statistics. Link the diagrams +above, state the exact upstream base, and list automated/build checks separately +from physical-device checks. Ask upstream maintainers which defaults and package +identifier they want before proposing their production release configuration. + +## Preparing an upstream PR later + +Review this repository's `upstream-nightly-20260925...main` comparison first. +Keep the dated base branch unchanged so the integration remains easy to compare. + +The public fork is ready for review; opening a PR is a separate step. When that +work begins, prepare a feature branch against the current upstream contribution +branch (`dev` at publication time): + +```powershell +git fetch origin main upstream-nightly-20260925 +git fetch upstream dev +git diff --binary --output=../oniimai-review.patch origin/upstream-nightly-20260925 origin/main +git switch -c oniimai-upstream upstream/dev +git apply --3way --index ../oniimai-review.patch +``` + +The patch contains only the integration delta from the pinned Nightly base. +Resolve conflicts against the current upstream version and run the tests/build again. +Do not assume the pinned Nightly integration will apply unchanged to a later +`dev`. Adapt fork-specific download links, versioning, and README text for the +upstream review before submitting. Never force-push this fork over upstream. + +## Licensing and authorship + +Keep the upstream GPL-3.0 license and all existing attribution, submodule license +files, and asset notices. Android companion art and fonts originate in this same +MajdataPlay tree; see the [asset provenance note](Assets/StreamingAssets/OniimaiUI/README.md). +The Oniimai additions were generated and revised with OpenAI Codex under human +direction. AI authorship does not replace code review, testing, or the upstream +license obligations. diff --git a/ProjectSettings/ProjectSettings.asset b/ProjectSettings/ProjectSettings.asset index c6fae1ed4..f2f2310f9 100644 --- a/ProjectSettings/ProjectSettings.asset +++ b/ProjectSettings/ProjectSettings.asset @@ -8,12 +8,12 @@ PlayerSettings: AndroidProfiler: 0 AndroidFilterTouchesWhenObscured: 0 AndroidEnableSustainedPerformanceMode: 0 - defaultScreenOrientation: 4 + defaultScreenOrientation: 0 targetDevice: 2 useOnDemandResources: 0 accelerometerFrequency: 0 companyName: bbben - productName: MajdataPlay + productName: MajdataPlay Oniimai defaultCursor: {fileID: 0} cursorHotspot: {x: 0, y: 0} m_SplashScreenBackgroundColor: {r: 0.13725491, g: 0.12156863, b: 0.1254902, a: 1} @@ -72,7 +72,7 @@ PlayerSettings: androidStartInFullscreen: 1 androidRenderOutsideSafeArea: 1 androidUseSwappy: 0 - androidDisplayOptions: 1 + androidDisplayOptions: 0 androidBlitType: 0 androidResizeableActivity: 0 androidDefaultWindowWidth: 1920 @@ -146,8 +146,9 @@ PlayerSettings: loadStoreDebugModeEnabled: 0 visionOSBundleVersion: 1.0 tvOSBundleVersion: 1.0 - bundleVersion: 2.0.3 - preloadedAssets: [] + bundleVersion: 2.0.3-oniimai.22 + preloadedAssets: + - {fileID: 11400000, guid: ea1bf0f280290291a848cb19cf872ab0, type: 2} metroInputSource: 0 wsaTransparentSwapchain: 0 m_HolographicPauseOnTrackingLoss: 1 @@ -169,7 +170,7 @@ PlayerSettings: androidMaxAspectRatio: 2.1 androidMinAspectRatio: 1 applicationIdentifier: - Android: net.majdata.majdataplay + Android: net.majdata.majdataplay.oniimai Standalone: com.DefaultCompany.2DProject iPhone: net.majdata.majdataplay buildNumber: @@ -178,9 +179,9 @@ PlayerSettings: iPhone: 1 tvOS: 0 overrideDefaultApplicationIdentifier: 1 - AndroidBundleVersionCode: 14 - AndroidMinSdkVersion: 25 - AndroidTargetSdkVersion: 29 + AndroidBundleVersionCode: 22 + AndroidMinSdkVersion: 26 + AndroidTargetSdkVersion: 35 AndroidPreferredInstallLocation: 1 AndroidPreferredDataLocation: 1 aotOptions: @@ -270,7 +271,7 @@ PlayerSettings: useCustomGradlePropertiesTemplate: 0 useCustomGradleSettingsTemplate: 0 useCustomProguardFile: 0 - AndroidTargetArchitectures: 3 + AndroidTargetArchitectures: 2 AndroidAllowedArchitectures: -1 AndroidSplashScreenScale: 0 androidSplashScreen: {fileID: 0} @@ -278,7 +279,7 @@ PlayerSettings: AndroidKeyaliasName: default AndroidEnableArmv9SecurityFeatures: 0 AndroidEnableArm64MTE: 0 - AndroidBuildApkPerCpuArchitecture: 1 + AndroidBuildApkPerCpuArchitecture: 0 AndroidTVCompatibility: 0 AndroidIsGame: 1 androidAppCategory: 3 @@ -287,7 +288,7 @@ PlayerSettings: AndroidEnableTango: 0 androidEnableBanner: 1 androidUseLowAccuracyLocation: 0 - androidUseCustomKeystore: 1 + androidUseCustomKeystore: 0 m_AndroidBanners: - width: 320 height: 180 @@ -501,6 +502,9 @@ PlayerSettings: - m_BuildTarget: Standalone m_StaticBatching: 1 m_DynamicBatching: 0 + - m_BuildTarget: Android + m_StaticBatching: 1 + m_DynamicBatching: 0 m_BuildTargetShaderSettings: [] m_BuildTargetGraphicsJobs: - m_BuildTarget: MacStandaloneSupport @@ -532,7 +536,7 @@ PlayerSettings: m_BuildTargetGraphicsJobMode: [] m_BuildTargetGraphicsAPIs: - m_BuildTarget: AndroidPlayer - m_APIs: 150000000b000000 + m_APIs: 0b000000 m_Automatic: 0 - m_BuildTarget: iOSSupport m_APIs: 10000000 @@ -868,7 +872,7 @@ PlayerSettings: il2cppCodeGeneration: {} il2cppStacktraceInformation: {} managedStrippingLevel: - Android: 1 + Android: 4 EmbeddedLinux: 1 GameCoreScarlett: 1 GameCoreXboxOne: 1 diff --git a/README.md b/README.md index 0f7f42b87..701f993d4 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,83 @@ -# MajdataPlay +# MajdataPlay Oniimai + + + + + Oniimai USB touch and IO4 buttons connect to MajdataPlay on Android, with RGB feedback, rotated external game output, and a separate phone dashboard. + + +## Overview + +This repository contains **MajdataPlay Oniimai 0.2.18**, based on the latest +published upstream Nightly checked on September 27, 2026: +[2.0.3-20260925-5e9c2aa](https://github.com/TeamMajdata/MajdataPlay/tree/5e9c2aa9d0aec133c5dcfca9e0dbcc228d39416b). +The upstream base is a Nightly snapshot; upstream's latest stable release remains +2.0.3 as of that date. This is an **unofficial public fork** of TeamMajdata/MajdataPlay +with the original Git history preserved. The integration runs inside the Android +game; it does not require LSPosed, NPatch, or root. + +**[Download the Oniimai Android APK](https://github.com/kyarameru0/MajdataPlay-Oniimai/releases/tag/oniimai-v0.2.18)** +— Android 8.0+ / ARM64. The maintainer confirmed normal operation of this exact +APK on their test setup. Install it as a separate Oniimai app; it can also update +an existing Oniimai installation signed with the same key. + +- USB touch input, IO4 HID buttons and P1, saved device selection, and sensitivity settings. +- Game-driven button/ring LEDs and upper-speaker RGB output. +- Rotated external game output with a separate, customizable phone dashboard. +- First-run setup in Korean or Simplified Chinese, saved widget layouts, and result retention. +- A 60fps default with a 120fps option and supported external display mode requests. + Actual refresh rate depends on the phone, adapter, and monitor; 120Hz output has + not been verified on the available hardware. + +### Why the 60/120fps lock exists + +Without a fixed frame-rate target, frame drops and uneven motion were observed +on the external display in the tested Oniimai setup, even when the phone's own +display appeared smooth. The integration therefore defaults to a **60fps lock** +and offers a **120fps lock** for a compatible 120Hz display setup. + +The option sets the game's render target and requests the corresponding external +60/120Hz mode. It cannot force an unsupported refresh rate: the UI reports the +actual monitor rate separately, and an unsuccessful 120Hz request falls back to +60Hz. This is a workaround for the observed setup, not a guarantee that every +phone, USB-C adapter, or monitor will sustain the selected rate. + +### How it works + +An Android bridge reads the controller's USB interfaces and passes input snapshots +to Unity through JNI. The game uses its normal input/judgment path and sends its +lighting state back to dedicated USB workers. External output uses a separate +Android Presentation, leaving the phone available for native statistics widgets. + +```mermaid +flowchart LR + Controller[Oniimai USB controller] <--> Android[Android USB bridge] + Android <--> Unity[Unity input and lighting] + Unity --> Surface[External game surface] + Surface --> Monitor[External monitor] + Unity --> Stats[Statistics and artwork] + Stats --> Phone[Native phone dashboard] +``` + +The [developer guide](ONIIMAI-DEVELOPMENT.md#architecture) walks through each +flow, the input snapshot contract, surface lifecycle, result retention, and the +source files and tests to inspect before an upstream PR. + +| Start here | Purpose | +| --- | --- | +| [Developer guide](ONIIMAI-DEVELOPMENT.md) | Clone, build, architecture, tests, and preparation for an upstream PR | +| [Asset provenance](Assets/StreamingAssets/OniimaiUI/README.md) | Original MajdataPlay artwork and fonts used by the Android UI | +| [Review diff](https://github.com/kyarameru0/MajdataPlay-Oniimai/compare/upstream-nightly-20260925...main) | Oniimai changes against the pinned upstream Nightly base | + +The Oniimai additions were written and iterated with **OpenAI Codex (AI-generated +code)** and human-directed device testing. This statement does not describe the +authorship of the upstream project or its third-party dependencies. The original +[GPL-3.0 license](LICENSE) and dependency/asset notices are retained. + +The instructions below describe the upstream project. Use the developer guide +above to build the Oniimai variant (`net.majdata.majdataplay.oniimai`). + +--- ![license GPL-3.0](https://img.shields.io/badge/license-GPL--3.0-blue) ![GitHub Release](https://img.shields.io/github/v/release/LingFeng-bbben/MajdataPlay) diff --git a/Tools/Build-Oniimai.ps1 b/Tools/Build-Oniimai.ps1 new file mode 100644 index 000000000..76a8d887d --- /dev/null +++ b/Tools/Build-Oniimai.ps1 @@ -0,0 +1,16 @@ +param( + [Parameter(Mandatory=$true)][string]$UnityEditor, + [string]$Output = '' +) +$ErrorActionPreference = 'Stop' +$project = (Resolve-Path "$PSScriptRoot/..").Path +if (-not $Output) { $Output = Join-Path $project 'Build/Oniimai-MajdataPlay.apk' } +$env:ONIIMAI_APK = [System.IO.Path]::GetFullPath($Output) +New-Item -ItemType Directory -Force (Split-Path $env:ONIIMAI_APK) | Out-Null +$log = Join-Path $project 'Build/Oniimai-build.log' +New-Item -ItemType Directory -Force (Split-Path $log) | Out-Null +$arguments = @('-batchmode','-nographics','-quit','-projectPath',('"' + $project + '"'),'-buildTarget','Android','-executeMethod','OniimaiBuild.Android','-logFile',('"' + $log + '"')) +$process = Start-Process -FilePath $UnityEditor -ArgumentList $arguments -WindowStyle Hidden -Wait -PassThru +if ($process.ExitCode -ne 0 -or -not (Test-Path -LiteralPath $env:ONIIMAI_APK)) { throw "Unity build failed. See $log" } +Write-Output "APK: $env:ONIIMAI_APK" +Write-Output "Log: $log" diff --git a/Tools/Export-OniimaiUI.ps1 b/Tools/Export-OniimaiUI.ps1 new file mode 100644 index 000000000..c69e9059d --- /dev/null +++ b/Tools/Export-OniimaiUI.ps1 @@ -0,0 +1,20 @@ +# Lossless extraction of the game's existing sprite regions for fast Android decoding. +# Outputs are committed with the source; running this is not required for normal builds. +$ErrorActionPreference='Stop' +Add-Type -AssemblyName System.Drawing +$root=(Resolve-Path "$PSScriptRoot/..").Path +$source=Join-Path $root 'Assets/Sprites/Ribbon/newui@3x.png' +$target=Join-Path $root 'Assets/StreamingAssets/OniimaiUI' +$atlas=[System.Drawing.Bitmap]::FromFile($source) +try { + $meta=Get-Content -LiteralPath ($source+'.meta') -Raw + $regions=[regex]::Matches($meta,'name: newui@3x_(\d+)\s+rect:\s+serializedVersion: 2\s+x: (\d+)\s+y: (\d+)\s+width: (\d+)\s+height: (\d+)') + foreach($region in $regions){ + $id=[int]$region.Groups[1].Value + if($id -notin @(6,10,16,17,18,20,21,22,26)){continue} + $x=[int]$region.Groups[2].Value;$y=[int]$region.Groups[3].Value;$w=[int]$region.Groups[4].Value;$h=[int]$region.Groups[5].Value + $rect=[System.Drawing.Rectangle]::new($x,($atlas.Height-$y-$h),$w,$h) + $sprite=$atlas.Clone($rect,[System.Drawing.Imaging.PixelFormat]::Format32bppArgb) + try{$sprite.Save((Join-Path $target "ui-$id.png"),[System.Drawing.Imaging.ImageFormat]::Png)}finally{$sprite.Dispose()} + } +}finally{$atlas.Dispose()} diff --git a/Tools/OniimaiTests/CeilingOutputTest.java b/Tools/OniimaiTests/CeilingOutputTest.java new file mode 100644 index 000000000..607910b8c --- /dev/null +++ b/Tools/OniimaiTests/CeilingOutputTest.java @@ -0,0 +1,47 @@ +package net.majdata.majdataplay.oniimai; + +import java.util.Arrays; +import java.util.concurrent.atomic.AtomicReference; + +public final class CeilingOutputTest { + static int checks; + static void check(boolean ok, String label) { checks++; if (!ok) throw new AssertionError(label); } + static void await(java.util.function.BooleanSupplier condition, String label) throws Exception { + long end=System.currentTimeMillis()+3000; + while (!condition.getAsBoolean() && System.currentTimeMillis() port=new AtomicReference<>(new UsbIo.Hid()); + CeilingOutput output=new CeilingOutput(port::get); + java.util.concurrent.ScheduledExecutorService producer=java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + producer.scheduleWithFixedDelay(()->output.submit(frame),0,100,java.util.concurrent.TimeUnit.MILLISECONDS); + try { + output.settings(true,50); + await(()->port.get().rgb==0x102040,"game colour + brightness reaches IO4"); + int n=port.get().writes; Thread.sleep(140); check(n==port.get().writes,"unchanged colours not resent"); + output.test(0xff0000); await(()->port.get().rgb==0x800000,"two-second speaker-only test"); + await(()->port.get().rgb==0x102040,"test automatically returns to current game"); + output.foreground(false); await(()->port.get().rgb==0,"background clears speaker"); + output.foreground(true); await(()->port.get().rgb==0x102040,"foreground restores latest colour"); + output.settings(false,50); await(()->port.get().rgb==0,"master/speaker OFF clears output"); + output.settings(true,100); await(()->port.get().rgb==0x204080,"re-enable uses saved brightness"); + producer.shutdownNow(); await(()->port.get().rgb==0,"stale game data clears speaker"); + UsbIo.Hid failed=port.get(); failed.fail=true; output.submit(frame); + await(()->output.summary().contains("확인 필요"),"output failure is reported"); + check(!failed.closed,"output failure never closes button input"); + UsbIo.Hid replacement=new UsbIo.Hid(); port.set(replacement); output.submit(frame); + await(()->replacement.rgb==0x204080,"new IO4 handle restores latest colour"); + output.destroy(); await(()->replacement.rgb==0,"destroy clears RGB"); + check(!replacement.closed,"ceiling worker does not own input handle"); + } finally { producer.shutdownNow(); output.destroy(); } + System.out.println("PASS: "+checks+" speaker RGB lifecycle checks"); + } +} diff --git a/Tools/OniimaiTests/ChannelTest.java b/Tools/OniimaiTests/ChannelTest.java new file mode 100644 index 000000000..7a159c0fb --- /dev/null +++ b/Tools/OniimaiTests/ChannelTest.java @@ -0,0 +1,33 @@ +package net.majdata.majdataplay.oniimai; +import java.io.*; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; + +public final class ChannelTest { + static int checks; + static void check(boolean value,String label){checks++;if(!value)throw new AssertionError(label);} + static byte[] reply(int cmd,int status,byte[] data){ByteArrayOutputStream out=new ByteArrayOutputStream();out.write(0x53);byte[] body=new byte[data.length+3];body[0]=(byte)cmd;body[1]=(byte)status;body[2]=(byte)data.length;System.arraycopy(data,0,body,3,data.length);for(byte b:body){if(b==0x53||b==0x7c)out.write(0x7c);out.write(b);}return out.toByteArray();} + public static void main(String[] args)throws Exception { + UsbIo.Cdc port=new UsbIo.Cdc();AtomicInteger debug=new AtomicInteger(); + CommandChannel channel=new CommandChannel(port,(b,n)->debug.incrementAndGet(),s->{},s->{}); + port.onWrite=packet->{port.receive(reply(0x12,0,new byte[144]));port.receive(reply(0x77,0,new byte[]{7}));port.receive(reply(0x20,0,new byte[]{83,124}));}; + check(Arrays.equals(channel.request(0x20),new byte[]{83,124}),"Debug and unrelated replies cannot complete configuration request"); + check(debug.get()==1,"Debug independently dispatched"); + port.onWrite=packet->port.receive(reply(0x21,3,new byte[0])); + try{channel.request(0x21);throw new AssertionError("Device error must throw");}catch(IOException expected){check(expected.getMessage().contains("3"),"Device status propagated");} + port.onWrite=packet->port.receive(reply(0x20,0,new byte[]{1}));check(channel.request(0x20)[0]==1,"Can retry after explicit error response");channel.close(); + ExecutorService executor=Executors.newSingleThreadExecutor(); + UsbIo.Cdc cancelled=new UsbIo.Cdc();CommandChannel closeable=new CommandChannel(cancelled,(b,n)->{},s->{},s->{});CountDownLatch written=new CountDownLatch(1);cancelled.onWrite=packet->written.countDown(); + Future pending=executor.submit(()->{try{closeable.request(0x20);return false;}catch(IOException e){return true;}}); + check(written.await(1,TimeUnit.SECONDS),"Cancellation test request started");closeable.close();check(pending.get(1,TimeUnit.SECONDS),"Disconnect unblocks pending request promptly"); + UsbIo.Cdc silent=new UsbIo.Cdc();CommandChannel timed=new CommandChannel(silent,(b,n)->{},s->{},s->{}); + try{timed.request(0x20);throw new AssertionError("Must time out");}catch(IOException expected){check(timed.isClosed()&&silent.closed,"Timeout closes and poisons transport");} + silent.receive(reply(0x20,0,new byte[]{2})); + try{timed.request(0x20);throw new AssertionError("Late ACK must not be reused");}catch(IOException expected){check(timed.isClosed(),"Late response cannot complete a later request");} + UsbIo.Cdc failed=new UsbIo.Cdc();CommandChannel broken=new CommandChannel(failed,(b2,n)->{},s->{},s->{}); + failed.onWrite=packet->failed.failure.accept("USB unplugged"); + try{broken.request(0x20);throw new AssertionError("Reader failure must throw");}catch(IOException expected){check(broken.isClosed()&&failed.closed,"Reader failure closes transport and unblocks request");} + executor.shutdownNow();System.out.println("PASS: "+checks+" command-channel checks"); + } +} diff --git a/Tools/OniimaiTests/DashboardLayoutTest.java b/Tools/OniimaiTests/DashboardLayoutTest.java new file mode 100644 index 000000000..7255776cc --- /dev/null +++ b/Tools/OniimaiTests/DashboardLayoutTest.java @@ -0,0 +1,289 @@ +package net.majdata.majdataplay.oniimai; + +import java.util.List; + +public final class DashboardLayoutTest { + private static int checks; + + private static void check(boolean condition, String message) { + checks++; + if (!condition) throw new AssertionError(message); + } + + private static void same(String expected, String actual, String message) { + check(expected.equals(actual), message + "\nExpected: " + expected + "\nActual: " + actual); + } + + private static String data(String records) { return "oniimai-dashboard-v2|1\n" + records; } + private static String legacy(String records) { return "oniimai-dashboard-v1|1\n" + records; } + + private static void size(String type, float width, float height, int expectedW, int expectedH) { + int[] size = DashboardLayout.nearestSize(type, width, height); + check(size != null && size[0] == expectedW && size[1] == expectedH, + "Snap " + type + " " + width + "x" + height + " to " + expectedW + "x" + expectedH); + } + + private static void valid(DashboardLayout layout) { + List items = layout.items(); + for (int i = 0; i < items.size(); i++) { + DashboardLayout.Item a = items.get(i); + check(DashboardLayout.validSize(a.type, a.w, a.h) && a.x >= 0 && a.y >= 0 + && a.x + a.w <= DashboardLayout.COLS && a.y + a.h <= DashboardLayout.MAX_ROWS, + "Widget " + a.id + " has supported size and in-bounds placement"); + for (int j = 0; j < i; j++) { + DashboardLayout.Item b = items.get(j); + check(a.id != b.id && (a.x + a.w <= b.x || b.x + b.w <= a.x + || a.y + a.h <= b.y || b.y + b.h <= a.y), "Distinct widgets do not overlap"); + } + } + } + + public static void main(String[] args) { + DashboardLayout layout = DashboardLayout.defaults(); + String defaults = layout.encode(); + check(layout.items().size() == 8 && layout.rows() == 10, "Photo dashboard uses eight widgets and ten square-cell rows"); + String photoDefaults = "oniimai-dashboard-v2|9\n1|song|0|0|2|2\n2|score|2|0|2|2\n" + + "3|graph|0|2|2|2\n4|judgments|2|2|2|2\n5|sensors|0|4|2|2\n6|timing|2|4|2|2\n" + + "7|connection|0|6|4|2\n8|clock|0|8|4|2"; + same(photoDefaults, defaults, "Default arrangement matches the supplied photo"); + same(photoDefaults, DashboardLayout.parse(null).encode(), "First launch uses photo arrangement"); + check(layout.items().stream().map(item -> item.type).distinct().count() == DashboardLayout.types().size(), + "Photo defaults include all eight widget types exactly once"); + String previousPhoto = "oniimai-dashboard-v2|8\n1|song|0|0|4|2\n2|graph|0|2|2|2\n" + + "3|judgments|2|2|2|2\n4|sensors|0|4|2|2\n5|timing|2|4|2|2\n" + + "6|connection|0|6|4|2\n7|clock|0|8|4|2"; + same(previousPhoto, DashboardLayout.parse(previousPhoto).encode(), + "Saved seven-widget photo arrangement is not replaced by the new eight-widget defaults"); + String previousDefaults = "oniimai-dashboard-v2|7\n1|song|0|0|2|2\n2|score|2|0|2|2\n" + + "3|timing|0|2|2|2\n4|judgments|2|2|2|2\n5|sensors|0|4|2|2\n6|connection|2|4|2|2"; + same(previousDefaults, DashboardLayout.parse(previousDefaults).encode(), + "Saved previous default or custom arrangement is not overwritten by new defaults"); + check(defaults.startsWith("oniimai-dashboard-v2|"), "New layouts persist as version 2"); + same(defaults, DashboardLayout.parse(defaults).encode(), "Persist every default coordinate and size"); + check(DashboardLayout.types().size() == 8, "Eight available widget types"); + for (String type : DashboardLayout.types()) { + check(DashboardLayout.validSize(type, DashboardLayout.defaultWidth(type), DashboardLayout.defaultHeight(type)), + "Supported default size for " + type); + check(DashboardLayout.validSize(type, 2, 2), "Every widget supports 2x2: " + type); + DashboardLayout.Item added = DashboardLayout.empty().add(type); + check(added != null && added.w == 2 && added.h == 2, "New widgets all start at 2x2: " + type); + for (int[] supported : DashboardLayout.sizes(type)) + check(DashboardLayout.validSize(type, supported[0], supported[1]), "Size picker only offers supported dimensions"); + check(!DashboardLayout.validSize(type, 3, 3) && !DashboardLayout.validSize(type, 4, 3) + && !DashboardLayout.validSize(type, 2, 4), "Arbitrary dimensions rejected for " + type); + } + check(DashboardLayout.sizes(DashboardLayout.SENSORS).length == 2 + && DashboardLayout.validSize(DashboardLayout.SENSORS, 2, 2) + && !DashboardLayout.validSize(DashboardLayout.SENSORS, 4, 2), "Sensor board offers only square 2x2 and 4x4 sizes"); + check(DashboardLayout.validSize(DashboardLayout.JUDGMENTS, 4, 4) + && !DashboardLayout.validSize(DashboardLayout.SCORE, 4, 4), "Only judgments and sensors offer large square cards"); + int[][] mutableSizes = DashboardLayout.sizes(DashboardLayout.SENSORS); + mutableSizes[0][0] = 1; + check(DashboardLayout.sizes(DashboardLayout.SENSORS)[0][0] == 2, "Size option arrays are defensive copies"); + check(DashboardLayout.minWidth("unknown") == 0 && !DashboardLayout.knownType(null) + && DashboardLayout.sizes(null).length == 0 && DashboardLayout.nearestSize(null, 2, 2) == null, + "Unknown types have no dimensions or nearest size"); + size(DashboardLayout.SCORE, 2.4f, 3.6f, 2, 2); + size(DashboardLayout.SCORE, 3.6f, 3.6f, 4, 2); + size(DashboardLayout.JUDGMENTS, 4, 3, 4, 2); + size(DashboardLayout.JUDGMENTS, 4, 3.01f, 4, 4); + size(DashboardLayout.SENSORS, 2, 2, 2, 2); + size(DashboardLayout.SENSORS, 3, 3, 2, 2); + size(DashboardLayout.SENSORS, 4, 5, 4, 4); + size(DashboardLayout.CLOCK, Float.NaN, 2, 2, 2); + size(DashboardLayout.SONG, 4, Float.POSITIVE_INFINITY, 2, 2); + valid(layout); + + check(!layout.move(1, 0, 2), "Moving over another widget is rejected"); + check(!layout.resize(3, 4, 2), "Resizing over adjacent judgment widget is rejected"); + check(!layout.resize(5, 2, 3), "Sensor map rejects non-square sizes"); + check(!layout.move(1, Integer.MAX_VALUE, 0) && !layout.move(1, 0, Integer.MAX_VALUE), "Huge coordinates cannot overflow bounds"); + check(!layout.move(1, -1, 0) && !layout.move(1, 0, -1), "Negative coordinates rejected"); + check(!layout.resize(1, Integer.MAX_VALUE, Integer.MAX_VALUE), "Huge sizes cannot overflow bounds"); + check(!layout.move(999, 0, 0) && !layout.resize(999, 2, 2) && !layout.remove(999), "Missing widget mutations rejected"); + same(defaults, layout.encode(), "Rejected mutations preserve exact layout"); + check(layout.move(1, 0, 20), "Freeform widget move succeeds"); + check(layout.rows() == 22, "Rows include intentionally empty vertical space"); + check(layout.resize(1, 2, 2), "Supported resize succeeds in unoccupied space"); + String positioned = layout.encode(); + DashboardLayout recreated = DashboardLayout.parse(positioned); + same(positioned, recreated.encode(), "Device rotation or Activity recreation preserves logical cells without pixel dimensions"); + check(recreated.get(1).x == 0 && recreated.get(1).y == 20 && recreated.get(1).w == 2 + && recreated.get(1).h == 2, "Persisted custom position and canonical size restored"); + + List snapshot = layout.items(); + DashboardLayout copy = layout.copy(); + check(copy.remove(1) && layout.get(1) != null, "Editing a copy cannot mutate original saved layout"); + boolean immutable = false; + try { snapshot.clear(); } catch (UnsupportedOperationException expected) { immutable = true; } + check(immutable, "Caller cannot mutate exposed item list"); + layout.remove(2); + check(snapshot.size() == 8 && layout.items().size() == 7, "Published list remains a stable snapshot"); + + DashboardLayout holes = DashboardLayout.defaults(); + check(holes.remove(3), "Delete graph widget"); + DashboardLayout.Item replacement = holes.add(DashboardLayout.CLOCK); + check(replacement != null && replacement.x == 0 && replacement.y == 2, "Add fills first available hole, not the bottom"); + check(replacement.id > 8, "Deleted IDs are not immediately reused"); + DashboardLayout.Item secondClock = holes.add(DashboardLayout.CLOCK); + check(secondClock != null && secondClock.id != replacement.id, "Duplicate widget types retain distinct stable IDs"); + holes.remove(secondClock.id); + DashboardLayout restored = DashboardLayout.parse(holes.encode()); + check(restored.add(DashboardLayout.CLOCK).id > secondClock.id, "Persistence remembers IDs even after most recent widget was removed"); + check(holes.add("not-a-widget") == null && holes.add(null) == null, "Cannot add unknown widget types"); + + DashboardLayout empty = DashboardLayout.empty(); + check(empty.rows() == 0 && DashboardLayout.parse(empty.encode()).items().isEmpty(), "Deliberately empty dashboard remains empty"); + check(DashboardLayout.parse("oniimai-dashboard-v1|25").items().isEmpty(), "Intentionally empty version 1 layout remains empty"); + empty.add(DashboardLayout.CLOCK); + empty.clear(); + check(DashboardLayout.parse(empty.encode()).add(DashboardLayout.CLOCK).id == 2, "Clear does not rewind stable IDs"); + empty.reset(); + same(defaults, empty.encode(), "Explicit reset restores all defaults"); + + for (String invalid : new String[] {null, "", "garbage", "oniimai-dashboard-v3|1", "oniimai-dashboard-v1|0", + "oniimai-dashboard-v1|-1", "oniimai-dashboard-v1|9223372036854775807", "oniimai-dashboard-v1|nope", + data("1|unknown|0|0|4|2"), data("1|song|0|0|0|2"), data("1|song|0|0|4|999999999999999999999"), + data("1|song|0|0|3|2"), legacy("1|sensors|0|0|2|3")}) { + same(defaults, DashboardLayout.parse(invalid).encode(), "Wholly corrupt saved data recovers defaults"); + } + String mixed = data("1|song|0|0|4|2\n" + + "1|clock|0|10|2|2\n" // Duplicate identity. + + "2|score|0|0|2|2\n" // Overlapping geometry. + + "3|unknown|0|4|2|2\n" + + "4|clock|-1|4|2|2\n" + + "5|clock|0|63|2|2\n" + + "6|clock|0|4|2|NaN\n" + + "-7|clock|0|4|2|2\n" + + "8|clock|0|4|2|2\n" + + "9|clock|0|10|2|3\n"); + DashboardLayout recovered = DashboardLayout.parse(mixed); + check(recovered.items().size() == 2 && recovered.get(1) != null && recovered.get(8) != null, + "Mixed corruption keeps valid non-overlapping widgets only"); + check(recovered.add(DashboardLayout.CLOCK).id == 9, "Stale next ID repaired from restored IDs"); + same(defaults, DashboardLayout.parse(defaults.replace("\n", "\r\n")).encode(), "Windows line endings accepted"); + same(defaults, DashboardLayout.parse("x".repeat(16385)).encode(), "Overlarge preference data is bounded"); + + String oldDefaults = "oniimai-dashboard-v1|7\n1|song|0|0|4|2\n2|score|0|2|2|2\n" + + "3|timing|2|2|2|2\n4|judgments|0|4|4|3\n5|sensors|0|7|4|5\n6|connection|0|12|4|2"; + DashboardLayout migrated = DashboardLayout.parse(oldDefaults); + check(migrated.items().size() == 6 && migrated.get(4).h == 2 && migrated.get(5).h == 4, + "Existing 0.2.4 defaults migrate judgment and sensor sizes without losing widgets"); + check(migrated.get(5).y == 7 && migrated.get(6).y == 12, "Migration preserves saved positions when resized widgets already fit"); + check(migrated.add(DashboardLayout.CLOCK).id == 7, "Migration preserves the next identity"); + valid(migrated); + String compatible = data("8|clock|1|19|2|2\n10|score|0|29|4|2"); + same(DashboardLayout.parse(compatible).encode(), DashboardLayout.parse(compatible.replace("v2|", "v1|")).encode(), + "Compatible legacy positions including odd cells and intentional gaps are restored exactly"); + DashboardLayout shiftedSensor = DashboardLayout.parse(legacy("51|sensors|1|60|3|4")); + check(shiftedSensor.items().size() == 1 && shiftedSensor.get(51).x == 0 && shiftedSensor.get(51).y == 0 + && shiftedSensor.get(51).w == 4 && shiftedSensor.get(51).h == 4, + "Legacy 3-column sensor snaps to 4x4 and repacks when expansion would leave the grid"); + String mixedLegacy = "oniimai-dashboard-v1|99\n1|song|0|0|4|3\n2|score|0|3|2|3\n" + + "3|timing|2|3|2|3\n4|judgments|0|6|4|3\n5|sensors|1|9|3|5\n" + + "6|clock|0|14|2|3\n7|clock|2|14|2|3"; + DashboardLayout migratedPacked = DashboardLayout.parse(mixedLegacy); + check(migratedPacked.items().size() == 7 && migratedPacked.rows() == 12, + "Mixed arbitrary legacy sizes snap and repack into a complete canonical layout"); + for (int id = 1; id <= 7; id++) + check(migratedPacked.get(id) != null && migratedPacked.items().get(id - 1).id == id, "Migration retains stable ID and stored order " + id); + check(DashboardLayout.CLOCK.equals(migratedPacked.get(6).type) && DashboardLayout.CLOCK.equals(migratedPacked.get(7).type), + "Migration retains duplicate widget types independently"); + valid(migratedPacked); + same(migratedPacked.encode(), DashboardLayout.parse(migratedPacked.encode()).encode(), "Migrated v2 layout remains stable across reloads"); + check(migratedPacked.add(DashboardLayout.CLOCK).id == 99, "Migration preserves previously removed widget IDs"); + DashboardLayout legacyCorruption = DashboardLayout.parse(legacy("2|clock|0|0|2|3\n2|clock|2|0|2|3\n" + + "3|clock|0|0|2|3\n4|clock|2|0|2|3\n5|sensors|0|20|2|4")); + check(legacyCorruption.items().size() == 2 && legacyCorruption.get(2) != null && legacyCorruption.get(4) != null, + "Migration still rejects duplicate IDs, original overlaps, and invalid legacy geometry"); + + DashboardLayout limit = DashboardLayout.empty(); + for (int i = 0; i < DashboardLayout.MAX_ITEMS; i++) check(limit.add(DashboardLayout.CLOCK) != null, "Add up to widget limit " + i); + String atLimit = limit.encode(); + check(limit.add(DashboardLayout.CLOCK) == null, "Widget count limit enforced"); + same(atLimit, limit.encode(), "Failed add does not consume an ID"); + check(DashboardLayout.parse(atLimit + "\n100|clock|0|40|2|2").items().size() == DashboardLayout.MAX_ITEMS, + "Parser also enforces widget count limit"); + DashboardLayout full = DashboardLayout.empty(); + for (int i = 0; i < 16; i++) { + DashboardLayout.Item sensor = full.add(DashboardLayout.SENSORS); + check(sensor != null && full.resizeAndPack(sensor.id, 4, 4), "Fill sensor grid with explicit 4x4 tiles " + i); + } + String fullBefore = full.encode(); + check(full.add(DashboardLayout.CLOCK) == null, "Adding to completely occupied grid fails without overlap"); + same(fullBefore, full.encode(), "Adding beyond grid bounds preserves exact layout and next ID"); + DashboardLayout exhausted = DashboardLayout.parse(data("2147483647|clock|0|0|2|2")); + check(exhausted.get(Integer.MAX_VALUE) != null && exhausted.add(DashboardLayout.CLOCK) == null, + "Exhausted positive ID range cannot wrap or duplicate"); + same(exhausted.encode(), DashboardLayout.parse(exhausted.encode()).encode(), "Maximum identity persists safely"); + + DashboardLayout packed = DashboardLayout.defaults(); + packed.move(1, 0, 30); + check(packed.pack(), "Compaction succeeds for displaced defaults"); + same(defaults, packed.encode(), "Compaction uses stable order and original sizes"); + check(packed.moveBy(4, -1), "Accessible reorder moves widget earlier"); + check(packed.items().get(2).id == 4 && packed.get(4).x == 0 && packed.get(4).y == 2 && packed.get(3).x == 2 && packed.get(3).y == 2, + "Accessible reorder exchanges side-by-side widgets and repacks coordinates"); + check(!packed.moveBy(1, -1) && !packed.moveBy(8, 1) && !packed.moveBy(2, 0) && !packed.moveBy(99, 1), + "Invalid and boundary reorder requests are rejected"); + check(packed.moveBy(4, 1), "Accessible reorder can be reversed"); + same(defaults, packed.encode(), "Reversed reorder restores layout"); + + String existingLarge = "oniimai-dashboard-v2|51\n9|sensors|0|0|4|4\n20|song|0|5|4|2\n" + + "25|sensors|0|10|4|4\n41|clock|2|20|2|2"; + DashboardLayout compacted = DashboardLayout.parse(existingLarge); + same(existingLarge, compacted.encode(), "Existing v2 large widgets retain saved sizes and positions"); + check(compacted.compact(), "Explicit compact action resizes and packs all existing widgets"); + check(compacted.items().size() == 4 && compacted.rows() == 4, "Compact layout uses two 2x2 tiles per row"); + int[] compactIds = {9, 20, 25, 41}; + String[] compactTypes = {DashboardLayout.SENSORS, DashboardLayout.SONG, DashboardLayout.SENSORS, DashboardLayout.CLOCK}; + for (int i = 0; i < compactIds.length; i++) { + DashboardLayout.Item item = compacted.items().get(i); + check(item.id == compactIds[i] && item.type.equals(compactTypes[i]) && item.w == 2 && item.h == 2 + && item.x == (i % 2) * 2 && item.y == (i / 2) * 2, + "Compaction preserves identity, type, duplicate widgets, and stored order " + i); + } + String compactOnce = compacted.encode(); + check(compacted.compact(), "Repeated compaction succeeds"); + same(compactOnce, compacted.encode(), "Compaction is idempotent"); + same(compactOnce, DashboardLayout.parse(compactOnce).encode(), "Compact sensor sizes persist across reloads"); + check(compacted.add(DashboardLayout.SENSORS).id == 51, "Compaction preserves next identity"); + DashboardLayout compactEmpty = DashboardLayout.parse("oniimai-dashboard-v2|19"); + check(compactEmpty.compact() && compactEmpty.items().isEmpty(), "Compact preserves intentionally empty dashboard"); + check(compactEmpty.add(DashboardLayout.SONG).id == 19, "Empty compaction preserves next identity"); + valid(compacted); + + DashboardLayout resizePacked = DashboardLayout.parse(data("1|score|0|0|2|2\n2|timing|2|0|2|2")); + check(!resizePacked.resize(1, 4, 2), "Wider replacement overlaps current neighbor"); + check(resizePacked.resizeAndPack(1, 4, 2), "Resize packs the desired dimensions without requiring temporary room for old dimensions"); + check(resizePacked.get(1).w == 4 && resizePacked.get(1).h == 2 && resizePacked.get(1).y == 0 + && resizePacked.get(2).y == 2 && resizePacked.get(2).x == 0, "Atomic resize preserves identities and moves the neighboring widget"); + valid(resizePacked); + String resized = resizePacked.encode(); + check(!resizePacked.resizeAndPack(1, 4, 3), "Resize to non-canonical dimensions is rejected"); + same(resized, resizePacked.encode(), "Failed resize and pack preserves every rectangle"); + check(!resizePacked.resizeAndPack(999, 4, 2) && !resizePacked.resizeAndPack(1, 1, 2) + && !resizePacked.resizeAndPack(1, Integer.MAX_VALUE, 2), "Atomic resize validates identity, readability, and bounds"); + same(resized, resizePacked.encode(), "Invalid atomic resize has no side effects"); + DashboardLayout resizeInPlace = DashboardLayout.empty(); + int resizeId = resizeInPlace.add(DashboardLayout.SCORE).id; + resizeInPlace.move(resizeId, 0, 20); + check(resizeInPlace.resizeAndPack(resizeId, 4, 2) && resizeInPlace.get(resizeId).x == 0 + && resizeInPlace.get(resizeId).y == 20, "Non-overlapping resize preserves user placement rather than packing unnecessarily"); + + StringBuilder occupied = new StringBuilder(); + for (int id = 1; id <= 15; id++) occupied.append(id).append("|sensors|0|").append((id - 1) * 4).append("|4|4\n"); + occupied.append("16|judgments|0|60|2|2\n17|judgments|2|60|2|2\n18|judgments|0|62|2|2\n19|judgments|2|62|2|2"); + DashboardLayout resizeFailure = DashboardLayout.parse(data(occupied.toString())); + check(resizeFailure.rows() == 64 && resizeFailure.items().size() == 19, "Canonical widgets can fill the entire grid"); + String resizeBefore = resizeFailure.encode(); + check(!resizeFailure.resizeAndPack(16, 4, 4), "Resize rejects dimensions that cannot fit all existing widgets"); + same(resizeBefore, resizeFailure.encode(), "Capacity failure preserves all identities, coordinates, sizes, order, and next ID atomically"); + resizeFailure.remove(18); + resizeFailure.remove(19); + check(resizeFailure.resizeAndPack(16, 4, 2) && resizeFailure.get(16).y == 60 && resizeFailure.get(17).y == 62, + "Freeing enough space permits the same overlapping resize without losing other widgets"); + valid(resizeFailure); + System.out.println("DashboardLayout: " + checks + " checks passed"); + } +} diff --git a/Tools/OniimaiTests/DisplayGeometryTest.java b/Tools/OniimaiTests/DisplayGeometryTest.java new file mode 100644 index 000000000..db74de125 --- /dev/null +++ b/Tools/OniimaiTests/DisplayGeometryTest.java @@ -0,0 +1,38 @@ +package net.majdata.majdataplay.oniimai; + +public final class DisplayGeometryTest { + private static int checks; + private static void near(float a, float b) { + checks++; if (Math.abs(a-b) > .002f) throw new AssertionError(a+" != "+b); + } + private static float[] map(float[] m, float x, float y) { + return new float[]{m[0]*x+m[1]*y+m[2], m[3]*x+m[4]*y+m[5]}; + } + public static void main(String[] args) { + for (int[] size : new int[][]{{1920,1080},{3840,2160},{2560,1440},{1280,720},{1024,768},{3440,1440}}) { + int w=size[0], h=size[1]; + for (boolean reverse : new boolean[]{false,true}) { + float[] surface=DisplayGeometry.surfaceMatrix(w,h,reverse); + float[] texture=DisplayGeometry.textureMatrix(w,h,reverse); + float[][] expected=reverse ? new float[][]{{0,h},{0,0},{w,0},{w,h},{w/2f,h/2f}} + : new float[][]{{w,0},{w,h},{0,h},{0,0},{w/2f,h/2f}}; + int[][] source={{0,0},{1080,0},{1080,1920},{0,1920},{540,960}}; + for (int i=0;i + Exenet8.0false + + diff --git a/Tools/OniimaiTests/FrameMerge/Program.cs b/Tools/OniimaiTests/FrameMerge/Program.cs new file mode 100644 index 000000000..1245ab26a --- /dev/null +++ b/Tools/OniimaiTests/FrameMerge/Program.cs @@ -0,0 +1 @@ +class Program { static void Main() { OniimaiFrameChecks.Run(); OniimaiStatsChecks.Run(); } } diff --git a/Tools/OniimaiTests/InputSnapshotTest.java b/Tools/OniimaiTests/InputSnapshotTest.java new file mode 100644 index 000000000..d51da2c30 --- /dev/null +++ b/Tools/OniimaiTests/InputSnapshotTest.java @@ -0,0 +1,80 @@ +package net.majdata.majdataplay.oniimai; + +public final class InputSnapshotTest { + private static int checks; + private static void eq(long expected, long actual) { + checks++; if (expected != actual) throw new AssertionError("expected " + expected + ", got " + actual + " (#" + checks + ")"); + } + private static InputSnapshot armed() { InputSnapshot s = new InputSnapshot(); s.enabled(true); s.read(0); return s; } + public static void main(String[] args) { + InputSnapshot s = new InputSnapshot(); + s.keyboard(1, true); eq(0, s.read(0)[0]); // off by default + s.enabled(true); eq(0, s.read(0)[0]); // enabling a held key must not press a menu + s.keyboard(0, true); s.keyboard(1, true); eq(1, s.read(1)[0]); eq(0, s.read(2)[2]); + s.keyboard(0, true); eq(0, s.read(3)[0]); + s = armed(); s.keyboard(1, true); s.keyboard(0, true); + eq(0,s.diagnostic(1)[0]); eq(2,s.diagnostic(1)[3]); // monitor must not consume a short input pulse + long[] f = s.read(1); eq(1, f[0]); eq(1, f[2]); eq(0, s.read(2)[0]); // short pulse exactly once + s = armed(); s.keyboard(1, true); s.hid(1, 1); s.read(1); s.keyboard(0, false); + eq(1, s.read(2)[0]); eq(0, s.read(2)[2]); // one source cannot release the other + s.hid(0, 3); eq(0, s.read(3)[0]); + s = armed(); s.touch(1L << 33, 1); eq(1L << 33, s.read(1)[1]); + eq(1L<<33,s.diagnostic(2)[1]); eq(1,s.diagnostic(2)[3]); + eq(1L << 33, s.read(501)[1]); eq(0, s.read(502)[1]); eq(0, s.read(503)[4]); // stale sensor release + eq(0,s.diagnostic(504)[1]); eq(0,s.diagnostic(504)[3]); + s = armed(); s.hid(255, 1); s.read(1); eq(0, s.read(502)[0]); + s = armed(); s.keyboard(256,true); s.keyboard(0,true); eq(256,s.read(1)[0]); eq(0,s.read(2)[0]); + s = armed(); s.hid(256,1); eq(256,s.read(1)[0]); eq(0,s.read(2)[2]); + s.panel(true); eq(0,s.read(3)[0]); s.panel(false); eq(0,s.read(4)[0]); + s.hid(0,5); s.hid(256,6); eq(256,s.read(6)[0]); + s = armed(); s.hid(0, 1); s.read(1); s.keyboard(1, true); s.keyboard(0, true); + eq(1, s.read(502)[0]); // expired HID cannot erase a fresh keyboard pulse + s = armed(); s.touch(1, 0); s.read(0); s.read(501); + s.touch(1, 502); eq(0, s.read(502)[1]); // stale held reports cannot re-press a menu + s.touch(0, 503); s.touch(1, 504); eq(1, s.read(504)[1]); + s = armed(); s.touch(3, 0); s.panel(true); eq(0, s.read(1)[1]); + s.panel(false); eq(0, s.read(2)[1]); s.touch(0, 3); s.touch(3, 4); eq(3, s.read(4)[1]); + s.foreground(false); eq(0, s.read(5)[1]); s.foreground(true); eq(0, s.read(6)[1]); + s.touch(0, 7); s.touch(2, 8); eq(2, s.read(8)[1]); + s.disconnect(); eq(0, s.read(9)[1]); + s = armed(); s.touch(1L << 16, 0); eq(1L << 16, s.read(0)[3]); + s.touch(3L << 16, 1); eq(0, s.read(1)[3]); + s.touch(1L << 17, 2); eq(0, s.read(2)[3]); + s.touch(0, 3); s.touch(1L << 17, 4); eq(1L << 17, s.read(4)[3]); + s = armed(); s.awaitStreams(true, true); s.read(0); + s.touch(1, 1); eq(0, s.read(1)[1]); // first report held during connection: suppress + s.touch(0, 2); s.touch(1, 3); eq(0, s.read(3)[1]); // still waiting for HID's initial report + s.hid(0, 4); s.touch(0, 5); s.touch(1, 6); eq(1, s.read(6)[1]); + for (int i = 0; i < 34; i++) { + s = armed(); s.touch(1L << i, 0); s.touch(0, 1); f = s.read(2); + eq(1L << i, f[1]); eq(1L << i, f[3]); eq(0, s.read(3)[1]); + } + for (int i = 0; i < 8; i++) { + s = armed(); s.keyboard(1 << i, true); f = s.read(0); + eq(1 << i, f[0]); eq(1 << i, f[2]); + for (int n = 0; n < 120; n++) { s.keyboard(1 << i, true); eq(0, s.read(n)[2]); } + } + eq(90, DisplayGeometry.angle(false)); eq(270, DisplayGeometry.angle(true)); + // Check all corners and the centre. 4K must fill exactly like 1080p; + // changing direction must not crop or stretch a circle on a 16:9 monitor. + for (int[] output : new int[][]{{1920,1080},{3840,2160},{1280,720},{2560,1440}}) { + int w = output[0], h = output[1]; + for (boolean reverse : new boolean[]{false,true}) { + float[] m = DisplayGeometry.textureMatrix(w,h,reverse); + int[][] src = {{0,0},{w,0},{w,h},{0,h},{w/2,h/2}}; + int[][] dst = reverse ? new int[][]{{0,h},{0,0},{w,0},{w,h},{w/2,h/2}} + : new int[][]{{w,0},{w,h},{0,h},{0,0},{w/2,h/2}}; + for (int i=0;i packets=Collections.synchronizedList(new ArrayList<>());AtomicReference handle=new AtomicReference<>();AtomicBoolean silent=new AtomicBoolean(); + AtomicInteger opens=new AtomicInteger(); + UsbIo.Cdc.opened=port->{opens.incrementAndGet();handle.set(port);port.onWrite=encoded->{ + byte[] p=LedTest.decode(encoded);packets.add(p); + if(!silent.get()&&(p[3]&255)!=0x7d){byte[] data=(p[3]&255)==0xf0?"15070-04xx".getBytes(StandardCharsets.US_ASCII):new byte[0];port.receive(LedTest.ack(p[0]&255,p[3]&255,1,1,data));} + };}; + LedOutput output=new LedOutput(new UsbManager()); + int[] frame={0xff0000,0x00ff00,0x0000ff,0x123456,0xd0e001,0xabcdef,0x567890,0xffffff,0x808080}; + java.util.concurrent.ScheduledExecutorService producer=java.util.concurrent.Executors.newSingleThreadScheduledExecutor(); + producer.scheduleAtFixedRate(()->output.submit(frame),0,100,java.util.concurrent.TimeUnit.MILLISECONDS); + try{ + output.settings(100,0,false,false);output.start(new UsbIo.Port(),17,0); + await(()->output.summary().contains("송신 1"),"initial complete frame acknowledged"); + check(rgb(packets,0)==0xff0000&&rgb(packets,7)==0xffffff,"game RGB to physical endpoints"); + int n=packets.size();Thread.sleep(130);check(packets.size()==n,"static frames are not queued repeatedly"); + output.settings(50,1,true,true);await(()->rgb(packets,1)==0x800000,"live brightness and mapping");await(()->has(packets,0x39),"optional ring command"); + output.settings(50,1,true,false);await(()->last(packets,0x39)[4]==0,"ring OFF clears previously enabled FET"); + output.foreground(false);await(()->output.summary().contains("백그라운드"),"pause completes blackout");check(rgb(packets,0)==0&&rgb(packets,7)==0,"pause clears both ends"); + output.foreground(true);await(()->rgb(packets,1)==0x800000,"resume restores game colors"); + output.test(0x0000ff);await(()->rgb(packets,0)==128&&rgb(packets,7)==128,"temporary color test uses brightness"); + await(()->rgb(packets,1)==0x800000,"test expires back to game colors"); + output.stop();await(()->handle.get().closed,"stop closes LED handle");check(rgb(packets,0)==0&&rgb(packets,7)==0,"stop clears lamps"); + silent.set(true);output.start(new UsbIo.Port(),17,0); + await(()->output.summary().contains("LED 재연결 대기: 1"),"handshake timeout schedules retry"); + check(output.running(),"enabled session retained while reconnecting"); + await(()->output.summary().contains("LED 재연결 대기: 2"),"repeated failure backs off"); + silent.set(false);await(()->output.summary().contains("LED 연동 중"),"automatic handshake recovery without settings refresh"); + UsbIo.Cdc old=handle.get();int before=opens.get(); + silent.set(true);output.test(0x992211); + await(()->output.summary().contains("LED 재연결 대기"),"mid-game write timeout schedules LED-only recovery"); + check(old.closed,"failed handle is closed before retry"); + output.foreground(false);silent.set(false);Thread.sleep(1300); + check(opens.get()==before,"background pauses retries without touching USB"); + output.foreground(true);await(()->output.summary().contains("LED 연동 중"),"foreground resumes pending retry automatically"); + check(opens.get()==before+1,"one replacement session only"); + check(rgb(packets,1)==0x800000,"recovery sends latest full game frame"); + silent.set(true);output.test(0x113399);await(()->output.summary().contains("LED 재연결 대기"),"prepare pending retry for stop"); + before=opens.get();output.stop();Thread.sleep(1300); + check(!output.running()&&opens.get()==before,"OFF cancels retry without reopening handle"); + silent.set(false);output.start(new UsbIo.Port(),17,0);await(()->output.summary().contains("LED 연동 중"),"explicit restart after cancelled retry"); + silent.set(true);output.test(0x339911);await(()->output.summary().contains("LED 재연결 대기"),"prepare pending retry for destroy"); + before=opens.get();output.destroy();Thread.sleep(1300); + check(opens.get()==before,"destroy cancels pending retry"); + }finally{producer.shutdownNow();output.destroy();} + await(()->handle.get().closed,"destroy closes worker-owned handle"); + System.out.println("PASS: "+checks+" LED worker/lifecycle checks"); + } + static boolean has(List packets,int cmd){return last(packets,cmd)!=null;} + static byte[] last(List packets,int cmd){synchronized(packets){for(int i=packets.size()-1;i>=0;i--)if((packets.get(i)[3]&255)==cmd)return packets.get(i);}return null;} + static int rgb(List packets,int index){synchronized(packets){for(int i=packets.size()-1;i>=0;i--){byte[] p=packets.get(i);if((p[3]&255)==0x31&&(p[4]&255)==index)return (p[5]&255)<<16|(p[6]&255)<<8|(p[7]&255);}}return -1;} +} diff --git a/Tools/OniimaiTests/LedTest.java b/Tools/OniimaiTests/LedTest.java new file mode 100644 index 000000000..975442a80 --- /dev/null +++ b/Tools/OniimaiTests/LedTest.java @@ -0,0 +1,44 @@ +package net.majdata.majdataplay.oniimai; +import java.io.*; +import java.util.*; +import java.util.concurrent.*; + +public final class LedTest { + static int checks; + static void check(boolean value,String label){checks++;if(!value)throw new AssertionError(label);} + static byte[] ack(int address,int command,int status,int report,byte[] payload){ + ByteArrayOutputStream out=new ByteArrayOutputStream();out.write(0xe0); + byte[] body=new byte[payload.length+6];body[0]=1;body[1]=(byte)address;body[2]=(byte)(payload.length+3);body[3]=(byte)status;body[4]=(byte)command;body[5]=(byte)report; + System.arraycopy(payload,0,body,6,payload.length);int sum=0; + for(byte b:body){sum=(sum+(b&255))&255;escape(out,b&255);}escape(out,sum);return out.toByteArray(); + } + static void escape(ByteArrayOutputStream out,int value){if(value==0xe0||value==0xd0){out.write(0xd0);out.write(value-1);}else out.write(value);} + static byte[] decode(byte[] packet){ByteArrayOutputStream out=new ByteArrayOutputStream();for(int i=1;i{port.receive(ack(18,0xf0,1,1,new byte[]{1}));port.receive(ack(17,0x31,1,1,new byte[]{2}));byte[] a=ack(17,0xf0,1,1,new byte[]{(byte)0xd0,(byte)0xe0});for(byte b:a)port.receive(new byte[]{b});}; + check(Arrays.equals(channel.request(0xf0),new byte[]{(byte)0xd0,(byte)0xe0}),"fragmentation, escaping, wrong node and command ignored");channel.close(); + UsbIo.Cdc errorPort=new UsbIo.Cdc();LedChannel error=new LedChannel(errorPort,17,100); + errorPort.onWrite=p->errorPort.receive(ack(17,0x31,1,4,new byte[0])); + try{error.request(0x31);throw new AssertionError();}catch(IOException expected){check(error.isClosed()&&errorPort.closed,"NAK closes LED channel");} + UsbIo.Cdc silent=new UsbIo.Cdc();LedChannel timeout=new LedChannel(silent,17,30); + try{timeout.request(0x31);throw new AssertionError();}catch(IOException expected){check(timeout.isClosed()&&silent.closed,"timeout poisons LED channel");} + silent.receive(ack(17,0x31,1,1,new byte[0]));try{timeout.request(0x31);throw new AssertionError();}catch(IOException expected){check(true,"late ACK cannot revive closed channel");} + UsbIo.Cdc cancelPort=new UsbIo.Cdc();LedChannel cancel=new LedChannel(cancelPort,17,2000);CountDownLatch sent=new CountDownLatch(1);cancelPort.onWrite=p->sent.countDown();ExecutorService worker=Executors.newSingleThreadExecutor(); + Future pending=worker.submit(()->{try{cancel.request(0xf0);return false;}catch(IOException expected){return true;}}); + check(sent.await(1,TimeUnit.SECONDS),"request started");cancel.close();check(pending.get(1,TimeUnit.SECONDS),"close wakes pending request");worker.shutdownNow(); + UsbIo.Cdc brokenPort=new UsbIo.Cdc();LedChannel broken=new LedChannel(brokenPort,17,100);brokenPort.onWrite=p->brokenPort.failure.accept("unplugged"); + try{broken.request(0xf0);throw new AssertionError();}catch(IOException expected){check(broken.isClosed(),"reader failure interrupts request");} + System.out.println("PASS: "+checks+" LED mapping and ACK checks"); + } +} diff --git a/Tools/OniimaiTests/OutputModeTest.java b/Tools/OniimaiTests/OutputModeTest.java new file mode 100644 index 000000000..40e01aaf9 --- /dev/null +++ b/Tools/OniimaiTests/OutputModeTest.java @@ -0,0 +1,25 @@ +package net.majdata.majdataplay.oniimai; +public final class OutputModeTest { + private static int checks; + private static void check(boolean ok){checks++;if(!ok)throw new AssertionError("check "+checks);} + private static OutputMode.Mode mode(int id,int w,int h,float rate){return new OutputMode.Mode(id,w,h,rate);} + public static void main(String[] args){ + OutputMode.Mode uhd60=mode(1,3840,2160,59.94f),fhd120=mode(2,1920,1080,119.88f); + OutputMode.Mode uhd120=mode(3,3840,2160,120),fhd60=mode(4,1920,1080,60); + check(OutputMode.choose(uhd60,new OutputMode.Mode[]{fhd60,uhd60,fhd120},60)==uhd60); + check(OutputMode.choose(uhd60,new OutputMode.Mode[]{uhd60,fhd120},120)==fhd120); + check(OutputMode.choose(uhd60,new OutputMode.Mode[]{fhd120,uhd120},120)==uhd120); + check(OutputMode.choose(fhd60,new OutputMode.Mode[]{uhd120,fhd120},120)==fhd120); + check(OutputMode.choose(uhd60,new OutputMode.Mode[]{uhd60,fhd60},120)==null); + check(OutputMode.choose(uhd60,new OutputMode.Mode[]{mode(5,1600,1200,120)},120)==null); + check(OutputMode.choose(uhd60,new OutputMode.Mode[]{mode(6,1920,1080,Float.NaN)},120)==null); + check(OutputMode.choose(uhd60,new OutputMode.Mode[]{mode(7,3840,0,120)},120)==null); + check(OutputMode.choose(uhd60,new OutputMode.Mode[0],60)==null); + check(OutputMode.choose(fhd120,new OutputMode.Mode[]{fhd60,uhd60},60)==fhd60); + check(OutputMode.frameRate(120)==120); + for(int saved:new int[]{-1,0,30,60,90,144,Integer.MAX_VALUE})check(OutputMode.frameRate(saved)==60); + check(OutputMode.matches(119.88f,120));check(!OutputMode.matches(60,120)); + check(!OutputMode.matches(Float.POSITIVE_INFINITY,60)); + System.out.println("OutputMode: "+checks+" checks passed"); + } +} diff --git a/Tools/OniimaiTests/PortSelectionTest.java b/Tools/OniimaiTests/PortSelectionTest.java new file mode 100644 index 000000000..24450eede --- /dev/null +++ b/Tools/OniimaiTests/PortSelectionTest.java @@ -0,0 +1,47 @@ +package net.majdata.majdataplay.oniimai; + +public final class PortSelectionTest { + private static int checks; + private static void eq(int expected,int actual) { checks++; if(expected!=actual) throw new AssertionError(expected+" != "+actual); } + public static void main(String[] args) { + String[] names={"onii-mai NFC","onii-mai Touch","onii-mai LED","onii-mai Command","onii-mai Keyboard","CDC Communications Control"}; + boolean[] hid={false,false,false,false,true,false}; + eq(3,PortSelection.named(names,hid,PortSelection.COMMAND)); + eq(1,PortSelection.named(names,hid,PortSelection.TOUCH)); + eq(2,PortSelection.named(names,hid,PortSelection.LED)); + eq(1,PortSelection.automaticTouch(names,hid)); // Touch wins even when Command is present. + eq(-1,PortSelection.automaticTouch(new String[]{"onii-mai Command"},new boolean[1])); + eq(-2,PortSelection.automaticTouch(new String[]{"onii-mai Touch","onii-mai Command","onii-mai Touch"},new boolean[3])); + eq(1,PortSelection.protocol("onii-mai Touch",false,0)); + eq(0,PortSelection.protocol("onii-mai Command",false,1)); + eq(1,PortSelection.protocol("CDC-ACM IF4",false,1)); + eq(0,PortSelection.protocol("CDC-ACM IF4",false,0)); + eq(0,PortSelection.role("Generic LED",false)); + eq(0,PortSelection.role("onii-mai LED",true)); + eq(0,PortSelection.role(null,false)); + eq(1,PortSelection.role(" ONII-MAI COMMAND ",false)); + eq(-2,PortSelection.named(new String[]{"onii-mai LED","onii-mai LED"},new boolean[2],PortSelection.LED)); + eq(-1,PortSelection.named(new String[]{"onii-mai NFC"},new boolean[1],PortSelection.TOUCH)); + String[] ids={"100:200:3:ABC","100:200:5:ABC","100:200:3:XYZ"}; + eq(0,PortSelection.unique(ids,"100:200:3:ABC")); + eq(2,PortSelection.unique(ids,"100:200:3:XYZ")); + eq(-1,PortSelection.unique(ids,"100:200:3:OTHER")); + eq(-2,PortSelection.unique(ids,"100:200:3:")); + eq(1,PortSelection.unique(ids,"100:200:5:")); // chosen before permission: serial becomes available later + eq(-1,PortSelection.unique(ids,"")); + eq(-1,PortSelection.unique(ids,"100:201:3:ABC")); + eq(-2,PortSelection.unique(new String[]{ids[0],ids[0]},ids[0])); + eq(0,PortSelection.unique(new String[]{ids[0]},ids[0])); // independent of /dev/bus address + String io4="I/O CONTROL BD;15257;01;90;1831;6679A;00;GOUT=14_ADIN=8,E_ROTIN=4_COININ=2_SWIN=2,E_UQ1=41,6;"; + eq(PortSelection.IO4,PortSelection.role(io4,true)); + eq(PortSelection.NONE,PortSelection.role(io4,false)); // Never claim a serial interface as HID. + eq(PortSelection.NONE,PortSelection.role("onii-mai Keyboard",true)); + eq(PortSelection.NONE,PortSelection.role("Generic HID",true)); + eq(PortSelection.IO4,PortSelection.role("onii-mai IO4",true)); + eq(PortSelection.IO4,PortSelection.role(" ONIIMAI IO4 HID ",true)); + eq(1,PortSelection.named(new String[]{"onii-mai Keyboard",io4,"onii-mai Touch"},new boolean[]{true,true,false},PortSelection.IO4)); + eq(-2,PortSelection.named(new String[]{io4,io4},new boolean[]{true,true},PortSelection.IO4)); + eq(-1,PortSelection.named(new String[]{"onii-mai Keyboard","Generic HID"},new boolean[]{true,true},PortSelection.IO4)); + System.out.println("PortSelection: "+checks+" checks passed"); + } +} diff --git a/Tools/OniimaiTests/ProtocolTest.java b/Tools/OniimaiTests/ProtocolTest.java new file mode 100644 index 000000000..ceecca059 --- /dev/null +++ b/Tools/OniimaiTests/ProtocolTest.java @@ -0,0 +1,52 @@ +package net.majdata.majdataplay.oniimai; +import java.util.*; +import java.io.ByteArrayOutputStream; + +public final class ProtocolTest { + static int checks; + static void check(boolean b,String name){checks++;if(!b)throw new AssertionError(name);} + static void invalid(Runnable f,String name){try{f.run();throw new AssertionError(name);}catch(IllegalArgumentException expected){checks++;}} + static byte[] reply(int cmd,int status,byte[] data){ByteArrayOutputStream out=new ByteArrayOutputStream();out.write(0x53);byte[] body=new byte[data.length+3];body[0]=(byte)cmd;body[1]=(byte)status;body[2]=(byte)data.length;System.arraycopy(data,0,body,3,data.length);for(byte b:body){if(b==0x53||b==0x7c)out.write(0x7c);out.write(b);}return out.toByteArray();} + public static void main(String[] args){ + check(Protocol.hex(Protocol.command(0x20,new byte[0])).equals("532000"),"GET config known vector"); + check(Protocol.hex(Protocol.command(0x21,new byte[]{0x53,0x7c,0})).equals("5321037C537C7C00"),"Command escaping known vector"); + byte[] data=new byte[144];data[0]=1;data[4]=2;data[8]=0x34;data[9]=0x12;data[76]=0x7c;data[77]=0x53; + Protocol.TouchDebug debug=new Protocol.TouchDebug(data);check(debug.pressed[0]&&debug.pressed[33],"34-bit touch map");check(debug.raw[0]==0x1234&&debug.baseline[0]==0x537c,"unsigned LE readings"); + invalid(()->new Protocol.TouchDebug(new byte[143]),"Short debug rejected"); + byte[] encoded=reply(0x12,0,data); + for(int split=0;split<=encoded.length;split++){ + List got=new ArrayList<>();Protocol.CommandParser parser=new Protocol.CommandParser((cmd,status,payload)->got.add(payload)); + parser.feed(Arrays.copyOfRange(encoded,0,split),split);parser.feed(Arrays.copyOfRange(encoded,split,encoded.length),encoded.length-split); + check(got.size()==1&&Arrays.equals(got.get(0),data),"Split command at "+split); + } + for(int value=0;value<256;value++){ + byte[] payload={(byte)value,0x53,0x7c};List got=new ArrayList<>();Protocol.CommandParser parser=new Protocol.CommandParser((c,s,p)->got.add(p)); + byte[] b=reply(0x20,0,payload);for(byte v:b)parser.feed(new byte[]{v},1); + check(got.size()==1&&Arrays.equals(got.get(0),payload),"All octets including escaped final byte "+value); + } + List cmds=new ArrayList<>();Protocol.CommandParser multi=new Protocol.CommandParser((c,s,p)->cmds.add(c)); + byte[] a=reply(0x20,0,new byte[]{1}),b=reply(0x12,0,data),c=reply(0x23,0,new byte[0]);ByteArrayOutputStream joined=new ByteArrayOutputStream();for(byte[] part:new byte[][]{new byte[]{5,8,0x53,8,7,90},a,b,c})joined.write(part,0,part.length);byte[] combined=joined.toByteArray();multi.feed(combined,combined.length); + check(cmds.equals(Arrays.asList(0x20,0x12,0x23)),"Noise, resync, debug interleave, empty ACK"); + byte[] original=new byte[156];new Random(2).nextBytes(original);Protocol.Config cfg=new Protocol.Config(original);cfg.sensitivity(33,512,255,128);cfg.brightness(50,60);byte[] modified=cfg.bytes(); + for(int i=0;i<156;i++)if(!(i>=132&&i<136)&&i!=139&&i!=140)check(original[i]==modified[i],"Preserve unrelated config byte "+i); + check(cfg.finger(33)==512&&cfg.noise(33)==255&&cfg.hysteresis(33)==128,"Config LE fields"); + modified[0]^=1;check(!Arrays.equals(modified,cfg.bytes()),"Defensive config copy"); + invalid(()->new Protocol.Config(new byte[155]),"Unknown config sizes rejected");invalid(()->cfg.sensitivity(34,1,1,1),"Zone bound");invalid(()->cfg.sensitivity(0,513,0,0),"Sensitivity bound"); + for(int zone=0;zone<34;zone++){ + List got=new ArrayList<>();Protocol.TouchParser parser=new Protocol.TouchParser(got::add);byte[] packet=new byte[9];packet[0]=40;packet[8]=41;packet[1+zone/5]=(byte)(1<<(zone%5));for(byte v:packet)parser.feed(new byte[]{v},1); + check(got.size()==1&&got.get(0)[zone],"Touch serial zone "+zone); + } + List malformed=new ArrayList<>();Protocol.TouchParser tp=new Protocol.TouchParser(malformed::add);tp.feed(new byte[]{40,1,2,41,40,32,0,0,0,0,0,0,41},13);check(malformed.isEmpty(),"Malformed touch rejected"); + check(Protocol.hex(Protocol.led(17,0x31,new byte[]{3,(byte)255,(byte)255,(byte)255})).equals("E01101053103FFFFFF48"),"Published Mai2LED single LED vector"); + check(Protocol.hex(Protocol.led(17,0x39,new byte[]{(byte)0xd0,(byte)0xd0,(byte)255})).equals("E011010439D0CFD0CFFFEE"),"Published Mai2LED escape vector"); + check(Protocol.hex(Protocol.led(17,0x3c,new byte[0])).equals("E01101013C4F"),"Published LED update vector"); + byte[] ack={(byte)0xe0,1,17,3,1,0x3c,1,0x53};List gotAck=new ArrayList<>();Protocol.LedParser lp=new Protocol.LedParser((source,cmd,status,report,p)->gotAck.add(cmd));for(byte v:ack)lp.feed(new byte[]{v},1);check(gotAck.equals(Arrays.asList(0x3c)),"LED ACK checksum");ack[7]=0;lp.feed(ack,ack.length);check(gotAck.size()==1,"LED bad checksum rejected"); + byte[] io4=new byte[64];io4[0]=1;int idle=0xf80d;io4[29]=(byte)idle;io4[30]=(byte)(idle>>8);boolean[] idleButtons=Protocol.io4(io4,0);check(!idleButtons[0]&&!idleButtons[7],"IO4 active-low idle");io4[29]&=~4;check(Protocol.io4(io4,0)[0],"IO4 button 1");invalid(()->Protocol.io4(new byte[16],0),"Unknown HID report not guessed"); + check(!Protocol.io4P1(io4),"IO4 P1 idle"); + io4[29]|=2; check(Protocol.io4P1(io4),"IO4 P1 active high independent of button bank"); + io4[29]&=~2; check(!Protocol.io4P1(io4),"IO4 P1 release"); + io4[29]|=64; check(!Protocol.io4P1(io4),"IO4 P2 is not P1"); + invalid(()->Protocol.io4P1(new byte[16]),"P1 rejects unknown report"); + System.out.println("PASS: "+checks+" protocol checks"); + } +} diff --git a/Tools/OniimaiTests/SetupDefaultsTest.java b/Tools/OniimaiTests/SetupDefaultsTest.java new file mode 100644 index 000000000..c48c03a55 --- /dev/null +++ b/Tools/OniimaiTests/SetupDefaultsTest.java @@ -0,0 +1,38 @@ +package net.majdata.majdataplay.oniimai; +import java.util.*; +public final class SetupDefaultsTest { + private static int checks; + private static void check(boolean ok){checks++;if(!ok)throw new AssertionError("check "+checks);} + public static void main(String[] args){ + Map fresh=SetupDefaults.missing(Collections.emptyMap()); + check(Boolean.FALSE.equals(fresh.get("setupComplete"))); + check(Boolean.TRUE.equals(fresh.get("led"))); + check(Boolean.TRUE.equals(fresh.get("input"))); + check(Boolean.TRUE.equals(fresh.get("autoConnect"))); + check(Integer.valueOf(1).equals(fresh.get("touchMode"))); + check(Integer.valueOf(2).equals(fresh.get("buttonMode"))); + check(Boolean.TRUE.equals(fresh.get("external"))); + check(Integer.valueOf(60).equals(fresh.get("frameRate"))); + check(SetupDefaults.missing(fresh).isEmpty()); // A deferred first run remains pending. + Map legacy=new HashMap<>();legacy.put("led",false);legacy.put("touchMode",0);legacy.put("touchPort","user-command-port");legacy.put("input",false);legacy.put("buttonMode",1);legacy.put("external",false); + Map patch=SetupDefaults.missing(legacy); + check(Boolean.TRUE.equals(patch.get("setupComplete"))); + check(patch.size()==2); // Only missing setup state and frame-rate choice are added. + check(Integer.valueOf(60).equals(patch.get("frameRate"))); + legacy.put("frameRate",120); + check(!SetupDefaults.missing(legacy).containsKey("frameRate")); + legacy.putAll(patch);check(SetupDefaults.missing(legacy).isEmpty()); + check(Integer.valueOf(1).equals(legacy.get("buttonMode"))); // Preserve an explicit keyboard choice. + check(Boolean.FALSE.equals(legacy.get("external"))); // Explicit phone-only output is not reset. + legacy.remove("buttonMode");check(Integer.valueOf(2).equals(SetupDefaults.missing(legacy).get("buttonMode"))); + Map pending=new HashMap<>(fresh);pending.put("setupStep",2);pending.put("led",false); + check(SetupDefaults.missing(pending).isEmpty()); + pending.put("setupComplete",true);check(SetupDefaults.missing(pending).isEmpty()); + Map partial=new HashMap<>();partial.put("language","zh-Hans"); + patch=SetupDefaults.missing(partial);check(Boolean.TRUE.equals(patch.get("led")));check(Integer.valueOf(1).equals(patch.get("touchMode")));check(!patch.containsKey("language")); + check(Integer.valueOf(2).equals(patch.get("buttonMode"))); + check(Boolean.TRUE.equals(patch.get("external"))); + check(Integer.valueOf(60).equals(patch.get("frameRate"))); + System.out.println("SetupDefaults: "+checks+" checks passed"); + } +} diff --git a/Tools/OniimaiTests/WidgetFieldsTest.java b/Tools/OniimaiTests/WidgetFieldsTest.java new file mode 100644 index 000000000..09e26f25d --- /dev/null +++ b/Tools/OniimaiTests/WidgetFieldsTest.java @@ -0,0 +1,33 @@ +package net.majdata.majdataplay.oniimai; + +import java.util.HashMap; +import java.util.Map; + +public final class WidgetFieldsTest { + private static int checks; + private static void check(boolean ok) { checks++; if(!ok)throw new AssertionError("check "+checks); } + private static boolean changed(String type,Map a,Map b){return WidgetFields.changed(type,a::get,b::get);} + public static void main(String[] args){ + Map before=new HashMap<>();before.put("game",true);before.put("scene","Game");before.put("seconds",0.0); + String[] stationary={"song","score","judgments","timing","connection","clock","sensors"}; + for(int i=1;i<=600;i++){ + Map next=new HashMap<>(before);next.put("seconds",i/10.0); + check(changed("graph",before,next)); + for(String type:stationary)check(!changed(type,before,next)); + before=next; + } + String[][] fields={{"song","title","artist","level"},{"score","achievement","combo","dx"}, + {"judgments","critical","perfect","great","good","miss"},{"timing","fast","late"},{"graph","seconds","length"}}; + for(String[] group:fields){ + for(int i=1;i next=new HashMap<>(before);next.put(group[i],"changed"); + check(changed(group[0],before,next)); + check(!changed(group[0],next,new HashMap<>(next))); + } + Map result=new HashMap<>(before);result.put("scene","Result"); + check(changed(group[0],before,result)); + check(changed(group[0],result,Map.of("game",false,"scene","List"))); + } + System.out.println("PASS: "+checks+" widget invalidation checks"); + } +} diff --git a/Tools/OniimaiTests/fakes/UsbIo.java b/Tools/OniimaiTests/fakes/UsbIo.java new file mode 100644 index 000000000..52a252888 --- /dev/null +++ b/Tools/OniimaiTests/fakes/UsbIo.java @@ -0,0 +1,28 @@ +package net.majdata.majdataplay.oniimai; +import java.io.IOException; +import java.util.function.Consumer; + +/** JVM-only fake. This file is never compiled into the Android app. */ +public final class UsbIo { + public interface Bytes {void accept(byte[] data,int length);} + public interface Failure {void accept(String message);} + public static final class Port {} + public static final class Hid { + public volatile int rgb=-1, writes; + public volatile boolean fail, closed; + public void writeCeiling(int rgb) throws IOException { + if (fail || closed) throw new IOException("output unavailable"); + this.rgb=rgb; writes++; + } + } + public static final class Cdc { + public static volatile Consumer opened; + public Cdc(){} + public Cdc(android.hardware.usb.UsbManager manager,Port port,int baud){if(opened!=null)opened.accept(this);} + public Consumer onWrite;public Bytes incoming;public Failure failure;public boolean closed; + public void start(Bytes bytes,Failure fail){incoming=bytes;failure=fail;} + public void write(byte[] bytes) throws IOException {if(closed)throw new IOException("closed");if(onWrite!=null)onWrite.accept(bytes);} + public void receive(byte[] bytes){incoming.accept(bytes,bytes.length);} + public void close(){closed=true;} + } +} diff --git a/Tools/OniimaiTests/fakes/android/hardware/usb/UsbManager.java b/Tools/OniimaiTests/fakes/android/hardware/usb/UsbManager.java new file mode 100644 index 000000000..5047b2d10 --- /dev/null +++ b/Tools/OniimaiTests/fakes/android/hardware/usb/UsbManager.java @@ -0,0 +1,2 @@ +package android.hardware.usb; +public class UsbManager {} diff --git a/Tools/OniimaiTests/fakes/android/os/SystemClock.java b/Tools/OniimaiTests/fakes/android/os/SystemClock.java new file mode 100644 index 000000000..62f9ca631 --- /dev/null +++ b/Tools/OniimaiTests/fakes/android/os/SystemClock.java @@ -0,0 +1,2 @@ +package android.os; +public class SystemClock {public static long uptimeMillis(){return System.nanoTime()/1000000;}} diff --git a/Tools/OniimaiTests/hid-transport/UiText.java.fixture b/Tools/OniimaiTests/hid-transport/UiText.java.fixture new file mode 100644 index 000000000..f3cee1ebc --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UiText.java.fixture @@ -0,0 +1,2 @@ +package net.majdata.majdataplay.oniimai; +final class UiText {static String t(String value){return value;}} diff --git a/Tools/OniimaiTests/hid-transport/UsbConstants.java.fixture b/Tools/OniimaiTests/hid-transport/UsbConstants.java.fixture new file mode 100644 index 000000000..f9f9068b6 --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbConstants.java.fixture @@ -0,0 +1,4 @@ +package android.hardware.usb; +public final class UsbConstants { + public static final int USB_ENDPOINT_XFER_BULK=2, USB_DIR_IN=128; +} diff --git a/Tools/OniimaiTests/hid-transport/UsbDevice.java.fixture b/Tools/OniimaiTests/hid-transport/UsbDevice.java.fixture new file mode 100644 index 000000000..aba036999 --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbDevice.java.fixture @@ -0,0 +1,11 @@ +package android.hardware.usb; +public final class UsbDevice { + private final UsbInterface[] interfaces; + public UsbDevice(UsbInterface... interfaces){this.interfaces=interfaces;} + public int getVendorId(){return 1;} + public int getProductId(){return 2;} + public String getSerialNumber(){return "TEST";} + public String getDeviceName(){return "/test/composite";} + public int getInterfaceCount(){return interfaces.length;} + public UsbInterface getInterface(int n){return interfaces[n];} +} diff --git a/Tools/OniimaiTests/hid-transport/UsbDeviceConnection.java.fixture b/Tools/OniimaiTests/hid-transport/UsbDeviceConnection.java.fixture new file mode 100644 index 000000000..4517ccf98 --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbDeviceConnection.java.fixture @@ -0,0 +1,46 @@ +package android.hardware.usb; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicInteger; +public final class UsbDeviceConnection { + public int failClaim=-1,throwRelease=-1;public boolean failCoding; + public final List claims=Collections.synchronizedList(new ArrayList<>()),releases=Collections.synchronizedList(new ArrayList<>()); + public final AtomicInteger dtrOn=new AtomicInteger(),dtrOff=new AtomicInteger(),coding=new AtomicInteger(),closes=new AtomicInteger(),writes=new AtomicInteger(); + public volatile CountDownLatch writing,proceed; + public final CountDownLatch inputQueued=new CountDownLatch(1),outputQueued=new CountDownLatch(1); + public final LinkedBlockingQueue completed=new LinkedBlockingQueue<>(); + public final AtomicInteger outputWrites=new AtomicInteger(),setReports=new AtomicInteger(); + public volatile UsbRequest input,output; + public volatile boolean blockOutput,shortOutput,failOutputQueue; + public volatile byte[] lastReport; + public int reportType,reportRequest,reportValue,reportIndex,reportTimeout; + public final List lineStates=new ArrayList<>(); + public byte[] getRawDescriptors(){return null;} + public boolean claimInterface(UsbInterface value,boolean force){claims.add(value.getId());return value.getId()!=failClaim;} + public boolean releaseInterface(UsbInterface value){releases.add(value.getId());if(value.getId()==throwRelease)throw new IllegalStateException("Release failed");return true;} + public int controlTransfer(int type,int request,int value,int index,byte[] data,int length,int timeout){ + if(request==0x20){coding.incrementAndGet();return failCoding?-1:length;} + if(request==0x22){lineStates.add(value);if(value==0)dtrOff.incrementAndGet();else dtrOn.incrementAndGet();} + if(request==9){ + setReports.incrementAndGet();lastReport=Arrays.copyOf(data,length); + reportType=type;reportRequest=request;reportValue=value;reportIndex=index;reportTimeout=timeout; + blockWrite();if(closes.get()!=0)throw new IllegalStateException("control raced closed fd"); + return shortOutput?10:length; + } + return length; + } + public int bulkTransfer(UsbEndpoint endpoint,byte[] data,int length,int timeout){return -1;} + public int bulkTransfer(UsbEndpoint endpoint,byte[] data,int offset,int length,int timeout){ + writes.incrementAndGet();blockWrite(); + if(closes.get()!=0)throw new IllegalStateException("write raced closed fd");return length; + } + private void blockWrite(){CountDownLatch started=writing,go=proceed; + if(started!=null){started.countDown();try{if(!go.await(2,TimeUnit.SECONDS))throw new IllegalStateException("test write stalled");}catch(InterruptedException e){throw new IllegalStateException(e);}} + } + public UsbRequest requestWait(long timeout)throws TimeoutException{ + try{UsbRequest done=completed.poll(timeout,TimeUnit.MILLISECONDS);if(done==null)throw new TimeoutException();done.queued=false;done.buffer.position(done.transferred);return done;} + catch(InterruptedException e){Thread.currentThread().interrupt();throw new IllegalStateException(e);} + } + public void deliverInput(byte[] bytes){UsbRequest r=input;if(r==null||!r.queued)throw new IllegalStateException("input not queued");r.buffer.clear();r.buffer.put(bytes);r.transferred=bytes.length;completed.offer(r);} + public void close(){closes.incrementAndGet();if(input!=null)input.cancel();if(output!=null)output.cancel();} +} diff --git a/Tools/OniimaiTests/hid-transport/UsbEndpoint.java.fixture b/Tools/OniimaiTests/hid-transport/UsbEndpoint.java.fixture new file mode 100644 index 000000000..adefaae67 --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbEndpoint.java.fixture @@ -0,0 +1,8 @@ +package android.hardware.usb; +public final class UsbEndpoint { + private final int direction,type; + public UsbEndpoint(int direction,int type){this.direction=direction;this.type=type;} + public int getDirection(){return direction;} + public int getType(){return type;} + public int getMaxPacketSize(){return 16;} +} diff --git a/Tools/OniimaiTests/hid-transport/UsbInterface.java.fixture b/Tools/OniimaiTests/hid-transport/UsbInterface.java.fixture new file mode 100644 index 000000000..7db4907d2 --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbInterface.java.fixture @@ -0,0 +1,12 @@ +package android.hardware.usb; +public final class UsbInterface { + private final int id,type;private final UsbEndpoint[] endpoints; + public UsbInterface(int id,int type,UsbEndpoint... endpoints){this.id=id;this.type=type;this.endpoints=endpoints;} + public int getId(){return id;} + public int getInterfaceClass(){return type;} + public int getInterfaceSubclass(){return 2;} + public int getAlternateSetting(){return 0;} + public String getName(){return "Test IF"+id;} + public int getEndpointCount(){return endpoints.length;} + public UsbEndpoint getEndpoint(int n){return endpoints[n];} +} diff --git a/Tools/OniimaiTests/hid-transport/UsbManager.java.fixture b/Tools/OniimaiTests/hid-transport/UsbManager.java.fixture new file mode 100644 index 000000000..55c110d2e --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbManager.java.fixture @@ -0,0 +1,9 @@ +package android.hardware.usb; +import java.util.*; +public final class UsbManager { + private final UsbDeviceConnection connection; + public UsbManager(UsbDeviceConnection connection){this.connection=connection;} + public boolean hasPermission(UsbDevice device){return true;} + public UsbDeviceConnection openDevice(UsbDevice device){return connection;} + public Map getDeviceList(){return Collections.emptyMap();} +} diff --git a/Tools/OniimaiTests/hid-transport/UsbRequest.java.fixture b/Tools/OniimaiTests/hid-transport/UsbRequest.java.fixture new file mode 100644 index 000000000..483ee5a2d --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbRequest.java.fixture @@ -0,0 +1,22 @@ +package android.hardware.usb; +import java.nio.ByteBuffer; +public final class UsbRequest { + UsbDeviceConnection connection;UsbEndpoint endpoint;ByteBuffer buffer; + volatile boolean queued;volatile int transferred; + public boolean initialize(UsbDeviceConnection connection,UsbEndpoint endpoint){this.connection=connection;this.endpoint=endpoint;return true;} + public boolean queue(ByteBuffer buffer){ + if(connection.closes.get()!=0)throw new IllegalStateException("queue raced closed fd"); + this.buffer=buffer;queued=true;transferred=0; + if(endpoint.getDirection()==128){connection.input=this;connection.inputQueued.countDown();} + else { + if(connection.failOutputQueue){queued=false;return false;} + connection.output=this;connection.outputWrites.incrementAndGet(); + byte[] report=new byte[buffer.remaining()];buffer.duplicate().get(report);connection.lastReport=report; + connection.outputQueued.countDown(); + if(!connection.blockOutput){transferred=connection.shortOutput?10:report.length;connection.completed.offer(this);} + } + return true; + } + public boolean cancel(){if(queued){queued=false;transferred=0;connection.completed.offer(this);}return true;} + public void close(){if(queued)throw new IllegalStateException("request closed before cancellation");} +} diff --git a/Tools/OniimaiTests/hid-transport/UsbTransportTest.java.fixture b/Tools/OniimaiTests/hid-transport/UsbTransportTest.java.fixture new file mode 100644 index 000000000..e8510f776 --- /dev/null +++ b/Tools/OniimaiTests/hid-transport/UsbTransportTest.java.fixture @@ -0,0 +1,69 @@ +package net.majdata.majdataplay.oniimai; +import android.hardware.usb.*; +import java.io.IOException; +import java.util.*; +import java.util.concurrent.*; +import java.util.concurrent.atomic.AtomicReference; + +public final class UsbTransportTest { + private static int checks; + private static void check(boolean ok,String message){if(!ok)throw new AssertionError(message);checks++;} + private static UsbIo.Hid hid(UsbDeviceConnection conn,boolean withOut,UsbIo.Bytes bytes,UsbIo.Failure failure)throws Exception{ + UsbInterface intf=withOut?new UsbInterface(7,3,new UsbEndpoint(128,3),new UsbEndpoint(0,3)):new UsbInterface(7,3,new UsbEndpoint(128,3)); + UsbIo.Hid hid=new UsbIo.Hid(new UsbManager(conn),new UsbIo.Port(new UsbDevice(intf),intf,"IO4"),bytes,failure); + check(conn.inputQueued.await(1,TimeUnit.SECONDS),"HID input reader did not queue");return hid; + } + private static void writeFails(UsbIo.Hid h){try{h.writeCeiling(0x123456);throw new AssertionError("output failure not reported");}catch(IOException expected){checks++;}} + public static void main(String[] args)throws Exception { ceilingTests(); System.out.println("PASS: "+checks+" real HID transport checks"); } + private static void ceilingTests()throws Exception{ + UsbDeviceConnection out=new UsbDeviceConnection();List errors=Collections.synchronizedList(new ArrayList<>()); + CountDownLatch input=new CountDownLatch(1);AtomicReference inputBytes=new AtomicReference<>(); + UsbIo.Hid h=hid(out,true,(bytes,n)->{inputBytes.set(bytes);input.countDown();},errors::add); + h.writeCeiling(0x12d0e0); + check(out.outputWrites.get()==1&&out.setReports.get()==0,"interrupt OUT must be preferred to control"); + check(Arrays.equals(out.lastReport,Io4Output.ceiling(0x12d0e0)),"interrupt report packing"); + check(out.claims.equals(Arrays.asList(7)),"ceiling output claimed another USB handle/interface"); + out.deliverInput(new byte[]{1,2,3});check(input.await(1,TimeUnit.SECONDS),"input lost after output completion"); + check(Arrays.equals(inputBytes.get(),new byte[]{1,2,3})&&errors.isEmpty(),"output completion was dispatched as button input"); + h.writeCeiling(0);check(out.outputWrites.get()==2,"second output failed");h.close();h.close(); + writeFails(h);check(out.closes.get()==1&&out.outputWrites.get()==2,"closed HID sent report or double closed fd"); + + UsbDeviceConnection control=new UsbDeviceConnection();UsbIo.Hid c=hid(control,false,(bytes,n)->{},errors::add); + c.writeCeiling(0x123456); + check(control.setReports.get()==1&&control.outputWrites.get()==0,"missing OUT did not use SET_REPORT"); + check(control.reportType==0x21&&control.reportRequest==9&&control.reportValue==0x210&&control.reportIndex==7,"SET_REPORT setup incorrect"); + check(control.reportTimeout>0&&control.reportTimeout<=500&&Arrays.equals(control.lastReport,Io4Output.ceiling(0x123456)),"SET_REPORT bound or payload incorrect");c.close(); + + UsbDeviceConnection shortReport=new UsbDeviceConnection();shortReport.shortOutput=true; + UsbIo.Hid sh=hid(shortReport,true,(bytes,n)->{},errors::add);writeFails(sh);writeFails(sh); + check(shortReport.outputWrites.get()==1&&shortReport.closes.get()==0,"short report should disable only output");sh.close(); + + UsbDeviceConnection stall=new UsbDeviceConnection();stall.blockOutput=true; + CountDownLatch stillInput=new CountDownLatch(1);UsbIo.Hid st=hid(stall,true,(bytes,n)->stillInput.countDown(),errors::add); + long before=System.nanoTime();writeFails(st); + check(System.nanoTime()-before{},errors::add);AtomicReference failure=new AtomicReference<>(); + Thread writer=new Thread(()->{try{ch.writeCeiling(0xff0000);}catch(Throwable e){failure.set(e);}});writer.start(); + check(closing.outputQueued.await(1,TimeUnit.SECONDS),"close test output did not queue"); + Thread close=new Thread(ch::close);close.start();writer.join(1500);close.join(1500); + check(!writer.isAlive()&&!close.isAlive()&&failure.get() instanceof IOException,"HID close did not cancel pending output"); + check(closing.closes.get()==1&&closing.outputWrites.get()==1,"pending output cleanup count"); + + UsbDeviceConnection controlClosing=new UsbDeviceConnection();controlClosing.writing=new CountDownLatch(1);controlClosing.proceed=new CountDownLatch(1); + UsbIo.Hid cc=hid(controlClosing,false,(bytes,n)->{},errors::add);AtomicReference controlFailure=new AtomicReference<>(); + Thread controlWriter=new Thread(()->{try{cc.writeCeiling(0xabc123);}catch(Throwable e){controlFailure.set(e);}});controlWriter.start(); + check(controlClosing.writing.await(1,TimeUnit.SECONDS),"control write did not start"); + Thread controlCloser=new Thread(cc::close);controlCloser.start(); + long deadline=System.nanoTime()+TimeUnit.SECONDS.toNanos(1); + while(controlCloser.getState()!=Thread.State.BLOCKED&&System.nanoTime()